diff --git a/src/DLSSUpdater/Addon/AddonServer.cs b/src/DLSSUpdater/Addon/AddonServer.cs index 9fb8ef9..93b14b1 100644 --- a/src/DLSSUpdater/Addon/AddonServer.cs +++ b/src/DLSSUpdater/Addon/AddonServer.cs @@ -153,19 +153,29 @@ internal static List Reverted(string targetDir) { var m = InstallManifest.Load(targetDir); if (m is null) return []; - var root = FileUtil.Normalize(Path.Combine(targetDir, m.RootRel)); - var dlssNames = ComponentStore.DlssFiles; - var out_ = new List(); - foreach (var b in m.Backups.Where(b => b.Kind == "dlss" && b.InstalledSha is not null)) + try + { + var root = FileUtil.Normalize(Path.Combine(targetDir, m.RootRel)); + string Full(string entry) => InstallManifest.Resolve(entry, targetDir, root); + var dlssNames = ComponentStore.DlssFiles; + var out_ = new List(); + foreach (var b in m.Backups.Where(b => b.Kind == "dlss" && b.InstalledSha is not null)) + { + var path = Full(b.Original); + if (!File.Exists(path) || !string.Equals(HashCache.Get(path), b.InstalledSha, StringComparison.OrdinalIgnoreCase)) + out_.Add(b.Original); + } + foreach (var rel in m.Files.Where(f => dlssNames.Contains(Path.GetFileName(f), StringComparer.OrdinalIgnoreCase))) + if (!File.Exists(Full(rel)) && !out_.Contains(rel, StringComparer.OrdinalIgnoreCase)) + out_.Add(rel); + return out_; + } + catch (UnsafeManifestException ex) { - var path = Path.Combine(root, b.Original); - if (!File.Exists(path) || !string.Equals(HashCache.Get(path), b.InstalledSha, StringComparison.OrdinalIgnoreCase)) - out_.Add(b.Original); + // Display only: an install or uninstall reports the same record properly. + Log.Error(ex.Message); + return []; } - foreach (var rel in m.Files.Where(f => dlssNames.Contains(Path.GetFileName(f), StringComparer.OrdinalIgnoreCase))) - if (!File.Exists(Path.Combine(root, rel)) && !out_.Contains(rel, StringComparer.OrdinalIgnoreCase)) - out_.Add(rel); - return out_; } private static string Short(Version v) => $"{v.Major}.{v.Minor}.{Math.Max(v.Build, 0)}"; @@ -254,7 +264,8 @@ private async Task BeforeLaunchAsync(GameInfo info, CancellationToke Progress($"A game update replaced {string.Join(", ", reverted.Select(Path.GetFileName).Distinct())}; putting DLSS back"); await EnsureStoreAsync(ct); var vm = Model(info); - var o = vm.UpdateOptions(m); + // Put back what was installed; moving OptiScaler or MFG Unlock to a new release takes a click in DLSS Updater. + var o = vm.UpdateOptions(m).KeepingInstalledVersions(m); if (vm.HasAntiCheat && vm.NeedsInjection(o) && !m.AntiCheatConfirmed) throw new AddonException(-32000, $"{info.Name} uses {info.AntiCheat}; update it in DLSS Updater"); await _services.Value.Installer.InstallAsync(info, target, o, null, ct); diff --git a/src/DLSSUpdater/Core/AppSettings.cs b/src/DLSSUpdater/Core/AppSettings.cs index 6154a69..2fcd78b 100644 --- a/src/DLSSUpdater/Core/AppSettings.cs +++ b/src/DLSSUpdater/Core/AppSettings.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using DLSSUpdater.Scan; @@ -40,6 +42,10 @@ public sealed class AppSettings public static readonly string[] ReShadeProxyNames = ["dxgi.dll", "d3d12.dll", "d3d11.dll", "d3d9.dll", "dinput8.dll", "opengl32.dll"]; + /// A proxy name DLSS Updater would ever install under (OptiScaler or ReShade-only). + public static bool IsKnownProxy(string? name) => + name is not null && (ProxyNames.Contains(name, StringComparer.OrdinalIgnoreCase) || ReShadeProxyNames.Contains(name, StringComparer.OrdinalIgnoreCase)); + public string DefaultProxy { get; set; } = "dxgi.dll"; /// What a new install puts in: OptiScaler-NR (the main purpose) or ReShade with add-ons only. public InstallMode DefaultMode { get; set; } = InstallMode.OptiScaler; @@ -54,7 +60,29 @@ public sealed class AppSettings public bool CarryOverGameIni { get; set; } = true; /// keep | apply | fresh — how existing game configs are treated. public string IniMode { get; set; } = "keep"; - public string? GitHubToken { get; set; } + /// GitHub token; settings.json holds it only encrypted for this Windows user (DPAPI). + [JsonIgnore] + public string? GitHubToken + { + get => Unprotect(GitHubTokenProtected); + set => GitHubTokenProtected = Protect(value); + } + public string? GitHubTokenProtected { get; set; } + + /// The plain-text token 1.5.1 and earlier saved: read once, then saved encrypted, never written. + [JsonPropertyName("GitHubToken")] + public string? LegacyGitHubToken + { + get => null; + set + { + if (string.IsNullOrWhiteSpace(value)) return; + GitHubToken = value; + _legacyTokenRead = true; + } + } + + private bool _legacyTokenRead; public List IniOverrides { get; set; } = ConfigProfile.Defaults(); public List ReShadeOverrides { get; set; } = ConfigProfile.ReShadeDefaults(); public bool InstallStreamline { get; set; } @@ -88,6 +116,7 @@ public static AppSettings Load() if (!s.CarryOverGameIni && s.IniMode == "keep") s.IniMode = "fresh"; s.CarryOverGameIni = true; s.Presets.RemoveAll(p => p.Name.Equals(ConfigProfile.RecommendedName, StringComparison.OrdinalIgnoreCase)); + if (s._legacyTokenRead) s.Save(); return s; } } @@ -104,6 +133,23 @@ public void Save() try { FileUtil.AtomicWriteText(AppPaths.SettingsFile, JsonSerializer.Serialize(this, JsonCtx.Default.AppSettings)); } catch (IOException ex) { Log.Error("Could not save settings", ex); } } + + private static string? Protect(string? secret) => + string.IsNullOrEmpty(secret) + ? null + : Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes(secret), null, DataProtectionScope.CurrentUser)); + + private static string? Unprotect(string? stored) + { + if (string.IsNullOrEmpty(stored)) return null; + try { return Encoding.UTF8.GetString(ProtectedData.Unprotect(Convert.FromBase64String(stored), null, DataProtectionScope.CurrentUser)); } + catch (Exception ex) when (ex is CryptographicException or FormatException) + { + // Encrypted by another Windows user or PC: the token has to be entered again. + Log.Error("Saved GitHub token can't be decrypted on this account", ex); + return null; + } + } } [JsonSourceGenerationOptions(WriteIndented = true, PropertyNameCaseInsensitive = true, diff --git a/src/DLSSUpdater/Core/ComponentStore.cs b/src/DLSSUpdater/Core/ComponentStore.cs index 1f6120b..3db0fe3 100644 --- a/src/DLSSUpdater/Core/ComponentStore.cs +++ b/src/DLSSUpdater/Core/ComponentStore.cs @@ -155,8 +155,7 @@ private static async Task Resolve(Component c, Func a.Name.Equals(MfgFile, StringComparison.OrdinalIgnoreCase)) - ?? r.Assets.FirstOrDefault(a => a.Name.EndsWith(".addon64", StringComparison.OrdinalIgnoreCase)); + var addon = r.Assets.FirstOrDefault(a => a.Name.Equals(MfgFile, StringComparison.OrdinalIgnoreCase)); if (addon is null) continue; return new ReleaseInfo { @@ -292,10 +291,14 @@ private static void Prune(Component c, int keep) // ---------- ensure (download + verify) ---------- - /// Returns the folder holding the extracted OptiScaler-NR package. - public async Task EnsureOptiAsync(IProgress? progress, CancellationToken ct) + /// + /// Returns the folder holding the extracted OptiScaler-NR package. null means the newest + /// release; another tag must already be cached. + /// + public async Task EnsureOptiAsync(string? tag, IProgress? progress, CancellationToken ct) { - var r = Opti ?? throw new InvalidOperationException("OptiScaler-NR release unknown (offline and nothing cached)."); + var r = CachedOrNewest(Component.OptiScaler, tag, Opti, "OptiScaler-NR") + ?? throw new InvalidOperationException("OptiScaler-NR release unknown (offline and nothing cached)."); var dir = TagDir(Component.OptiScaler, r.Tag); var pkg = Path.Combine(dir, "pkg"); await _locks[Component.OptiScaler].WaitAsync(ct); @@ -324,12 +327,18 @@ public async Task EnsureOptiAsync(IProgress? progress, finally { _locks[Component.OptiScaler].Release(); } } - /// Returns the path of the MFG Unlock addon. - public async Task EnsureMfgAsync(IProgress? progress, CancellationToken ct) + /// + /// Returns the path of the MFG Unlock addon. null means the newest release, "local" the + /// imported file; another tag must already be cached. + /// + public async Task EnsureMfgAsync(string? tag, IProgress? progress, CancellationToken ct) { var local = Path.Combine(AppPaths.Components, MfgFile); - if (Mfg is null && File.Exists(local)) return local; - var r = Mfg ?? throw new InvalidOperationException("MFG Unlock release unknown (offline and nothing cached)."); + if (tag == LocalMfgTag) + return File.Exists(local) ? local : throw new FileNotFoundException("The imported MFG Unlock add-on is gone.", local); + if (tag is null && Mfg is null && File.Exists(local)) return local; + var r = CachedOrNewest(Component.MfgUnlock, tag, Mfg, "MFG Unlock") + ?? throw new InvalidOperationException("MFG Unlock release unknown (offline and nothing cached)."); var dir = TagDir(Component.MfgUnlock, r.Tag); var file = Path.Combine(dir, MfgFile); await _locks[Component.MfgUnlock].WaitAsync(ct); @@ -348,6 +357,21 @@ public async Task EnsureMfgAsync(IProgress? progress, finally { _locks[Component.MfgUnlock].Release(); } } + /// Tag recorded for an MFG Unlock add-on the user imported instead of a release. + public const string LocalMfgTag = "local"; + + /// True when that MFG Unlock version can be installed without a download. + public static bool HasMfg(string tag) => + tag == LocalMfgTag ? File.Exists(Path.Combine(AppPaths.Components, MfgFile)) : IsCached(Component.MfgUnlock, tag); + + private static ReleaseInfo? CachedOrNewest(Component c, string? tag, ReleaseInfo? newest, string label) + { + if (tag is null || (newest is not null && newest.Tag.Equals(tag, StringComparison.OrdinalIgnoreCase))) return newest; + return IsCached(c, tag) + ? new ReleaseInfo { Tag = tag, FromCache = true } + : throw new InvalidOperationException($"{label} {tag} is no longer cached."); + } + /// /// Returns file name -> cached path for the nvngx_dlss / dlssd / dlssg files the release has /// (older SDKs ship without RR or FG). null means the latest release. @@ -372,7 +396,7 @@ IReadOnlyDictionary Present() => DlssFiles foreach (var (name, url, _) in r.Files) { var src = url; - if (!await GitHubClient.ExistsAsync(src, ct)) + if (!await _gh.ExistsAsync(src, ct)) { if (!latest) continue; // this SDK version has no such file src = DlssRawUrl("main", name); @@ -495,9 +519,14 @@ internal static int AppendedZipStart(byte[] data) throw new InvalidDataException("No zip archive found in the ReShade setup."); } + /// Checks a GitHub release asset against the digest GitHub published for it; no digest is a failure. private static async Task VerifyAsync(string file, string? sha256, CancellationToken ct) { - if (sha256 is null) return; + if (sha256 is null) + { + File.Delete(file); + throw new InvalidDataException($"{Path.GetFileName(file)}: GitHub published no SHA-256 digest for it, so it can't be verified."); + } var actual = await Task.Run(() => FileUtil.Sha256(file), ct); if (!actual.Equals(sha256, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/DLSSUpdater/Core/FileUtil.cs b/src/DLSSUpdater/Core/FileUtil.cs index 35d75fa..b8b76f2 100644 --- a/src/DLSSUpdater/Core/FileUtil.cs +++ b/src/DLSSUpdater/Core/FileUtil.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Security.Cryptography; +using System.Text; namespace DLSSUpdater.Core; @@ -68,11 +69,16 @@ public static void AtomicCopy(string src, string dest) File.Move(tmp, dest, true); } + /// Writes via a temp file that is flushed to disk before the rename, so a power loss leaves the old or the new text, never a torn file. public static void AtomicWriteText(string dest, string text) { Directory.CreateDirectory(Path.GetDirectoryName(dest)!); var tmp = dest + ".dlssu-tmp"; - File.WriteAllText(tmp, text); + using (var fs = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None)) + { + fs.Write(new UTF8Encoding(false).GetBytes(text)); + fs.Flush(flushToDisk: true); + } File.Move(tmp, dest, true); } @@ -85,6 +91,35 @@ public static bool IsUnder(string path, string root) public static string Normalize(string path) => Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar); + /// + /// The first junction or symbolic link on the way from (not included) down to + /// (included), or null. Other reparse points (OneDrive placeholders, dedup) are not links. + /// + public static string? LinkBelow(string root, string path) + { + var r = Normalize(root); + var cur = Normalize(path); + while (cur.Length > r.Length && IsUnder(cur, r)) + { + if (IsLink(cur)) return cur; + cur = Path.GetDirectoryName(cur)!; + } + return null; + } + + private static bool IsLink(string path) + { + try + { + var attrs = File.GetAttributes(path); + if ((attrs & FileAttributes.ReparsePoint) == 0) return false; + FileSystemInfo info = (attrs & FileAttributes.Directory) != 0 ? new DirectoryInfo(path) : new FileInfo(path); + return info.LinkTarget is not null; + } + catch (FileNotFoundException) { return false; } + catch (DirectoryNotFoundException) { return false; } + } + public static void TryDeleteEmptyDir(string dir) { try diff --git a/src/DLSSUpdater/Core/GitHubClient.cs b/src/DLSSUpdater/Core/GitHubClient.cs index 60f1599..6f0b74d 100644 --- a/src/DLSSUpdater/Core/GitHubClient.cs +++ b/src/DLSSUpdater/Core/GitHubClient.cs @@ -38,20 +38,25 @@ public sealed class ApiCacheEntry /// Minimal GitHub REST client with ETag caching (304s don't count against the rate limit). public sealed class GitHubClient { - private static readonly HttpClient Http = CreateHttp(); + private static readonly HttpClient SharedHttp = CreateHttp(new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + PooledConnectionLifetime = TimeSpan.FromMinutes(10), + ConnectTimeout = TimeSpan.FromSeconds(15), + }); + private readonly HttpClient _http; private readonly object _cacheGate = new(); private Dictionary? _cache; + public GitHubClient() => _http = SharedHttp; + + /// For tests: every request goes to . + internal GitHubClient(HttpMessageHandler handler) => _http = CreateHttp(handler); + public string? Token { get; set; } - private static HttpClient CreateHttp() + private static HttpClient CreateHttp(HttpMessageHandler handler) { - var handler = new SocketsHttpHandler - { - AutomaticDecompression = DecompressionMethods.All, - PooledConnectionLifetime = TimeSpan.FromMinutes(10), - ConnectTimeout = TimeSpan.FromSeconds(15), - }; var http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("DLSSUpdater", "1.0")); return http; @@ -78,7 +83,7 @@ public async Task GetStringAsync(string url, CancellationToken ct) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(30)); - using var res = await Http.GetAsync(url, cts.Token); + using var res = await _http.GetAsync(url, cts.Token); res.EnsureSuccessStatusCode(); return await res.Content.ReadAsStringAsync(cts.Token); } @@ -97,7 +102,7 @@ private async Task GetApiAsync(string url, CancellationToken ct) using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(30)); - using var res = await Http.SendAsync(req, cts.Token); + using var res = await _http.SendAsync(req, cts.Token); if (res.StatusCode == HttpStatusCode.NotModified && cached is not null) return cached.Body; if (res.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.TooManyRequests && cached is not null) @@ -123,7 +128,7 @@ public async Task DownloadAsync(string url, string dest, string label, IProgress { Directory.CreateDirectory(Path.GetDirectoryName(dest)!); var tmp = dest + ".part"; - using (var res = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct)) + using (var res = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct)) { res.EnsureSuccessStatusCode(); var total = res.Content.Headers.ContentLength; @@ -149,11 +154,14 @@ public async Task DownloadAsync(string url, string dest, string label, IProgress File.Move(tmp, dest, true); } - public static async Task ExistsAsync(string url, CancellationToken ct) + /// True when the file is there, false only on a 404; anything else (rate limit, server error) throws. + public async Task ExistsAsync(string url, CancellationToken ct) { using var req = new HttpRequestMessage(HttpMethod.Head, url); - using var res = await Http.SendAsync(req, ct); - return res.IsSuccessStatusCode; + using var res = await _http.SendAsync(req, ct); + if (res.StatusCode == HttpStatusCode.NotFound) return false; + res.EnsureSuccessStatusCode(); + return true; } private Dictionary LoadCache() diff --git a/src/DLSSUpdater/Core/InstallManifest.cs b/src/DLSSUpdater/Core/InstallManifest.cs index c2ba824..19b7f64 100644 --- a/src/DLSSUpdater/Core/InstallManifest.cs +++ b/src/DLSSUpdater/Core/InstallManifest.cs @@ -3,11 +3,17 @@ namespace DLSSUpdater.Core; +public sealed class UnsafeManifestException(string targetDir, string detail) + : Exception($"DLSS Updater's install record in {targetDir} points outside the game and install folders ({detail}). Nothing was changed."); + public sealed class BackupEntry { - /// Original location, relative to the game root. + /// Original location, an entry as in . public string Original { get; set; } = ""; - /// Backup location, relative to the backup folder. + /// + /// Backup location, relative to the backup folder. Records from 0ed78f7 may hold "<original>.N" as a full path + /// instead (an install folder on another drive than the game). + /// public string Backup { get; set; } = ""; /// "dlss" for nvngx swaps, "file" for anything else we displaced. public string Kind { get; set; } = "file"; @@ -21,7 +27,7 @@ public sealed class InstallManifest public const string DirName = ".dlssupdater"; public int Schema { get; set; } = 1; - /// Game root relative to the target dir (e.g. "..\..\.."). + /// Game root relative to the target dir (e.g. "..\..\.."), or a full path when the two are on different drives. public string RootRel { get; set; } = "."; /// Proxy the game loads: OptiScaler's, or ReShade's in ReShade-only mode. public string? Proxy { get; set; } @@ -46,9 +52,9 @@ public sealed class InstallManifest public Dictionary ReShadeIni { get; set; } = new(StringComparer.OrdinalIgnoreCase); public bool AntiCheatConfirmed { get; set; } - /// Files we placed, relative to the game root. + /// Files we placed: relative to the game root, or \… relative to the install folder (see ). public List Files { get; set; } = []; - /// Folders we own, relative to the game root. + /// Folders we own, entries as in . public List Dirs { get; set; } = []; public List Backups { get; set; } = []; public DateTime Updated { get; set; } @@ -57,18 +63,149 @@ public sealed class InstallManifest public static string PathFor(string targetDir) => Path.Combine(DirFor(targetDir), "manifest.json"); public static string BackupDirFor(string targetDir) => Path.Combine(DirFor(targetDir), "backup"); + /// For display: the manifest, or null when there is none or it can't be read. public static InstallManifest? Load(string targetDir) + { + try { return Read(targetDir); } + catch (InvalidDataException ex) + { + Log.Error(ex.Message, ex.InnerException); + return null; + } + } + + /// + /// For changes: the manifest, or null when there is none. One that exists but can't be read throws, so an + /// install never starts a fresh record over the original-file backups. + /// + public static InstallManifest? Read(string targetDir) { var path = PathFor(targetDir); if (!File.Exists(path)) return null; - try { return JsonSerializer.Deserialize(File.ReadAllText(path), JsonCtx.Default.InstallManifest); } + try + { + return JsonSerializer.Deserialize(File.ReadAllText(path), JsonCtx.Default.InstallManifest) + ?? throw new JsonException("empty manifest"); + } catch (Exception ex) when (ex is JsonException or IOException) { - Log.Error($"Unreadable manifest {path}", ex); - return null; + throw new InvalidDataException( + $"DLSS Updater's install record {path} can't be read ({ex.Message}). Nothing was changed; retry, or restore that file.", ex); } } + /// Starts an entry that is relative to the install folder. '|' can't occur in a Windows path, so no game file collides. + public const string InstallTag = "|install|"; + + /// + /// How is recorded: relative to the game root when it lies in the game, otherwise relative to + /// the install folder behind . Unlike a path relative to the game root, that also works for an + /// install folder on another drive or network share. + /// + public static string Entry(string full, string targetDir, string gameRoot) + { + if (FileUtil.IsUnder(full, gameRoot)) return Path.GetRelativePath(gameRoot, full); + if (FileUtil.IsUnder(full, targetDir)) return InstallTag + Path.DirectorySeparatorChar + Path.GetRelativePath(targetDir, full); + throw new UnsafeManifestException(targetDir, $"{full} is outside {gameRoot} and {targetDir}"); + } + + /// + /// Where an entry points; is the root it was recorded against. Says nothing about whether + /// that is a safe place: see . + /// + /// The entry is not a path DLSS Updater writes. + public static string Resolve(string entry, string targetDir, string gameRoot) + { + if (entry.StartsWith(InstallTag, StringComparison.Ordinal)) + { + var rel = entry[InstallTag.Length..]; + if (rel.Length > 1 && rel[0] is '\\' or '/' && IsRelative(rel[1..])) return Path.GetFullPath(Path.Combine(targetDir, rel[1..])); + } + // Full paths are what 0ed78f7 recorded for an install folder on another drive or share. + else if (IsFullPath(entry)) return Path.GetFullPath(entry); + else if (IsRelative(entry)) return Path.GetFullPath(Path.Combine(gameRoot, entry)); + throw new UnsafeManifestException(targetDir, $"path \"{entry}\""); + } + + /// + /// The manifest travels with the game folder, so every path in it is untrusted. Each one must stay inside + /// or the install folder (which may be a folder the user picked outside the game, on + /// any drive or share) and not lead through a junction or symbolic link; owned folders must lie inside the install + /// folder and backups inside its backup folder. Entries are rewritten the way records them for + /// , so the same install folder reached from another game root still finds its + /// originals, and records from earlier versions are brought up to date. + /// + /// Any entry breaks these rules; nothing is changed. + public void Contain(string targetDir, string gameRoot) + { + var target = FileUtil.Normalize(targetDir); + var root = FileUtil.Normalize(gameRoot); + var backupDir = BackupDirFor(target); + // Links are looked for below whichever of the two trusted folders holds the path. + string Base(string full) => FileUtil.IsUnder(full, root) ? root : target; + if (FileUtil.LinkBelow(Base(backupDir), backupDir) is { } link) throw new UnsafeManifestException(target, $"{link} is a link"); + if (!IsRelative(RootRel) && !IsFullPath(RootRel)) throw new UnsafeManifestException(target, $"game root \"{RootRel}\""); + if (Proxy is not null && !AppSettings.IsKnownProxy(Proxy)) throw new UnsafeManifestException(target, $"proxy \"{Proxy}\""); + var oldRoot = FileUtil.Normalize(Path.Combine(target, RootRel)); + + static bool StrictlyUnder(string full, string dir) => + FileUtil.IsUnder(full, dir) && !FileUtil.Normalize(full).Equals(dir, StringComparison.OrdinalIgnoreCase); + + string Check(string entry, bool inTargetOnly) + { + var full = Resolve(entry, target, oldRoot); + var inside = StrictlyUnder(full, target) || (!inTargetOnly && StrictlyUnder(full, root)); + if (!inside) throw new UnsafeManifestException(target, $"\"{entry}\" is outside {(inTargetOnly ? target : $"{root} and {target}")}"); + if (FileUtil.LinkBelow(Base(full), full) is { } l) throw new UnsafeManifestException(target, $"\"{entry}\" leads through the link {l}"); + return full; + } + + var files = Files.Select(f => Check(f, inTargetOnly: false)).ToList(); + var dirs = Dirs.Select(d => Check(d, inTargetOnly: true)).ToList(); + if (dirs.Any(d => FileUtil.IsUnder(d, DirFor(target)))) + throw new UnsafeManifestException(target, "an owned folder is DLSS Updater's own folder"); + var originals = Backups.Select(b => Check(b.Original, inTargetOnly: false)).ToList(); + for (var i = 0; i < Backups.Count; i++) + { + var b = Backups[i]; + string full; + if (IsFullPath(b.Backup)) + { + // 0ed78f7 joined a full original path onto the backup folder, which left the original beside itself as ".N". + full = Path.GetFullPath(b.Backup); + if (!IsSideBackup(full, originals[i])) throw new UnsafeManifestException(target, $"backup \"{b.Backup}\""); + } + else + { + if (!IsRelative(b.Backup) || b.Backup.Split('\\', '/').Contains("..")) throw new UnsafeManifestException(target, $"backup \"{b.Backup}\""); + full = Path.GetFullPath(Path.Combine(backupDir, b.Backup)); + if (!StrictlyUnder(full, backupDir)) throw new UnsafeManifestException(target, $"backup \"{b.Backup}\" is outside {backupDir}"); + } + if (FileUtil.LinkBelow(Base(full), full) is { } l) throw new UnsafeManifestException(target, $"backup \"{b.Backup}\" leads through the link {l}"); + } + + Files = files.Select(f => Entry(f, target, root)).ToList(); + Dirs = dirs.Select(d => Entry(d, target, root)).ToList(); + for (var i = 0; i < Backups.Count; i++) Backups[i].Original = Entry(originals[i], target, root); + RootRel = Path.GetRelativePath(target, root); + } + + /// "<original>.N" in the original's own folder. + private static bool IsSideBackup(string full, string original) => + full.Length > original.Length + 1 + && full.StartsWith(original + ".", StringComparison.OrdinalIgnoreCase) + && full[(original.Length + 1)..].All(char.IsAsciiDigit); + + /// Relative, with no drive or stream (':'). ".." parts are fine: where the path ends up is checked separately. + private static bool IsRelative(string? rel) => + !string.IsNullOrWhiteSpace(rel) && !Path.IsPathRooted(rel) && !rel.Contains(':'); + + /// A drive or UNC path with no stream (':') after its root. Device paths (\\?\, \\.\) are not accepted. + private static bool IsFullPath(string? path) => + !string.IsNullOrWhiteSpace(path) && Path.IsPathFullyQualified(path) + && !(path.Length > 2 && path[0] is '\\' or '/' && path[1] is '\\' or '/' && path[2] is '?' or '.') + && !path[Path.GetPathRoot(path)!.Length..].Contains(':'); + public void Save(string targetDir) { Updated = DateTime.UtcNow; diff --git a/src/DLSSUpdater/Core/Installer.cs b/src/DLSSUpdater/Core/Installer.cs index 5110c72..b3ce089 100644 --- a/src/DLSSUpdater/Core/Installer.cs +++ b/src/DLSSUpdater/Core/Installer.cs @@ -10,7 +10,7 @@ namespace DLSSUpdater.Core; [JsonConverter(typeof(JsonStringEnumConverter))] public enum InstallMode { OptiScaler, ReShadeOnly } -public sealed class InstallOptions +public sealed record InstallOptions { public InstallMode Mode { get; init; } public bool ReShadeOnly => Mode == InstallMode.ReShadeOnly; @@ -32,6 +32,23 @@ public sealed class InstallOptions public IReadOnlyList Overrides { get; init; } = []; public IReadOnlyList ReShadeOverrides { get; init; } = []; public bool AntiCheatConfirmed { get; init; } + /// OptiScaler-NR release to install from the cache; null = the newest. + public string? OptiTag { get; init; } + /// MFG Unlock release to install from the cache ("local" = the imported file); null = the newest. + public string? MfgTag { get; init; } + + /// + /// The same install with OptiScaler-NR and MFG Unlock held at the versions the game already has, for automatic + /// reinstalls (a Seaglass launch) that must not pull in a release nobody chose. A component whose installed + /// version is no longer cached is left as it is. + /// + public InstallOptions KeepingInstalledVersions(InstallManifest m) => this with + { + Opti = Opti && m.OptiTag is { } ot && ComponentStore.IsCached(Component.OptiScaler, ot), + OptiTag = m.OptiTag, + Mfg = Mfg && m.MfgTag is { } mt && ComponentStore.HasMfg(mt), + MfgTag = m.MfgTag, + }; } public sealed class NeedsAdminException(string path) @@ -50,8 +67,15 @@ public sealed class Installer(ComponentStore store) private sealed record Ctx(string Root, string Target, InstallManifest M) { - public string Rel(string full) => Path.GetRelativePath(Root, full); - public string Full(string rel) => Path.GetFullPath(Path.Combine(Root, rel)); + public string Rel(string full) => InstallManifest.Entry(full, Target, Root); + /// Entries live in the game or in the install folder, which the user may have picked outside the game. + public string Full(string rel) + { + var full = InstallManifest.Resolve(rel, Target, Root); + return FileUtil.IsUnder(full, Root) || FileUtil.IsUnder(full, Target) + ? full + : throw new UnsafeManifestException(Target, $"\"{rel}\" is outside {Root} and {Target}"); + } public string BackupDir => InstallManifest.BackupDirFor(Target); } @@ -75,10 +99,11 @@ public async Task InstallAsync(GameInfo game, string targetDir, InstallOptions o private async Task InstallCoreAsync(GameInfo game, string targetDir, InstallOptions o, IProgress? progress, CancellationToken ct) { if (o.ReShadeOnly && !o.ReShade) throw new InvalidOperationException("ReShade-only mode installs ReShade as the proxy; ReShade can't be left out."); + if (!AppSettings.IsKnownProxy(o.Proxy)) throw new ArgumentException($"\"{o.Proxy}\" is not a proxy name DLSS Updater installs under.", nameof(o)); // Fetch everything first so a network failure never leaves a half-installed game. - var pkg = o.Opti && !o.ReShadeOnly ? await store.EnsureOptiAsync(progress, ct) : null; + var pkg = o.Opti && !o.ReShadeOnly ? await store.EnsureOptiAsync(o.OptiTag, progress, ct) : null; var dlssNr = o.DlssNr && !o.ReShadeOnly; - var mfg = o.Mfg ? await store.EnsureMfgAsync(progress, ct) : null; + var mfg = o.Mfg ? await store.EnsureMfgAsync(o.MfgTag, progress, ct) : null; var addMissing = o.AddMissingDlss && o.Opti; var dlss = o.Dlss || addMissing ? await store.EnsureDlssAsync(o.DlssTag, progress, ct) : null; var sl = o.Streamline && game.Streamline.Count > 0 ? await store.EnsureStreamlineAsync(progress, ct) : null; @@ -88,8 +113,9 @@ private async Task InstallCoreAsync(GameInfo game, string targetDir, InstallOpti var reshade = o.ReShade ? await store.EnsureReShadeFileAsync(progress, ct) : null; progress?.Report(new TransferProgress($"Installing to {game.Name}", null)); - var m = InstallManifest.Load(targetDir) ?? new InstallManifest(); - m.RootRel = Path.GetRelativePath(targetDir, game.Root); + // An unreadable or unsafe record stops the install instead of starting over and burying the originals. + var m = InstallManifest.Read(targetDir) ?? new InstallManifest(); + m.Contain(targetDir, game.Root); var ctx = new Ctx(FileUtil.Normalize(game.Root), targetDir, m); await Task.Run(() => @@ -104,7 +130,7 @@ await Task.Run(() => { InstallOpti(ctx, pkg, o); m.Opti = true; - m.OptiTag = store.Opti?.Tag; + m.OptiTag = o.OptiTag ?? store.Opti?.Tag; m.Proxy = o.Proxy; } if (reshade is not null) @@ -124,7 +150,7 @@ await Task.Run(() => { Place(ctx, mfg, Path.Combine(targetDir, ComponentStore.MfgFile)); m.Mfg = true; - m.MfgTag = store.Mfg?.Tag ?? "local"; + m.MfgTag = o.MfgTag ?? store.Mfg?.Tag ?? ComponentStore.LocalMfgTag; } if (dlssNr) { @@ -402,7 +428,7 @@ private static void Backup(Ctx ctx, string full, string kind, bool copy = false) { var rel = ctx.Rel(full); var entry = ctx.M.BackupOf(rel); - var backupRel = entry?.Backup ?? rel.Replace("..", "_up"); + var backupRel = entry?.Backup ?? FreeBackupName(ctx, BackupName(rel)); var dest = Path.Combine(ctx.BackupDir, backupRel); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); if (copy) File.Copy(full, dest, true); @@ -411,23 +437,54 @@ private static void Backup(Ctx ctx, string full, string kind, bool copy = false) else entry.InstalledSha = null; } + /// The entry as a path inside the backup folder: install-folder entries go under "_install". + private static string BackupName(string rel) => + rel.StartsWith(InstallManifest.InstallTag, StringComparison.Ordinal) + ? "_install" + rel[InstallManifest.InstallTag.Length..] + : rel.Replace("..", "_up"); + + /// A backup name no entry uses and no file holds, so a new backup never overwrites a saved original. + private static string FreeBackupName(Ctx ctx, string name) + { + var candidate = name; + for (var i = 1; File.Exists(Path.Combine(ctx.BackupDir, candidate)) + || ctx.M.Backups.Any(b => b.Backup.Equals(candidate, StringComparison.OrdinalIgnoreCase)); i++) + candidate = $"{name}.{i}"; + return candidate; + } + // ---------- uninstall / restore ---------- public Task UninstallAsync(GameInfo game, string targetDir, CancellationToken ct) => Task.Run(() => { targetDir = FileUtil.Normalize(targetDir); EnsureNotRunning(game.Root); - var m = InstallManifest.Load(targetDir) ?? throw new InvalidOperationException("No DLSS Updater install found here."); - var ctx = new Ctx(FileUtil.Normalize(Path.Combine(targetDir, m.RootRel)), targetDir, m); + var m = InstallManifest.Read(targetDir) ?? throw new InvalidOperationException("No DLSS Updater install found here."); + m.Contain(targetDir, game.Root); + var ctx = new Ctx(FileUtil.Normalize(game.Root), targetDir, m); try { - foreach (var rel in m.Files) TryDelete(ctx.Full(rel)); - foreach (var rel in m.Dirs) + // Every step is recorded as soon as it is done, so a retry after a failure part-way (a locked file) + // carries on where it stopped and never deletes an original that was already put back. + foreach (var rel in m.Files.ToList()) + { + TryDelete(ctx.Full(rel)); + m.Files.Remove(rel); + m.Save(targetDir); + } + foreach (var rel in m.Dirs.ToList()) { var d = ctx.Full(rel); if (Directory.Exists(d)) Directory.Delete(d, true); + m.Dirs.Remove(rel); + m.Save(targetDir); + } + foreach (var b in m.Backups.ToList()) + { + Restore(ctx, b); + m.Backups.Remove(b); + m.Save(targetDir); } - foreach (var b in m.Backups) Restore(ctx, b); foreach (var log in LogFiles) TryDelete(Path.Combine(targetDir, log)); Directory.Delete(InstallManifest.DirFor(targetDir), true); Log.Info($"{game.Name}: uninstalled, originals restored"); @@ -439,8 +496,9 @@ public Task RestoreDlssAsync(GameInfo game, string targetDir, CancellationToken { targetDir = FileUtil.Normalize(targetDir); EnsureNotRunning(game.Root); - var m = InstallManifest.Load(targetDir) ?? throw new InvalidOperationException("No DLSS Updater install found here."); - var ctx = new Ctx(FileUtil.Normalize(Path.Combine(targetDir, m.RootRel)), targetDir, m); + var m = InstallManifest.Read(targetDir) ?? throw new InvalidOperationException("No DLSS Updater install found here."); + m.Contain(targetDir, game.Root); + var ctx = new Ctx(FileUtil.Normalize(game.Root), targetDir, m); try { foreach (var b in m.Backups.Where(b => b.Kind is "dlss" or "streamline").ToList()) @@ -470,6 +528,13 @@ private static void Restore(Ctx ctx, BackupEntry b) var src = Path.Combine(ctx.BackupDir, b.Backup); if (!File.Exists(src)) return; var dest = ctx.Full(b.Original); + // A game patch replaced the file we put in: the game's newer file stays and the stale original goes. + if (b.InstalledSha is { } ours && File.Exists(dest) && !string.Equals(HashCache.Get(dest), ours, StringComparison.OrdinalIgnoreCase)) + { + File.Delete(src); + Log.Info($" {b.Original}: changed by the game since, kept"); + return; + } Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Move(src, dest, true); } diff --git a/tests/DLSSUpdater.Tests/ComponentStoreTests.cs b/tests/DLSSUpdater.Tests/ComponentStoreTests.cs new file mode 100644 index 0000000..5cb183d --- /dev/null +++ b/tests/DLSSUpdater.Tests/ComponentStoreTests.cs @@ -0,0 +1,82 @@ +using System.Net; +using DLSSUpdater.Core; + +namespace DLSSUpdater.Tests; + +/// Download checks against a fake GitHub: no request leaves the machine. +public class ComponentStoreTests +{ + private sealed class FakeGitHub(Func respond) : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + lock (Requests) Requests.Add($"{request.Method} {request.RequestUri}"); + return Task.FromResult(respond(request)); + } + } + + public ComponentStoreTests() + { + AppPaths.Root = Path.Combine(Path.GetTempPath(), "dlssu-store-" + Guid.NewGuid().ToString("N")[..8]); + AppPaths.Ensure(); + } + + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body) }; + + /// MFG Unlock's repo answers with , every other API call with an empty list. + private static FakeGitHub Releases(string mfgReleases, string dlssReleases = "[]", HttpStatusCode head = HttpStatusCode.OK) => new(r => + { + var url = r.RequestUri!.ToString(); + if (r.Method == HttpMethod.Head) return new HttpResponseMessage(head); + if (url.Contains(ComponentStore.MfgRepo + "/releases")) return Json(mfgReleases); + if (url.Contains(ComponentStore.DlssRepo + "/releases")) return Json(dlssReleases); + if (url.StartsWith("https://api.github.com/")) return Json("[]"); + if (url.EndsWith(".addon64")) return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([1, 2, 3]) }; + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + [Fact] + public async Task Mfg_AnyOtherAddonInTheRelease_IsNotAccepted() + { + var gh = Releases("""[{"tag_name":"2.0","assets":[{"name":"something-else.addon64","browser_download_url":"https://example.invalid/something-else.addon64","digest":null}]}]"""); + var store = new ComponentStore(new GitHubClient(gh), () => true); + await store.RefreshAsync(default); + Assert.Null(store.Mfg); + } + + [Fact] + public async Task Mfg_AssetWithoutDigest_IsRefused() + { + var gh = Releases($$"""[{"tag_name":"2.0","assets":[{"name":"{{ComponentStore.MfgFile}}","browser_download_url":"https://example.invalid/{{ComponentStore.MfgFile}}"}]}]"""); + var store = new ComponentStore(new GitHubClient(gh), () => true); + await store.RefreshAsync(default); + Assert.Equal("2.0", store.Mfg?.Tag); + + await Assert.ThrowsAsync(() => store.EnsureMfgAsync(null, null, default)); + Assert.False(ComponentStore.IsCached(Component.MfgUnlock, "2.0")); + Assert.False(File.Exists(Path.Combine(ComponentStore.TagDir(Component.MfgUnlock, "2.0"), ComponentStore.MfgFile))); + } + + [Fact] + public async Task Dlss_PinnedVersion_RateLimitedCheckFails_InsteadOfSkippingFiles() + { + var store = new ComponentStore(new GitHubClient(Releases("[]", head: HttpStatusCode.TooManyRequests)), () => true); + await Assert.ThrowsAsync(() => store.EnsureDlssAsync("v310.1.0", null, default)); + Assert.False(ComponentStore.IsCached(Component.Dlss, "v310.1.0")); + } + + [Fact] + public async Task Dlss_Latest_RateLimitedCheck_NeverFallsBackToMain() + { + var gh = Releases("[]", dlssReleases: """[{"tag_name":"v310.2.0","assets":[]}]""", head: HttpStatusCode.ServiceUnavailable); + var store = new ComponentStore(new GitHubClient(gh), () => true); + await store.RefreshAsync(default); + Assert.Equal("v310.2.0", store.Dlss?.Tag); + + await Assert.ThrowsAsync(() => store.EnsureDlssAsync(null, null, default)); + Assert.DoesNotContain(gh.Requests, r => r.Contains("/main/")); + Assert.False(ComponentStore.IsCached(Component.Dlss, "v310.2.0")); + } +} diff --git a/tests/DLSSUpdater.Tests/InstallerTests.cs b/tests/DLSSUpdater.Tests/InstallerTests.cs index fe6e84f..078bf57 100644 --- a/tests/DLSSUpdater.Tests/InstallerTests.cs +++ b/tests/DLSSUpdater.Tests/InstallerTests.cs @@ -78,9 +78,9 @@ private void SeedDlss(string tag, string content) _store.Dlss = r; } - private Dictionary Snapshot() => - Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories) - .ToDictionary(f => Path.GetRelativePath(_root, f), File.ReadAllText, StringComparer.OrdinalIgnoreCase); + private Dictionary Snapshot(string? dir = null) => + Directory.EnumerateFiles(dir ?? _root, "*", SearchOption.AllDirectories) + .ToDictionary(f => Path.GetRelativePath(dir ?? _root, f), File.ReadAllText, StringComparer.OrdinalIgnoreCase); private GameInfo Game() => GameScanner.Inspect(new GameEntry("Game", _root, "Manual")); @@ -494,4 +494,457 @@ public async Task ReShadeOnly_WithoutReShade_Throws() var o = new InstallOptions { Mode = InstallMode.ReShadeOnly, Mfg = true }; await Assert.ThrowsAsync(() => new Installer(_store).InstallAsync(Game(), _target, o, null, default)); } + + // ---------- review 2026-10: crafted, moved or broken install records ---------- + + private string Engine(string file) => Path.Combine(_root, @"Engine\Plugins\Runtime\Nvidia\DLSS\Binaries\ThirdParty\Win64", file); + + /// A folder next to the game that no install record may reach. + private string Victim() + { + var v = Path.Combine(_tmp, "Victim"); + Write(Path.Combine(v, "keep.txt"), "precious"); + return v; + } + + private void EditManifest(Action edit) + { + var m = InstallManifest.Read(_target)!; + edit(m); + m.Save(_target); + } + + private IEnumerable BackupContents() => + Directory.EnumerateFiles(InstallManifest.BackupDirFor(_target), "*", SearchOption.AllDirectories).Select(File.ReadAllText); + + private static void Junction(string link, string target) + { + var psi = new System.Diagnostics.ProcessStartInfo("cmd.exe", $"/c mklink /J \"{link}\" \"{target}\"") + { + UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, + }; + using var p = System.Diagnostics.Process.Start(psi)!; + p.WaitForExit(); + Assert.Equal(0, p.ExitCode); + } + + [Theory] + [InlineData("dir-up")] + [InlineData("dir-absolute")] + [InlineData("dir-outside-install-folder")] + [InlineData("root-up")] + [InlineData("file-up")] + [InlineData("backup-original-up")] + [InlineData("backup-escapes-backup-folder")] + [InlineData("backup-absolute")] + [InlineData("file-absolute")] + [InlineData("file-install-tag-up")] + [InlineData("root-absolute")] + [InlineData("file-unc-outside")] + public async Task CraftedManifest_UninstallRefuses_AndTouchesNothingOutside(string attack) + { + var victim = Victim(); + var installer = new Installer(_store); + await installer.InstallAsync(Game(), _target, new InstallOptions { Dlss = true }, null, default); + Write(Path.Combine(InstallManifest.BackupDirFor(_target), "evil.dll"), "EVIL"); + EditManifest(m => + { + switch (attack) + { + case "dir-up": m.Dirs.Add(@"..\Victim"); break; + case "dir-absolute": m.Dirs.Add(victim); break; + case "dir-outside-install-folder": m.Dirs.Add("Engine"); break; + case "root-up": m.RootRel = @"..\..\..\.."; m.Dirs.Add("Victim"); break; + case "file-up": m.Files.Add(@"..\Victim\keep.txt"); break; + case "backup-original-up": m.Backups.Add(new BackupEntry { Original = @"..\Victim\evil.dll", Backup = "evil.dll" }); break; + case "backup-escapes-backup-folder": m.Backups.Add(new BackupEntry { Original = "Game.exe", Backup = @"..\..\..\..\..\..\Victim\keep.txt" }); break; + case "backup-absolute": m.Backups.Add(new BackupEntry { Original = "Game.exe", Backup = Path.Combine(victim, "keep.txt") }); break; + case "file-absolute": m.Files.Add(Path.Combine(victim, "keep.txt")); break; + case "file-install-tag-up": m.Files.Add(InstallManifest.InstallTag + @"\..\..\..\..\Victim\keep.txt"); break; + case "root-absolute": m.RootRel = _tmp; m.Dirs.Add("Victim"); break; + case "file-unc-outside": m.Files.Add(Unc(Path.Combine(victim, "keep.txt")) ?? Path.Combine(victim, "keep.txt")); break; + } + }); + var before = Snapshot(); + + await Assert.ThrowsAsync(() => installer.UninstallAsync(Game(), _target, default)); + await Assert.ThrowsAsync(() => installer.RestoreDlssAsync(Game(), _target, default)); + + Assert.Equal(["keep.txt"], Directory.EnumerateFiles(victim).Select(Path.GetFileName)); + Assert.Equal("precious", File.ReadAllText(Path.Combine(victim, "keep.txt"))); + Assert.Equal(before.OrderBy(k => k.Key), Snapshot().OrderBy(k => k.Key)); + } + + [Fact] + public async Task CraftedManifest_LaunchStyleReinstall_RefusesProxyOutsideTheGame() + { + // Seaglass's beforeLaunch reinstalls from the record: its proxy and files must not reach outside either. + var victim = Victim(); + var installer = new Installer(_store); + await installer.InstallAsync(Game(), _target, Full, null, default); + EditManifest(m => + { + m.Proxy = @"..\..\..\..\Victim\keep.txt"; + m.Files.Add(@"..\Victim\keep.txt"); + }); + + await Assert.ThrowsAsync(() => installer.InstallAsync(Game(), _target, ReShadeOnly(), null, default)); + Assert.Equal("precious", File.ReadAllText(Path.Combine(victim, "keep.txt"))); + await Assert.ThrowsAsync(() => + installer.InstallAsync(Game(), _target, new InstallOptions { Opti = true, Proxy = @"..\..\evil.dll" }, null, default)); + } + + [Fact] + public async Task ManifestPathThroughJunction_IsRefused() + { + var victim = Victim(); + var installer = new Installer(_store); + await installer.InstallAsync(Game(), _target, new InstallOptions { Dlss = true }, null, default); + var link = Path.Combine(_target, "Link"); + Junction(link, victim); + try + { + EditManifest(m => m.Files.Add(@"Game\Binaries\Win64\Link\keep.txt")); + + await Assert.ThrowsAsync(() => installer.UninstallAsync(Game(), _target, default)); + Assert.Equal("precious", File.ReadAllText(Path.Combine(victim, "keep.txt"))); + } + finally + { + Directory.Delete(link); // removes the junction only; a recursive delete of the temp folder can't + } + } + + [Fact] + public async Task SameFolderFromNarrowerRoot_IsRefused_OriginalsStayRestorable() + { + var installer = new Installer(_store); + await installer.InstallAsync(Game(), _target, new InstallOptions { Dlss = true }, null, default); + + // "Add game folder" picked Game\Game: the SR backup at Game\Engine is out of its reach. + var inner = GameScanner.Inspect(new GameEntry("Game", Path.Combine(_root, "Game"), "Manual")); + await Assert.ThrowsAsync(() => installer.InstallAsync(inner, _target, new InstallOptions { Dlss = true }, null, default)); + + await installer.RestoreDlssAsync(Game(), _target, default); + Assert.Equal("OLD-FG", T("nvngx_dlssg.dll")); + Assert.Equal("OLD-SR", File.ReadAllText(Engine("nvngx_dlss.dll"))); + } + + [Fact] + public async Task SameFolderFromWiderRoot_RebasesRecord_RestoreReturnsOriginals() + { + var before = Snapshot(); + var installer = new Installer(_store); + var inner = GameScanner.Inspect(new GameEntry("Game", Path.Combine(_root, "Game"), "Manual")); + await installer.InstallAsync(inner, _target, new InstallOptions { Dlss = true }, null, default); + await installer.InstallAsync(Game(), _target, new InstallOptions { Dlss = true }, null, default); + + await installer.RestoreDlssAsync(Game(), _target, default); + Assert.Equal(before.OrderBy(k => k.Key), Snapshot().OrderBy(k => k.Key)); + } + + [Fact] + public async Task UnreadableManifest_InstallRefuses_BackupsSurvive() + { + var installer = new Installer(_store); + await installer.InstallAsync(Game(), _target, new InstallOptions { Dlss = true }, null, default); + File.WriteAllText(InstallManifest.PathFor(_target), "{ truncated"); + + await Assert.ThrowsAsync(() => installer.InstallAsync(Game(), _target, new InstallOptions { Dlss = true }, null, default)); + Assert.Contains("OLD-FG", BackupContents()); + Assert.Equal("NEW-nvngx_dlssg.dll", T("nvngx_dlssg.dll")); + Assert.Null(InstallManifest.Load(_target)); // display code still just sees "not installed" + } + + [Fact] + public async Task LostManifest_NewInstallNeverOverwritesSavedOriginals() + { + var installer = new Installer(_store); + await installer.InstallAsync(Game(), _target, new InstallOptions { Dlss = true }, null, default); + File.Delete(InstallManifest.PathFor(_target)); + + await installer.InstallAsync(Game(), _target, new InstallOptions { Dlss = true }, null, default); + Assert.Contains("OLD-FG", BackupContents()); + Assert.Contains("OLD-SR", BackupContents()); + } + + [Fact] + public async Task LaunchReinstall_KeepsInstalledOptiAndMfgVersions() + { + var installer = new Installer(_store); + await installer.InstallAsync(Game(), _target, Full, null, default); + SeedOpti("v10.0.0", "OPTI-2"); + SeedMfg("2.0", "MFG-2"); + + var m = InstallManifest.Read(_target)!; + await installer.InstallAsync(Game(), _target, Full.KeepingInstalledVersions(m), null, default); + Assert.Equal("OPTI-1", T("dxgi.dll")); + Assert.Equal("MFG-1", T(ComponentStore.MfgFile)); + Assert.Equal("v9.9.9", InstallManifest.Read(_target)!.OptiTag); + Assert.Equal("1.0", InstallManifest.Read(_target)!.MfgTag); + + // The installed release left the cache: OptiScaler is left alone rather than upgraded. + Directory.Delete(ComponentStore.TagDir(Component.OptiScaler, "v9.9.9"), true); + var o = Full.KeepingInstalledVersions(InstallManifest.Read(_target)!); + Assert.False(o.Opti); + Assert.True(o.Mfg); + await installer.InstallAsync(Game(), _target, o, null, default); + Assert.Equal("OPTI-1", T("dxgi.dll")); + + // A click in DLSS Updater (no pin) moves to the newest release. + await installer.InstallAsync(Game(), _target, Full, null, default); + Assert.Equal("OPTI-2", T("dxgi.dll")); + Assert.Equal("MFG-2", T(ComponentStore.MfgFile)); + } + + [Fact] + public async Task RestoreDlss_KeepsAFileTheGameUpdatedSince() + { + var installer = new Installer(_store); + await installer.InstallAsync(Game(), _target, new InstallOptions { Dlss = true }, null, default); + File.WriteAllText(Path.Combine(_target, "nvngx_dlssg.dll"), "GAME-PATCH-3.8"); + + await installer.RestoreDlssAsync(Game(), _target, default); + Assert.Equal("GAME-PATCH-3.8", T("nvngx_dlssg.dll")); + Assert.Equal("OLD-SR", File.ReadAllText(Engine("nvngx_dlss.dll"))); + } + + /// An install folder the user picked outside the game (the folder dialog allows any folder). + private string Elsewhere() + { + var dir = Path.Combine(_tmp, "Elsewhere"); + Write(Path.Combine(dir, "dxgi.dll"), "ELSEWHERE-MOD"); + return dir; + } + + [Fact] + public async Task InstallFolderOutsideGame_InstallUpdateUninstall_RoundTrip() + { + var elsewhere = Elsewhere(); + var before = Snapshot(); + var beforeElsewhere = Snapshot(elsewhere); + var installer = new Installer(_store); + + await installer.InstallAsync(Game(), elsewhere, Full, null, default); + Assert.Equal("OPTI-1", File.ReadAllText(Path.Combine(elsewhere, "dxgi.dll"))); + Assert.Equal("MFG-1", File.ReadAllText(Path.Combine(elsewhere, ComponentStore.MfgFile))); + Assert.Equal("NEW-nvngx_dlssg.dll", T("nvngx_dlssg.dll")); + + SeedOpti("v10.0.0", "OPTI-2"); + await installer.InstallAsync(Game(), elsewhere, Full, null, default); + Assert.Equal("OPTI-2", File.ReadAllText(Path.Combine(elsewhere, "dxgi.dll"))); + + await installer.UninstallAsync(Game(), elsewhere, default); + Assert.Equal(before.OrderBy(k => k.Key), Snapshot().OrderBy(k => k.Key)); + Assert.Equal(beforeElsewhere.OrderBy(k => k.Key), Snapshot(elsewhere).OrderBy(k => k.Key)); + Assert.False(Directory.Exists(InstallManifest.DirFor(elsewhere))); + } + + [Fact] + public async Task InstallFolderOutsideGame_RecordWrittenByEarlierVersion_StillUninstalls() + { + // The record 1.5.1 writes for such a folder: game root and our files reached through "..". + var elsewhere = Elsewhere(); + var savedMod = Path.Combine(InstallManifest.BackupDirFor(elsewhere), @"_up\Elsewhere\dxgi.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(savedMod)!); + File.Move(Path.Combine(elsewhere, "dxgi.dll"), savedMod); + Write(Path.Combine(elsewhere, "dxgi.dll"), "OPTI-1"); + Write(Path.Combine(elsewhere, @"OptiScaler\libxess.dll"), "XESS"); + Write(Path.Combine(InstallManifest.BackupDirFor(elsewhere), @"Game\Binaries\Win64\nvngx_dlssg.dll"), "OLD-FG"); + File.WriteAllText(Path.Combine(_target, "nvngx_dlssg.dll"), "NEW-nvngx_dlssg.dll"); + File.WriteAllText(InstallManifest.PathFor(elsewhere), """ + { + "Schema": 1, + "RootRel": "..\\Game", + "Proxy": "dxgi.dll", + "Mode": "OptiScaler", + "Opti": true, + "Dlss": true, + "Files": [ "..\\Elsewhere\\dxgi.dll" ], + "Dirs": [ "..\\Elsewhere\\OptiScaler" ], + "Backups": [ + { "Original": "..\\Elsewhere\\dxgi.dll", "Backup": "_up\\Elsewhere\\dxgi.dll", "Kind": "file" }, + { "Original": "Game\\Binaries\\Win64\\nvngx_dlssg.dll", "Backup": "Game\\Binaries\\Win64\\nvngx_dlssg.dll", "Kind": "dlss" } + ] + } + """); + + var m = InstallManifest.Read(elsewhere)!; + m.Contain(elsewhere, _root); + Assert.Equal([@"|install|\dxgi.dll"], m.Files); + Assert.Equal([@"|install|\OptiScaler"], m.Dirs); + Assert.NotNull(m.BackupOf(@"|install|\dxgi.dll")); + + await new Installer(_store).UninstallAsync(Game(), elsewhere, default); + Assert.Equal("ELSEWHERE-MOD", File.ReadAllText(Path.Combine(elsewhere, "dxgi.dll"))); + Assert.False(Directory.Exists(Path.Combine(elsewhere, "OptiScaler"))); + Assert.Equal("OLD-FG", T("nvngx_dlssg.dll")); + Assert.False(Directory.Exists(InstallManifest.DirFor(elsewhere))); + } + + /// The same path through the admin share (\\localhost\C$\…), or null where that share isn't reachable. + private static string? Unc(string local) + { + var share = $@"\\localhost\{local[0]}$"; + return Directory.Exists(share + @"\") ? share + local[2..] : null; + } + + /// A drive letter mapped onto a folder with subst: to path handling, another volume. + private sealed class Subst : IDisposable + { + public string? Drive { get; } + + public Subst(string dir) + { + var used = DriveInfo.GetDrives().Select(d => char.ToUpperInvariant(d.Name[0])).ToHashSet(); + foreach (var letter in "RSTUVWXYZ".Where(l => !used.Contains(l))) + if (Run($"{letter}: \"{dir}\"") == 0) { Drive = $@"{letter}:\"; return; } + } + + private static int Run(string args) + { + var psi = new System.Diagnostics.ProcessStartInfo("subst.exe", args) + { + UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, + }; + using var p = System.Diagnostics.Process.Start(psi)!; + p.WaitForExit(); + return p.ExitCode; + } + + public void Dispose() + { + if (Drive is not null) Run($"{Drive[..2]} /d"); + } + } + + /// The install folder as reached on another volume than the game, or null where this machine can't. + private string? OnOtherVolume(string how, string local, Subst? subst) => how switch + { + "subst" => subst?.Drive is { } d ? Path.Combine(d, Path.GetRelativePath(_tmp, local)) : null, + _ => Unc(local), + }; + + [Theory] + [InlineData("subst")] + [InlineData("unc")] + public async Task InstallFolderOnAnotherVolume_InstallUpdateRestoreUninstall_RoundTrip(string how) + { + var elsewhere = Elsewhere(); + using var subst = how == "subst" ? new Subst(_tmp) : null; + var via = OnOtherVolume(how, elsewhere, subst); + if (via is null) return; // no free drive letter or admin share here + var before = Snapshot(); + var beforeElsewhere = Snapshot(elsewhere); + var installer = new Installer(_store); + + await installer.InstallAsync(Game(), via, Full, null, default); + Assert.Equal("OPTI-1", File.ReadAllText(Path.Combine(elsewhere, "dxgi.dll"))); + Assert.Equal("NEW-nvngx_dlssg.dll", T("nvngx_dlssg.dll")); + Assert.False(File.Exists(Path.Combine(elsewhere, "dxgi.dll.1"))); + var m = InstallManifest.Read(via)!; + Assert.Contains(@"|install|\dxgi.dll", m.Files); + Assert.All(m.Files.Concat(m.Dirs).Concat(m.Backups.SelectMany(b => new[] { b.Original, b.Backup })), + e => Assert.False(Path.IsPathRooted(e), e)); + + SeedOpti("v10.0.0", "OPTI-2"); + await installer.InstallAsync(Game(), via, Full, null, default); + Assert.Equal("OPTI-2", File.ReadAllText(Path.Combine(elsewhere, "dxgi.dll"))); + + await installer.RestoreDlssAsync(Game(), via, default); + Assert.Equal("OLD-FG", T("nvngx_dlssg.dll")); + Assert.Equal("OLD-SR", File.ReadAllText(Engine("nvngx_dlss.dll"))); + + await installer.UninstallAsync(Game(), via, default); + Assert.Equal(before.OrderBy(k => k.Key), Snapshot().OrderBy(k => k.Key)); + Assert.Equal(beforeElsewhere.OrderBy(k => k.Key), Snapshot(elsewhere).OrderBy(k => k.Key)); + Assert.False(Directory.Exists(InstallManifest.DirFor(elsewhere))); + } + + [Theory] + [InlineData("subst")] + [InlineData("unc")] + public async Task InstallFolderOnAnotherVolume_RecordWrittenBy0ed78f7_RestoresDisplacedOriginal(string how) + { + // 0ed78f7 recorded full paths for such a folder and left the displaced dxgi.dll beside itself as dxgi.dll.1. + var elsewhere = Elsewhere(); + using var subst = how == "subst" ? new Subst(_tmp) : null; + var via = OnOtherVolume(how, elsewhere, subst); + if (via is null) return; + var proxy = Path.Combine(via, "dxgi.dll"); + File.Move(Path.Combine(elsewhere, "dxgi.dll"), Path.Combine(elsewhere, "dxgi.dll.1")); + Write(Path.Combine(elsewhere, "dxgi.dll"), "OPTI-1"); + Write(Path.Combine(elsewhere, @"OptiScaler\libxess.dll"), "XESS"); + Write(Path.Combine(InstallManifest.BackupDirFor(elsewhere), @"Game\Binaries\Win64\nvngx_dlssg.dll"), "OLD-FG"); + File.WriteAllText(Path.Combine(_target, "nvngx_dlssg.dll"), "NEW-nvngx_dlssg.dll"); + new InstallManifest + { + RootRel = _root, Proxy = "dxgi.dll", Mode = InstallMode.OptiScaler, Opti = true, Dlss = true, + Files = [proxy], + Dirs = [Path.Combine(via, "OptiScaler")], + Backups = + [ + new BackupEntry { Original = proxy, Backup = proxy + ".1", Kind = "file" }, + new BackupEntry { Original = @"Game\Binaries\Win64\nvngx_dlssg.dll", Backup = @"Game\Binaries\Win64\nvngx_dlssg.dll", Kind = "dlss" }, + ], + }.Save(via); + var installer = new Installer(_store); + + await installer.RestoreDlssAsync(Game(), via, default); + Assert.Equal("OLD-FG", T("nvngx_dlssg.dll")); + Assert.Equal([@"|install|\dxgi.dll"], InstallManifest.Read(via)!.Files); + + await installer.UninstallAsync(Game(), via, default); + Assert.Equal("ELSEWHERE-MOD", File.ReadAllText(Path.Combine(elsewhere, "dxgi.dll"))); + Assert.Equal(["dxgi.dll"], Directory.EnumerateFileSystemEntries(elsewhere).Select(Path.GetFileName)); + } + + [Fact] + public void ManifestEntries_RoundTripForEveryKindOfInstallFolder() + { + var inGame = Path.Combine(_target, "dxgi.dll"); + Assert.Equal(@"Game\Binaries\Win64\dxgi.dll", InstallManifest.Entry(inGame, _target, _root)); + foreach (var target in new[] { @"D:\Mods", @"\\nas\games\Mods", Path.Combine(_tmp, "Elsewhere") }) + { + var file = Path.Combine(target, @"OptiScaler\libxess.dll"); + var entry = InstallManifest.Entry(file, target, _root); + Assert.Equal(@"|install|\OptiScaler\libxess.dll", entry); + Assert.Equal(file, InstallManifest.Resolve(entry, target, _root)); + } + Assert.Throws(() => InstallManifest.Entry(@"E:\Other\x.dll", @"D:\Mods", _root)); + foreach (var bad in new[] { @"|install|", @"|install|\", @"|install|\C:\x.dll", @"\\?\C:\x.dll", @"C:\x.dll:stream", "x.dll:stream", @"\x.dll" }) + Assert.Throws(() => InstallManifest.Resolve(bad, @"D:\Mods", _root)); + } + + [Fact] + public async Task Uninstall_FailsPartWay_RetryKeepsOriginalsAlreadyRestored() + { + var before = Snapshot(); + var installer = new Installer(_store); + await installer.InstallAsync(Game(), _target, Full, null, default); + + // The game's SR dll is held open: its restore fails after dxgi.dll's original is already back. + using (new FileStream(Engine("nvngx_dlss.dll"), FileMode.Open, FileAccess.Read, FileShare.Read)) + await Assert.ThrowsAnyAsync(() => installer.UninstallAsync(Game(), _target, default)); + Assert.Equal("OTHER-MOD", T("dxgi.dll")); + + await installer.UninstallAsync(Game(), _target, default); + Assert.Equal(before.OrderBy(k => k.Key), Snapshot().OrderBy(k => k.Key)); + Assert.False(Directory.Exists(InstallManifest.DirFor(_target))); + } + + [Fact] + public void GitHubToken_IsSavedEncrypted_AndPlainTextIsMigrated() + { + const string token = "github_pat_TEST_ONLY_not_a_real_token"; + new AppSettings { GitHubToken = token }.Save(); + Assert.DoesNotContain(token, File.ReadAllText(AppPaths.SettingsFile)); + Assert.Equal(token, AppSettings.Load().GitHubToken); + + // settings.json from 1.5.1: the plain token is read, then rewritten encrypted. + File.WriteAllText(AppPaths.SettingsFile, $"{{ \"GitHubToken\": \"{token}\" }}"); + Assert.Equal(token, AppSettings.Load().GitHubToken); + Assert.DoesNotContain(token, File.ReadAllText(AppPaths.SettingsFile)); + Assert.Equal(token, AppSettings.Load().GitHubToken); + } }