From 1955ca8ab5c6dfcc09e85319b5e943d249b90be7 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 23:12:15 +0200 Subject: [PATCH] fix(launcher): harden updater crash recovery --- docs/plans/2026-08-14-launcher-campaign.md | 91 +- .../2026-08-14-launcher-campaign-design.md | 10 +- .../Launching/LauncherProcessSupervisor.cs | 25 + .../Orchestration/LauncherExecutableSet.cs | 7 + .../Orchestration/LauncherOrchestrator.cs | 9 + .../Updates/ClientVersionStore.cs | 82 +- .../Updates/LauncherSelfUpdateBootstrap.cs | 410 +++++---- .../Updates/LauncherSelfUpdateManager.cs | 796 ++++++++++++++---- .../Updates/LauncherUpdater.cs | 3 - .../Updates/PortablePathRules.cs | 38 + .../Updates/ReleaseManifestClient.cs | 233 +++-- .../Updates/SafeZipExtractor.cs | 15 +- .../Updates/UpdateSessionBarrier.cs | 59 +- .../Updates/VerifiedArtifactDownloader.cs | 87 +- src/AcDream.Launcher/AcDream.Launcher.csproj | 4 + src/AcDream.Launcher/App.axaml.cs | 42 +- .../LauncherUpdateComposition.cs | 128 +++ .../ViewModels/LauncherUpdateViewModel.cs | 36 +- .../Program.cs | 171 +++- .../LauncherOrchestratorTests.cs | 89 ++ .../Updates/ClientVersionStoreTests.cs | 55 ++ .../Updates/LauncherSelfUpdateManagerTests.cs | 153 ++-- .../Updates/LauncherSelfUpdateProcessTests.cs | 453 ++++++++++ .../LauncherUpdaterIntegrationTests.cs | 12 +- .../Updates/ReleaseTransportTests.cs | 170 +++- .../Updates/SafeZipExtractorTests.cs | 15 + .../LauncherUpdateCompositionTests.cs | 65 ++ 27 files changed, 2714 insertions(+), 544 deletions(-) create mode 100644 src/AcDream.Launcher.Core/Updates/PortablePathRules.cs create mode 100644 src/AcDream.Launcher/LauncherUpdateComposition.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs create mode 100644 tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 151d7c78..2ba8202d 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -471,7 +471,8 @@ gate (user): clean-profile first-run against real DATs. verify, unpack to `DataDirectory/app//`, atomic `current.json` pointer swap, refuse while any session runs, keep previous version for one-step rollback. -- Launcher self-update: staged download + rename-dance on next start. +- Launcher self-update: staged download + target-local atomic replacement on + next start. - Session-config composition targets `app/current`'s binaries. **Acceptance:** manifest/download/verify/swap tests against a local HTTP @@ -488,8 +489,11 @@ before doing network, extraction, or activation work. The production feed is pinned to GitHub owner/repository `eriknihlen/acdream`; the launcher reads `https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json`. -Tests may inject a loopback HTTP URI, but production artifacts and redirects -must use HTTPS. `manifest.json` is: +Tests use a separate internal fixture constructor that may admit loopback HTTP; +that allowance never propagates to the production feed. Production manifest +and artifact URIs use HTTPS. Automatic redirects are disabled and every +redirect hop is validated before it is requested; redirect loops, a chain over +five hops, and any HTTPS-to-HTTP downgrade are rejected. `manifest.json` is: ```json { @@ -557,14 +561,18 @@ same-volume directory rename. write-through temporary-file + same-directory atomic rename. The last valid pointer is also atomically preserved as `current.previous.json`; startup may restore that exact backup only when `current.json` is missing/malformed and -the referenced version verifies. Orphan LA10 staging directories and pointer -temporaries are transaction-owned by exact names and are removed under the -update lease. A corrupt installed version is never silently selected; the -explicit one-step rollback swaps the two verified pointer versions. +the referenced version verifies. Orphan LA10 staging directories, download +archives, corrupt-version quarantine directories, and pointer temporaries are +transaction-owned by exact lowercase GUID names and are removed only under the +exclusive update lease; near-matching user names are preserved. A corrupt +installed version is never silently selected; the explicit one-step rollback +swaps the two verified pointer versions. `DataDirectory/app/.update-session.lock` is the cross-process barrier. Each supervised launcher activity holds a shared OS handle from before executable -resolution until terminal process observation; an update/rollback holds the +resolution until terminal process observation; launcher disposal requests +child termination and does not release that handle until the child is actually +observed terminal. An update/rollback holds the exclusive handle for its entire recovery/download/extract/promote/pointer transaction. Failure to acquire the exclusive handle is an immediate refusal, not a wait behind a running session. The open handle, not lock-file contents, @@ -572,11 +580,11 @@ owns the lease and therefore releases after process death. Launcher self-update staging lives at `DataDirectory/launcher-update/transactions//` and the sole -durable authority is `DataDirectory/launcher-update/pending.json`: +durable authority is `DataDirectory/launcher-update/pending.json` (schema 2): ```json { - "schemaVersion": 1, + "schemaVersion": 2, "transactionId": "0123456789abcdef0123456789abcdef", "state": "staged", "version": "1.2.3", @@ -591,17 +599,62 @@ durable authority is `DataDirectory/launcher-update/pending.json`: } ``` -Before mutation a next-start helper copied outside the target directory -atomically advances the plan to `applying` and fills `apply` with each path's -`hadOriginal` bit. It waits for the initiating launcher PID without invoking a -shell, moves originals into the transaction backup tree, then moves verified -staged files into place. It never opens a target with truncate/overwrite. On -success the plan becomes `awaitingConfirmation`; the new launcher confirms at -its first managed instruction, after which backup and plan cleanup is safe. An +Before mutation the verified staged launcher becomes the next-start helper and +waits for the initiating launcher PID without invoking a shell. It first copies +the complete verified payload into the target-local +`.acdream-self-update-/incoming/` tree. The plan then advances +to `applying`; `apply` is an ordinally sorted union of new payload paths, the +owned metadata path, and obsolete paths from the previous ownership record: + +```json +[ + { "path": "acdream-launcher.exe", "operation": "install", "hadOriginal": true }, + { "path": "obsolete.dll", "operation": "remove", "hadOriginal": true } +] +``` + +Existing targets are replaced with one same-filesystem atomic replace whose +backup is also target-local. Previously absent noncanonical files use one +same-filesystem rename; obsolete owned files use one rename into backup. The +canonical launcher path therefore contains either the complete old file or the +complete new file at every durable crash boundary. Rollback reverses the same +operations atomically and is idempotent after a process/power loss. Linux mode +bits come from the verified incoming file. A helper that cannot immediately +acquire the exclusive update lease defers the staged plan and exits without +restarting the old launcher, preventing restart loops. + +Successful application writes strict target ownership metadata at +`/launcher.install.json`: + +```json +{ + "schemaVersion": 1, + "version": "1.2.3", + "rid": "win-x64", + "files": [ + { "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 } + ] +} +``` + +The archive may not supply that reserved metadata path. A prior valid record is +the only authority for obsolete-file removal; the first managed update does +not infer ownership of unrelated legacy files. On success the plan becomes +`awaitingConfirmation`; the new launcher confirms at its first managed +instruction, after which the helper releases its lease and the confirmed +launcher reclaims plan, data-transaction, and target-local residue. An `applying` plan is rolled back before retry, and failure to start/confirm the new launcher restores every original (and removes every no-original target). -All plan paths are re-derived/contained under the pinned data root except the -target directory, which must equal the actual launcher base directory. +Reading `pending.json` never performs cleanup. Ordinary startup attempts the +exclusive lease without waiting and skips update cleanup entirely when another +session/staging transaction owns it. All plan paths are re-derived/contained +under pinned roots; the target directory must equal the actual launcher base +directory. + +Every portable archive and persisted relative path rejects Windows device +segments on every host: `CON`, `PRN`, `AUX`, `NUL`, `CLOCK$`, `CONIN$`, +`CONOUT$`, `COM1`-`COM9`, `LPT1`-`LPT9`, and the Windows-equivalent superscript +forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions. ## LA11 — closeout diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index 6c4d8d07..be403756 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -298,19 +298,19 @@ preview would be a deliberate divergence we are NOT taking. install to `DataDirectory/app//`; atomic pointer swap (`current.json`); never while any session is running; keep the previous version for one-step rollback. -- **Launcher self-update:** same feed; staged download; rename-dance swap - on next start (a running exe can't replace itself on Windows). +- **Launcher self-update:** same feed; staged download; target-local atomic + replacement on next start after the running process exits. - **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips are release assets; the launcher pins the repo/owner in its config. The exact v1 manifest, extracted-version record, `current.json` activation -pointer, shared-session/exclusive-update OS lease, and durable self-update -plan are pinned in +pointer and launcher ownership record, shared-session/exclusive-update OS +lease, and durable self-update plan schema 2 are pinned in `docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater contracts (v1, BINDING)**. That section is normative: implementations reject unknown/duplicate fields and unsupported versions, use strict SemVer 2.0 precedence, verify bounded streamed downloads before safe ZIP extraction, and -derive all mutable staging/backup paths from the application data root. The +use per-hop redirect validation plus same-filesystem atomic replacement. The LA9 DAT/pak install record remains the sole content descriptor fed to session configs; LA10 changes only which verified `app/current.json` client binaries the process supervisor executes. diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs index dd0db580..3a4c2923 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs @@ -44,6 +44,7 @@ public sealed class LauncherProcessSupervisorFactory( /// public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor { + private static readonly TimeSpan DisposeStopTimeout = TimeSpan.FromSeconds(5); private readonly ILauncherChildProcessFactory _factory; private readonly object _gate = new(); private readonly Queue _pendingStateChanges = []; @@ -51,6 +52,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor private LauncherSessionState _state = LauncherSessionState.Starting; private int? _exitCode; private bool _publishingStateChanges; + private bool _disposed; public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null) { @@ -100,6 +102,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor ILauncherChildProcess process; lock (_gate) { + ObjectDisposedException.ThrowIf(_disposed, this); if (_process is not null) { throw new InvalidOperationException( @@ -201,6 +204,11 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor if (!process.WaitForExit(timeout) && !process.HasExited) { process.Kill(); + if (!process.WaitForExit(Timeout.InfiniteTimeSpan) && !process.HasExited) + { + throw new InvalidOperationException( + "The launcher child could not be observed terminal after it was killed."); + } } } @@ -307,6 +315,23 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor public void Dispose() { + ILauncherChildProcess? process; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + process = _process; + } + + if (process is { HasExited: false }) + { + Stop(DisposeStopTimeout); + } + lock (_gate) { if (_process is not null) diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs index 5fc2a858..4c3ffe16 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -149,6 +149,13 @@ public sealed class LauncherExecutableSet }); } + public static LauncherExecutableSet Unavailable(string reason) + { + ArgumentException.ThrowIfNullOrWhiteSpace(reason); + return new LauncherExecutableSet( + () => throw new LauncherUpdateException(reason)); + } + private ExecutablePaths RequireAvailable(LaunchMode mode) { LauncherCapability capability = GetAvailability(mode); diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index d430eaad..a734fcaf 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -627,6 +627,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator } request.Cancellation.Dispose(); + request.Activity.StartCompleted.Set(); } } @@ -1201,11 +1202,16 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator private static void DisposeActivity(ManagedActivity activity) { activity.StartCancellation?.Cancel(); + activity.StartCompleted.Wait(); activity.StartCancellation?.Dispose(); activity.StartCancellation = null; if (activity.Supervisor is not null) { + // Disposal is a process-lifetime transaction: the shared update + // lease remains held until Stop has observed the real child + // terminal (including the post-kill wait). + activity.Supervisor.Stop(TimeSpan.FromSeconds(5)); if (activity.SupervisorStateHandler is not null) { activity.Supervisor.StateChanged -= activity.SupervisorStateHandler; @@ -1216,6 +1222,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator } ReleaseUpdateSessionLease(activity); + activity.StartCompleted.Dispose(); } private static void ReleaseUpdateSessionLease(ManagedActivity activity) @@ -1285,6 +1292,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator public UpdateSessionBarrier.SessionLease? UpdateSessionLease; + public ManualResetEventSlim StartCompleted { get; } = new(false); + public object StatusReadGate { get; } = new(); public bool IsActive => State is not ( diff --git a/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs b/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs index e14163b0..3b008184 100644 --- a/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs +++ b/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs @@ -503,7 +503,9 @@ public sealed class ClientVersionStore .Where(path => !string.Equals( path, "install.json", - StringComparison.OrdinalIgnoreCase)) + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) .OrderBy(path => path, StringComparer.Ordinal) .ToArray(); string[] recordedFiles = record.Files @@ -809,13 +811,43 @@ public sealed class ClientVersionStore ".client-staging-*", SearchOption.TopDirectoryOnly)) { - string suffix = Path.GetFileName(path)[".client-staging-".Length..]; - if (Guid.TryParseExact(suffix, "N", out _)) + if (HasCanonicalGuidName( + Path.GetFileName(path), + ".client-staging-", + string.Empty)) { SafeZipExtractor.TryDeleteDirectory(path); } } + foreach (string path in Directory.EnumerateDirectories( + AppDirectory, + ".client-corrupt-*", + SearchOption.TopDirectoryOnly)) + { + if (HasCanonicalGuidName( + Path.GetFileName(path), + ".client-corrupt-", + string.Empty)) + { + SafeZipExtractor.TryDeleteDirectory(path); + } + } + + foreach (string path in Directory.EnumerateFiles( + AppDirectory, + ".client-download-*.zip", + SearchOption.TopDirectoryOnly)) + { + if (HasCanonicalGuidName( + Path.GetFileName(path), + ".client-download-", + ".zip")) + { + VerifiedArtifactDownloader.TryDelete(path); + } + } + foreach (string path in Directory.EnumerateFiles( AppDirectory, ".current*.tmp", @@ -825,20 +857,43 @@ public sealed class ClientVersionStore string[] parts = fileName.Split('.'); if (parts.Length >= 4 && string.Equals(parts[^1], "tmp", StringComparison.Ordinal) - && Guid.TryParseExact(parts[^2], "N", out _)) + && Guid.TryParseExact(parts[^2], "N", out Guid parsed) + && string.Equals( + parsed.ToString("N"), + parts[^2], + StringComparison.Ordinal)) { VerifiedArtifactDownloader.TryDelete(path); } } } + private static bool HasCanonicalGuidName( + string fileName, + string prefix, + string suffix) + { + if (!fileName.StartsWith(prefix, StringComparison.Ordinal) + || !fileName.EndsWith(suffix, StringComparison.Ordinal) + || fileName.Length != prefix.Length + 32 + suffix.Length) + { + return false; + } + + string value = fileName.Substring(prefix.Length, 32); + return Guid.TryParseExact(value, "N", out Guid parsed) + && string.Equals(parsed.ToString("N"), value, StringComparison.Ordinal); + } + private void RequireOwnedStagingPath(string path) { string parent = Path.GetDirectoryName(path) ?? string.Empty; string fileName = Path.GetFileName(path); if (!PathsEqual(parent, AppDirectory) - || !fileName.StartsWith(".client-staging-", StringComparison.Ordinal) - || !Guid.TryParseExact(fileName[".client-staging-".Length..], "N", out _)) + || !HasCanonicalGuidName( + fileName, + ".client-staging-", + string.Empty)) { throw new LauncherUpdateException( "The client extraction path is not an owned LA10 staging directory."); @@ -901,7 +956,7 @@ public sealed class ClientVersionStore && !part.Any(character => char.IsControl(character) || character is '<' or '>' or '"' or '|' or '?' or '*') - && !IsWindowsDeviceName(part)); + && !PortablePathRules.IsWindowsDeviceName(part)); } internal static string ResolveContained(string root, string relative) @@ -929,19 +984,6 @@ public sealed class ClientVersionStore return path; } - private static bool IsWindowsDeviceName(string segment) - { - string stem = segment.Split('.')[0]; - return stem.Equals("CON", StringComparison.OrdinalIgnoreCase) - || stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) - || stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) - || stem.Equals("NUL", StringComparison.OrdinalIgnoreCase) - || (stem.Length == 4 - && (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase) - || stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) - && stem[3] is >= '1' and <= '9'); - } - private static bool PathsEqual(string left, string right) => string.Equals( Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs index 6470244a..e72bea60 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs @@ -8,16 +8,17 @@ public sealed record SelfUpdateStartupResult( string[] RemainingArguments); /// -/// Process-level rename dance for launcher self-update. Every child argument -/// is passed through with -/// UseShellExecute=false; no path or PID is ever interpolated into a -/// shell command. +/// Process-level self-update bootstrap. Every child argument is passed through +/// with shell execution disabled. /// public static class LauncherSelfUpdateBootstrap { public const string HelperArgument = "--acdream-self-update-helper-v1"; public const string ConfirmArgument = "--acdream-self-update-confirm-v1"; + internal const string DeferredArgument = "--acdream-self-update-deferred-v1"; + internal const int DeferredLeaseExitCode = 73; private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5); public static async Task HandleAsync( string[] args, @@ -32,10 +33,16 @@ public static class LauncherSelfUpdateBootstrap Path.GetFullPath(launcherBaseDirectory)); string executable = Path.GetFullPath(currentExecutablePath); + if (args.Length > 0 + && string.Equals(args[0], DeferredArgument, StringComparison.Ordinal)) + { + return new SelfUpdateStartupResult(false, 0, args[1..]); + } + if (args.Length > 0 && string.Equals(args[0], HelperArgument, StringComparison.Ordinal)) { - if (args.Length != 4 + if (args.Length < 4 || !int.TryParse( args[1], System.Globalization.NumberStyles.None, @@ -51,6 +58,7 @@ public static class LauncherSelfUpdateBootstrap parentPid, args[2], args[3], + args[4..], cancellationToken) .ConfigureAwait(false); return new SelfUpdateStartupResult(true, exitCode, []); @@ -59,7 +67,7 @@ public static class LauncherSelfUpdateBootstrap if (args.Length > 0 && string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal)) { - if (args.Length != 2) + if (args.Length < 2) { return new SelfUpdateStartupResult(true, 64, []); } @@ -70,82 +78,97 @@ public static class LauncherSelfUpdateBootstrap executable, cancellationToken) .ConfigureAwait(false); - return new SelfUpdateStartupResult(false, 0, []); - } - - SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken) - .ConfigureAwait(false); - if (plan is null) - { - return new SelfUpdateStartupResult(false, 0, args); - } - - if (!PathsEqual(plan.TargetDirectory, baseDirectory)) - { - throw new LauncherUpdateException( - "The pending self-update targets a different launcher directory."); - } - - if (plan.State == SelfUpdatePlanState.AwaitingConfirmation) - { - if (!manager.IsConfirmed(plan.TransactionId)) - { - await manager.ConfirmAsync( - plan.TransactionId, - baseDirectory, - executable, - cancellationToken) - .ConfigureAwait(false); - } - - await manager.CompleteConfirmedAsync( - plan.TransactionId, + await FinishConfirmedCleanupAsync( + manager, baseDirectory, cancellationToken) .ConfigureAwait(false); + return new SelfUpdateStartupResult(false, 0, args[2..]); + } + + if (!manager.Barrier.TryAcquireExclusive( + out UpdateSessionBarrier.ExclusiveLease? startupLease)) + { + // A running session or another launcher is staging. Reading the + // plan is safe, but cleanup or starting a competing helper is not. return new SelfUpdateStartupResult(false, 0, args); } - string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) - ? ".exe" - : string.Empty; - string expectedExecutable = ClientVersionStore.ResolveContained( - baseDirectory, - "acdream-launcher" + suffix); - if (!PathsEqual(executable, expectedExecutable)) + using (UpdateSessionBarrier.ExclusiveLease lease = startupLease + ?? throw new InvalidOperationException("Exclusive startup lease is missing.")) { - throw new LauncherUpdateException( - "Self-update can start only from the published acdream-launcher executable."); - } + SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + _ = manager.CleanupOwnedResidueUnderLease( + plan, + baseDirectory, + lease); + if (plan is null) + { + return new SelfUpdateStartupResult(false, 0, args); + } - string helperPath = manager.GetHelperPath(plan.TransactionId); - Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); - VerifiedArtifactDownloader.TryDelete(helperPath); - File.Copy(executable, helperPath, overwrite: false); - if (OperatingSystem.IsLinux()) - { - File.SetUnixFileMode( - helperPath, - UnixFileMode.UserRead - | UnixFileMode.UserWrite - | UnixFileMode.UserExecute); - } + if (!PathsEqual(plan.TargetDirectory, baseDirectory)) + { + throw new LauncherUpdateException( + "The pending self-update targets a different launcher directory."); + } - var startInfo = new ProcessStartInfo(helperPath) - { - UseShellExecute = false, - WorkingDirectory = manager.GetTransactionDirectory(plan.TransactionId), - }; - startInfo.ArgumentList.Add(HelperArgument); - startInfo.ArgumentList.Add( - Environment.ProcessId.ToString( - System.Globalization.CultureInfo.InvariantCulture)); - startInfo.ArgumentList.Add(baseDirectory); - startInfo.ArgumentList.Add(plan.TransactionId); - _ = Process.Start(startInfo) - ?? throw new LauncherUpdateException( - "The launcher self-update helper could not be started."); - return new SelfUpdateStartupResult(true, 0, []); + if (plan.State == SelfUpdatePlanState.AwaitingConfirmation) + { + if (!manager.IsConfirmed(plan.TransactionId)) + { + await manager.ConfirmAsync( + plan.TransactionId, + baseDirectory, + executable, + cancellationToken) + .ConfigureAwait(false); + } + + await manager.CompleteConfirmedAsync( + plan.TransactionId, + baseDirectory, + cancellationToken) + .ConfigureAwait(false); + _ = manager.CleanupOwnedResidueUnderLease( + pending: null, + baseDirectory, + lease); + return new SelfUpdateStartupResult(false, 0, args); + } + + string expectedExecutable = ClientVersionStore.ResolveContained( + baseDirectory, + GetLauncherFileName(plan.Rid)); + if (!PathsEqual(executable, expectedExecutable)) + { + throw new LauncherUpdateException( + "Self-update can start only from the published acdream-launcher executable."); + } + + string helperPath = manager.GetStagedLauncherPath(plan); + var startInfo = new ProcessStartInfo(helperPath) + { + UseShellExecute = false, + WorkingDirectory = manager.GetPayloadDirectory(plan.TransactionId), + }; + startInfo.ArgumentList.Add(HelperArgument); + startInfo.ArgumentList.Add( + Environment.ProcessId.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add(baseDirectory); + startInfo.ArgumentList.Add(plan.TransactionId); + foreach (string argument in args) + { + startInfo.ArgumentList.Add(argument); + } + + _ = Process.Start(startInfo) + ?? throw new LauncherUpdateException( + "The launcher self-update helper could not be started."); + return new SelfUpdateStartupResult(true, 0, []); + } } private static async Task RunHelperAsync( @@ -153,6 +176,7 @@ public static class LauncherSelfUpdateBootstrap int parentPid, string targetDirectory, string transactionId, + IReadOnlyList publicArguments, CancellationToken cancellationToken) { SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken) @@ -164,12 +188,15 @@ public static class LauncherSelfUpdateBootstrap "The helper transaction does not match the pending self-update."); } - string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) - ? ".exe" - : string.Empty; + if (!PathsEqual(plan.TargetDirectory, targetDirectory)) + { + throw new LauncherUpdateException( + "The helper target does not match the pending self-update."); + } + string launcherPath = ClientVersionStore.ResolveContained( targetDirectory, - "acdream-launcher" + suffix); + GetLauncherFileName(plan.Rid)); var startInfo = new ProcessStartInfo(launcherPath) { UseShellExecute = false, @@ -177,97 +204,184 @@ public static class LauncherSelfUpdateBootstrap }; startInfo.ArgumentList.Add(ConfirmArgument); startInfo.ArgumentList.Add(transactionId); - - Process? replacement = null; - UpdateSessionBarrier.ExclusiveLease? updateLease = null; - bool appliedByThisHelper = false; - try + foreach (string argument in publicArguments) { - await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false); - updateLease = manager.Barrier.AcquireExclusive(); - plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken) - .ConfigureAwait(false); - appliedByThisHelper = true; - replacement = Process.Start(startInfo) - ?? throw new LauncherUpdateException( - "The updated launcher could not be started."); - DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout; - while (!manager.IsConfirmed(transactionId)) - { - cancellationToken.ThrowIfCancellationRequested(); - if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline) - { - throw new LauncherUpdateException( - replacement.HasExited - ? $"The updated launcher exited with code {replacement.ExitCode} " - + "before confirming startup." - : "The updated launcher did not confirm startup in time."); - } - - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - - await manager.CompleteConfirmedAsync( - transactionId, - targetDirectory, - cancellationToken) - .ConfigureAwait(false); - return 0; + startInfo.ArgumentList.Add(argument); } - catch + + await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false); + if (!manager.Barrier.TryAcquireExclusive( + out UpdateSessionBarrier.ExclusiveLease? updateLease)) { - if (replacement is { HasExited: false }) + // Do not restart the canonical launcher: it would immediately see + // the same staged plan and create an unbounded helper loop. + return DeferredLeaseExitCode; + } + + using (UpdateSessionBarrier.ExclusiveLease lease = updateLease + ?? throw new InvalidOperationException("Exclusive update lease is missing.")) + { + plan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException( + "The helper found no pending self-update after acquiring the lease."); + if (!string.Equals( + plan.TransactionId, + transactionId, + StringComparison.Ordinal) + || !PathsEqual(plan.TargetDirectory, targetDirectory)) { - replacement.Kill(entireProcessTree: true); - await replacement.WaitForExitAsync(CancellationToken.None) - .ConfigureAwait(false); + throw new LauncherUpdateException( + "The pending self-update changed before the helper acquired its lease."); } + _ = manager.CleanupOwnedResidueUnderLease( + plan, + targetDirectory, + lease); + Process? replacement = null; + bool appliedByThisHelper = false; try { - if (appliedByThisHelper) + plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken) + .ConfigureAwait(false); + appliedByThisHelper = true; + replacement = Process.Start(startInfo) + ?? throw new LauncherUpdateException( + "The updated launcher could not be started."); + DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout; + while (!manager.IsConfirmed(transactionId)) { - SelfUpdatePlan? pending = await manager.LoadPendingAsync( - CancellationToken.None) - .ConfigureAwait(false); - if (pending?.State == SelfUpdatePlanState.Applying) + cancellationToken.ThrowIfCancellationRequested(); + if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline) { - _ = await manager.RecoverApplyingAsync( - targetDirectory, - CancellationToken.None) - .ConfigureAwait(false); - } - else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation) - { - _ = await manager.RollbackAwaitingConfirmationAsync( - targetDirectory, - CancellationToken.None) - .ConfigureAwait(false); + throw new LauncherUpdateException( + replacement.HasExited + ? $"The updated launcher exited with code {replacement.ExitCode} " + + "before confirming startup." + : "The updated launcher did not confirm startup in time."); } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); } + + await manager.CompleteConfirmedAsync( + transactionId, + targetDirectory, + cancellationToken) + .ConfigureAwait(false); + return 0; } catch { - // Do not start an executable from an ambiguous half-applied - // state. A subsequent startup replays the durable journal. - return 75; - } + if (replacement is { HasExited: false }) + { + replacement.Kill(entireProcessTree: true); + await replacement.WaitForExitAsync(CancellationToken.None) + .ConfigureAwait(false); + } - var restored = new ProcessStartInfo(launcherPath) + try + { + if (appliedByThisHelper) + { + SelfUpdatePlan? pending = await manager.LoadPendingAsync( + CancellationToken.None) + .ConfigureAwait(false); + if (pending?.State == SelfUpdatePlanState.Applying) + { + _ = await manager.RecoverApplyingAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false); + } + else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation) + { + _ = await manager.RollbackAwaitingConfirmationAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false); + } + } + } + catch + { + // An ambiguous state must not start either executable. + return 75; + } + + var restored = new ProcessStartInfo(launcherPath) + { + UseShellExecute = false, + WorkingDirectory = Path.GetFullPath(targetDirectory), + }; + restored.ArgumentList.Add(DeferredArgument); + foreach (string argument in publicArguments) + { + restored.ArgumentList.Add(argument); + } + + _ = Process.Start(restored); + return 74; + } + finally { - UseShellExecute = false, - WorkingDirectory = Path.GetFullPath(targetDirectory), - }; - _ = Process.Start(restored); - return 74; - } - finally - { - replacement?.Dispose(); - updateLease?.Dispose(); + replacement?.Dispose(); + } } } + private static async Task FinishConfirmedCleanupAsync( + LauncherSelfUpdateManager manager, + string targetDirectory, + CancellationToken cancellationToken) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout; + do + { + cancellationToken.ThrowIfCancellationRequested(); + if (manager.Barrier.TryAcquireExclusive( + out UpdateSessionBarrier.ExclusiveLease? lease)) + { + using (UpdateSessionBarrier.ExclusiveLease acquiredLease = lease + ?? throw new InvalidOperationException( + "Exclusive cleanup lease is missing.")) + { + SelfUpdatePlan? pending = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + if (pending is + { + State: SelfUpdatePlanState.AwaitingConfirmation, + } + && manager.IsConfirmed(pending.TransactionId)) + { + await manager.CompleteConfirmedAsync( + pending.TransactionId, + targetDirectory, + cancellationToken) + .ConfigureAwait(false); + pending = null; + } + + if (manager.CleanupOwnedResidueUnderLease( + pending, + targetDirectory, + acquiredLease)) + { + return; + } + } + } + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + while (DateTimeOffset.UtcNow < deadline); + } + + private static string GetLauncherFileName(string rid) => + "acdream-launcher" + + (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); + private static async Task WaitForParentExitAsync( int parentPid, CancellationToken cancellationToken) diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs index 1dddc952..71ef0064 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs @@ -11,7 +11,16 @@ public enum SelfUpdatePlanState AwaitingConfirmation, } -public sealed record SelfUpdateApplyEntry(string Path, bool HadOriginal); +public enum SelfUpdateApplyOperation +{ + Install, + Remove, +} + +public sealed record SelfUpdateApplyEntry( + string Path, + SelfUpdateApplyOperation Operation, + bool HadOriginal); public sealed record SelfUpdatePlan( int SchemaVersion, @@ -24,6 +33,15 @@ public sealed record SelfUpdatePlan( long ArchiveSize, IReadOnlyList Files, IReadOnlyList? Apply) +{ + public const int CurrentSchemaVersion = 2; +} + +public sealed record LauncherBinaryInstallRecord( + int SchemaVersion, + string Version, + string Rid, + IReadOnlyList Files) { public const int CurrentSchemaVersion = 1; } @@ -33,13 +51,27 @@ public sealed record SelfUpdateStageResult( string PendingPlanPath, string Status); +internal enum SelfUpdateApplyBoundary +{ + AfterTargetMutation, +} + +internal sealed record SelfUpdateApplyObservation( + SelfUpdateApplyBoundary Boundary, + string Path, + SelfUpdateApplyOperation Operation); + /// -/// Durable self-update transaction owner. It stages a verified launcher ZIP; -/// a separately copied helper performs move-only replacement after the parent -/// exits and can replay rollback after a crash at any file boundary. +/// Durable self-update transaction owner. Verified payload bytes are copied +/// into a target-local transaction before mutation. Existing targets use a +/// same-filesystem atomic replace with a target-local backup, so the canonical +/// executable is never absent at a durable boundary. /// public sealed class LauncherSelfUpdateManager { + public const string InstallRecordFileName = "launcher.install.json"; + private const string TargetTransactionPrefix = ".acdream-self-update-"; + private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -47,18 +79,34 @@ public sealed class LauncherSelfUpdateManager WriteIndented = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, MaxDepth = 32, - Converters = { new JsonStringEnumConverter( - JsonNamingPolicy.CamelCase, - allowIntegerValues: false) }, + Converters = + { + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false), + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false), + }, }; private readonly VerifiedArtifactDownloader _downloader; private readonly SafeZipExtractor _extractor; + private readonly Action? _applyObserver; public LauncherSelfUpdateManager( ApplicationPathSet paths, HttpClient httpClient, SafeZipExtractor? extractor = null) + : this(paths, httpClient, extractor, applyObserver: null) + { + } + + internal LauncherSelfUpdateManager( + ApplicationPathSet paths, + HttpClient httpClient, + SafeZipExtractor? extractor, + Action? applyObserver) { ArgumentNullException.ThrowIfNull(paths); RootDirectory = Path.Combine( @@ -70,6 +118,7 @@ public sealed class LauncherSelfUpdateManager _downloader = new VerifiedArtifactDownloader( httpClient ?? throw new ArgumentNullException(nameof(httpClient))); _extractor = extractor ?? new SafeZipExtractor(); + _applyObserver = applyObserver; } public string RootDirectory { get; } @@ -80,7 +129,7 @@ public sealed class LauncherSelfUpdateManager public UpdateSessionBarrier Barrier { get; } - public async Task StageAsync( + public Task StageAsync( ReleaseManifest manifest, string rid, string targetDirectory, @@ -88,15 +137,13 @@ public sealed class LauncherSelfUpdateManager CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(manifest); - ReleaseArtifact artifact = manifest.RequireLauncher(rid); - return await StageAsync( - manifest.Version, - rid, - artifact, - targetDirectory, - progress, - cancellationToken) - .ConfigureAwait(false); + return StageAsync( + manifest.Version, + rid, + manifest.RequireLauncher(rid), + targetDirectory, + progress, + cancellationToken); } internal async Task StageAsync( @@ -114,11 +161,13 @@ public sealed class LauncherSelfUpdateManager throw new ArgumentException("RID is invalid.", nameof(rid)); } + using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive(); string target = NormalizeTargetDirectory(targetDirectory); Directory.CreateDirectory(RootDirectory); Directory.CreateDirectory(TransactionsDirectory); SelfUpdatePlan? existing = await LoadPendingAsync(cancellationToken) .ConfigureAwait(false); + _ = CleanupOwnedResidueUnderLease(existing, target, lease); if (existing is not null) { throw new LauncherUpdateException( @@ -148,6 +197,14 @@ public sealed class LauncherSelfUpdateManager extracted, rid, launcherPayload: true); + if (extracted.Any(file => string.Equals( + file.Path, + InstallRecordFileName, + StringComparison.OrdinalIgnoreCase))) + { + throw new LauncherUpdateException( + $"The launcher ZIP may not provide '{InstallRecordFileName}'."); + } var plan = new SelfUpdatePlan( SelfUpdatePlan.CurrentSchemaVersion, @@ -185,12 +242,15 @@ public sealed class LauncherSelfUpdateManager } } + /// + /// Reads the durable plan without mutating any transaction-owned path. + /// Cleanup is a separate operation that requires the exclusive OS lease. + /// public async Task LoadPendingAsync( CancellationToken cancellationToken = default) { if (!File.Exists(PendingPlanPath)) { - CleanupOwnedResidue(keepTransactionId: null); return null; } @@ -207,7 +267,6 @@ public sealed class LauncherSelfUpdateManager } ValidatePlan(plan, plan.TargetDirectory); - CleanupOwnedResidue(plan.TransactionId); return plan; } catch (OperationCanceledException) @@ -252,21 +311,17 @@ public sealed class LauncherSelfUpdateManager } await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false); - var apply = new List(plan.Files.Count); - foreach (InstalledFileRecord file in plan.Files) - { - string targetPath = ClientVersionStore.ResolveContained( + LauncherBinaryInstallRecord? previous = await ReadAndVerifyInstallRecordAsync( expectedTarget, - file.Path); - if (Directory.Exists(targetPath)) - { - throw new LauncherUpdateException( - $"Self-update target '{file.Path}' is unexpectedly a directory."); - } - - apply.Add(new SelfUpdateApplyEntry(file.Path, File.Exists(targetPath))); - } - + plan.Rid, + cancellationToken) + .ConfigureAwait(false); + IReadOnlyList apply = BuildApplyJournal( + plan, + previous, + expectedTarget); + await PrepareTargetTransactionAsync(plan, apply, cancellationToken) + .ConfigureAwait(false); plan = plan with { State = SelfUpdatePlanState.Applying, @@ -274,30 +329,16 @@ public sealed class LauncherSelfUpdateManager }; await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); - string payload = GetPayloadDirectory(plan.TransactionId); - string backup = GetBackupDirectory(plan.TransactionId); try { foreach (SelfUpdateApplyEntry entry in plan.Apply) { cancellationToken.ThrowIfCancellationRequested(); - string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path); - string targetPath = ClientVersionStore.ResolveContained(expectedTarget, entry.Path); - string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); - EnsureSafeParent(expectedTarget, targetPath); - Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); - if (entry.HadOriginal) - { - File.Move(targetPath, backupPath); - } - - File.Move(stagedPath, targetPath); - InstalledFileRecord file = plan.Files.Single(candidate => - string.Equals(candidate.Path, entry.Path, StringComparison.Ordinal)); - if (OperatingSystem.IsLinux() && file.UnixMode != 0) - { - File.SetUnixFileMode(targetPath, (UnixFileMode)file.UnixMode); - } + ApplyEntry(plan, entry); + _applyObserver?.Invoke(new SelfUpdateApplyObservation( + SelfUpdateApplyBoundary.AfterTargetMutation, + entry.Path, + entry.Operation)); } plan = plan with { State = SelfUpdatePlanState.AwaitingConfirmation }; @@ -344,12 +385,9 @@ public sealed class LauncherSelfUpdateManager "The running launcher does not match the pending confirmation plan."); } - string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) - ? ".exe" - : string.Empty; string expectedExecutable = ClientVersionStore.ResolveContained( expectedTarget, - "acdream-launcher" + suffix); + GetLauncherFileName(plan.Rid)); if (!PathsEqual(expectedExecutable, currentExecutablePath)) { throw new LauncherUpdateException( @@ -358,6 +396,11 @@ public sealed class LauncherSelfUpdateManager await VerifyAppliedTargetsAsync(plan, expectedTarget, cancellationToken) .ConfigureAwait(false); + await VerifyInstalledOwnershipMatchesPlanAsync( + plan, + expectedTarget, + cancellationToken) + .ConfigureAwait(false); string confirmationPath = GetConfirmationPath(transactionId); await AtomicJsonFile.WriteBytesAsync( confirmationPath, @@ -377,7 +420,8 @@ public sealed class LauncherSelfUpdateManager SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) .ConfigureAwait(false) ?? throw new LauncherUpdateException("There is no self-update to complete."); - ValidatePlan(plan, NormalizeTargetDirectory(expectedTargetDirectory)); + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + ValidatePlan(plan, expectedTarget); if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal) || plan.State != SelfUpdatePlanState.AwaitingConfirmation || !IsConfirmed(transactionId)) @@ -386,6 +430,7 @@ public sealed class LauncherSelfUpdateManager } File.Delete(PendingPlanPath); + SafeZipExtractor.TryDeleteDirectory(GetTargetTransactionDirectory(plan)); SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId)); } @@ -419,18 +464,211 @@ public sealed class LauncherSelfUpdateManager public string GetPayloadDirectory(string transactionId) => Path.Combine(GetTransactionDirectory(transactionId), "payload"); - public string GetBackupDirectory(string transactionId) => - Path.Combine(GetTransactionDirectory(transactionId), "backup"); - public string GetConfirmationPath(string transactionId) => Path.Combine(GetTransactionDirectory(transactionId), "confirmed"); - public string GetHelperPath(string transactionId) + internal string GetTargetTransactionDirectory(SelfUpdatePlan plan) => + Path.Combine(plan.TargetDirectory, TargetTransactionPrefix + plan.TransactionId); + + internal string GetStagedLauncherPath(SelfUpdatePlan plan) => + ClientVersionStore.ResolveContained( + GetPayloadDirectory(plan.TransactionId), + GetLauncherFileName(plan.Rid)); + + internal bool CleanupOwnedResidueUnderLease( + SelfUpdatePlan? pending, + string targetDirectory, + UpdateSessionBarrier.ExclusiveLease lease) { - string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; - return Path.Combine( - GetTransactionDirectory(transactionId), - "acdream-self-update-helper" + suffix); + Barrier.RequireOwned(lease); + string target = NormalizeTargetDirectory(targetDirectory); + CleanupDataResidue(pending?.TransactionId); + string? keepTarget = pending is + { + State: SelfUpdatePlanState.Applying or SelfUpdatePlanState.AwaitingConfirmation, + } + ? pending.TransactionId + : null; + CleanupTargetResidue(target, keepTarget); + return !HasReclaimableResidue(pending?.TransactionId, target, keepTarget); + } + + private static string GetLauncherFileName(string rid) => + "acdream-launcher" + + (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); + + private IReadOnlyList BuildApplyJournal( + SelfUpdatePlan plan, + LauncherBinaryInstallRecord? previous, + string targetDirectory) + { + var operations = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (InstalledFileRecord file in plan.Files) + { + operations.Add(file.Path, SelfUpdateApplyOperation.Install); + } + + operations.Add(InstallRecordFileName, SelfUpdateApplyOperation.Install); + if (previous is not null) + { + foreach (InstalledFileRecord file in previous.Files) + { + if (!operations.ContainsKey(file.Path)) + { + operations.Add(file.Path, SelfUpdateApplyOperation.Remove); + } + } + } + + var result = new List(operations.Count); + foreach ((string path, SelfUpdateApplyOperation operation) in operations + .OrderBy(item => item.Key, StringComparer.Ordinal)) + { + string targetPath = ClientVersionStore.ResolveContained(targetDirectory, path); + EnsureSafeParent(targetDirectory, targetPath); + if (Directory.Exists(targetPath)) + { + throw new LauncherUpdateException( + $"Self-update target '{path}' is unexpectedly a directory."); + } + + bool hadOriginal = File.Exists(targetPath); + if (operation == SelfUpdateApplyOperation.Remove && !hadOriginal) + { + throw new LauncherUpdateException( + $"Owned obsolete launcher file '{path}' is missing."); + } + + if (string.Equals( + path, + GetLauncherFileName(plan.Rid), + StringComparison.OrdinalIgnoreCase) + && !hadOriginal) + { + throw new LauncherUpdateException( + "The canonical launcher executable is missing before self-update."); + } + + result.Add(new SelfUpdateApplyEntry(path, operation, hadOriginal)); + } + + return result; + } + + private async Task PrepareTargetTransactionAsync( + SelfUpdatePlan plan, + IReadOnlyList apply, + CancellationToken cancellationToken) + { + string swap = GetTargetTransactionDirectory(plan); + if (Directory.Exists(swap)) + { + ClientVersionStore.RejectReparseTree(swap); + SafeZipExtractor.TryDeleteDirectory(swap); + } + + if (Directory.Exists(swap) || File.Exists(swap)) + { + throw new LauncherUpdateException( + "The target-local self-update transaction could not be reclaimed."); + } + + string incoming = Path.Combine(swap, "incoming"); + Directory.CreateDirectory(incoming); + string payload = GetPayloadDirectory(plan.TransactionId); + foreach (InstalledFileRecord file in plan.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + string source = ClientVersionStore.ResolveContained(payload, file.Path); + string destination = ClientVersionStore.ResolveContained(incoming, file.Path); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + await CopyFileDurablyAsync(source, destination, cancellationToken) + .ConfigureAwait(false); + if (OperatingSystem.IsLinux() && file.UnixMode != 0) + { + File.SetUnixFileMode(destination, (UnixFileMode)file.UnixMode); + } + + await VerifyFileAsync( + incoming, + file, + "Target-local incoming launcher", + cancellationToken) + .ConfigureAwait(false); + } + + var ownership = new LauncherBinaryInstallRecord( + LauncherBinaryInstallRecord.CurrentSchemaVersion, + plan.Version, + plan.Rid, + plan.Files); + await AtomicJsonFile.WriteAsync( + Path.Combine(incoming, InstallRecordFileName), + ownership, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + ClientVersionStore.RejectReparseTree(swap); + + string[] actual = Directory.EnumerateFiles( + incoming, + "*", + SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(incoming, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + string[] expected = apply + .Where(entry => entry.Operation == SelfUpdateApplyOperation.Install) + .Select(entry => entry.Path) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + if (!actual.SequenceEqual(expected, StringComparer.Ordinal)) + { + throw new LauncherUpdateException( + "The target-local self-update incoming tree is incomplete."); + } + } + + private void ApplyEntry(SelfUpdatePlan plan, SelfUpdateApplyEntry entry) + { + string swap = GetTargetTransactionDirectory(plan); + string incoming = Path.Combine(swap, "incoming"); + string backup = Path.Combine(swap, "backup"); + string targetPath = ClientVersionStore.ResolveContained( + plan.TargetDirectory, + entry.Path); + string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); + EnsureSafeParent(plan.TargetDirectory, targetPath); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + + if (entry.Operation == SelfUpdateApplyOperation.Remove) + { + if (!entry.HadOriginal || !File.Exists(targetPath)) + { + throw new LauncherUpdateException( + $"Owned obsolete launcher file '{entry.Path}' vanished during apply."); + } + + File.Move(targetPath, backupPath); + return; + } + + string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path); + if (!File.Exists(incomingPath)) + { + throw new LauncherUpdateException( + $"Incoming launcher file '{entry.Path}' is missing."); + } + + if (entry.HadOriginal) + { + File.Replace(incomingPath, targetPath, backupPath, ignoreMetadataErrors: true); + } + else + { + File.Move(incomingPath, targetPath); + } } private async Task RollbackApplyingAsync( @@ -442,26 +680,52 @@ public sealed class LauncherSelfUpdateManager throw new LauncherUpdateException("The self-update rollback journal is missing."); } - string payload = GetPayloadDirectory(plan.TransactionId); - string backup = GetBackupDirectory(plan.TransactionId); + string swap = GetTargetTransactionDirectory(plan); + string backup = Path.Combine(swap, "backup"); + string discard = Path.Combine(swap, "rollback-discard"); foreach (SelfUpdateApplyEntry entry in plan.Apply.Reverse()) { cancellationToken.ThrowIfCancellationRequested(); - string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path); string targetPath = ClientVersionStore.ResolveContained( plan.TargetDirectory, entry.Path); string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); - if (!File.Exists(stagedPath) && File.Exists(targetPath)) + if (entry.Operation == SelfUpdateApplyOperation.Remove) { - Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!); - File.Move(targetPath, stagedPath); + if (File.Exists(backupPath)) + { + if (File.Exists(targetPath) || Directory.Exists(targetPath)) + { + throw new LauncherUpdateException( + $"Obsolete launcher rollback target '{entry.Path}' was recreated."); + } + + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.Move(backupPath, targetPath); + } + + continue; } if (entry.HadOriginal && File.Exists(backupPath)) { Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); - File.Move(backupPath, targetPath); + if (File.Exists(targetPath)) + { + string discardPath = ClientVersionStore.ResolveContained( + discard, + entry.Path); + Directory.CreateDirectory(Path.GetDirectoryName(discardPath)!); + File.Replace( + backupPath, + targetPath, + discardPath, + ignoreMetadataErrors: true); + } + else + { + File.Move(backupPath, targetPath); + } } else if (!entry.HadOriginal && File.Exists(targetPath)) { @@ -469,7 +733,7 @@ public sealed class LauncherSelfUpdateManager } } - SafeZipExtractor.TryDeleteDirectory(backup); + SafeZipExtractor.TryDeleteDirectory(swap); plan = plan with { State = SelfUpdatePlanState.Staged, @@ -510,24 +774,8 @@ public sealed class LauncherSelfUpdateManager foreach (InstalledFileRecord file in plan.Files) { - cancellationToken.ThrowIfCancellationRequested(); - string path = ClientVersionStore.ResolveContained(payload, file.Path); - var info = new FileInfo(path); - if (!info.Exists || info.Length != file.Size) - { - throw new LauncherUpdateException( - $"Staged launcher file '{file.Path}' size is corrupt."); - } - - string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( - path, - cancellationToken) + await VerifyFileAsync(payload, file, "Staged launcher", cancellationToken) .ConfigureAwait(false); - if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase)) - { - throw new LauncherUpdateException( - $"Staged launcher file '{file.Path}' SHA-256 is corrupt."); - } } } @@ -538,37 +786,121 @@ public sealed class LauncherSelfUpdateManager { foreach (InstalledFileRecord file in plan.Files) { - cancellationToken.ThrowIfCancellationRequested(); - string path = ClientVersionStore.ResolveContained(targetDirectory, file.Path); - EnsureSafeParent(targetDirectory, path); - var info = new FileInfo(path); - if (!info.Exists - || (info.Attributes & FileAttributes.ReparsePoint) != 0 - || info.Length != file.Size) - { - throw new LauncherUpdateException( - $"Applied launcher file '{file.Path}' is missing, linked, or corrupt."); - } - - string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( - path, + await VerifyFileAsync( + targetDirectory, + file, + "Applied launcher", cancellationToken) .ConfigureAwait(false); - if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase)) - { - throw new LauncherUpdateException( - $"Applied launcher file '{file.Path}' SHA-256 is corrupt."); - } + } - if (OperatingSystem.IsLinux() - && ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode) + if (plan.Apply is not null) + { + foreach (SelfUpdateApplyEntry obsolete in plan.Apply.Where(entry => + entry.Operation == SelfUpdateApplyOperation.Remove)) { - throw new LauncherUpdateException( - $"Applied launcher file '{file.Path}' mode is corrupt."); + string path = ClientVersionStore.ResolveContained( + targetDirectory, + obsolete.Path); + if (File.Exists(path) || Directory.Exists(path)) + { + throw new LauncherUpdateException( + $"Obsolete launcher file '{obsolete.Path}' remains after apply."); + } } } } + private static async Task VerifyFileAsync( + string root, + InstalledFileRecord file, + string description, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + string path = ClientVersionStore.ResolveContained(root, file.Path); + EnsureSafeParent(root, path); + var info = new FileInfo(path); + if (!info.Exists + || (info.Attributes & FileAttributes.ReparsePoint) != 0 + || info.Length != file.Size) + { + throw new LauncherUpdateException( + $"{description} file '{file.Path}' is missing, linked, or corrupt."); + } + + string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( + path, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new LauncherUpdateException( + $"{description} file '{file.Path}' SHA-256 is corrupt."); + } + + if (OperatingSystem.IsLinux() + && ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode) + { + throw new LauncherUpdateException( + $"{description} file '{file.Path}' mode is corrupt."); + } + } + + private static async Task ReadAndVerifyInstallRecordAsync( + string targetDirectory, + string rid, + CancellationToken cancellationToken) + { + string path = Path.Combine(targetDirectory, InstallRecordFileName); + if (!File.Exists(path)) + { + return null; + } + + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException("The launcher ownership record is linked."); + } + + byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken) + .ConfigureAwait(false); + LauncherBinaryInstallRecord? record = ClientVersionStore.ParseStrict( + bytes, + SerializerOptions); + ValidateInstallRecord(record, rid); + foreach (InstalledFileRecord file in record!.Files) + { + await VerifyFileAsync( + targetDirectory, + file, + "Owned launcher", + cancellationToken) + .ConfigureAwait(false); + } + + return record; + } + + private static async Task VerifyInstalledOwnershipMatchesPlanAsync( + SelfUpdatePlan plan, + string targetDirectory, + CancellationToken cancellationToken) + { + LauncherBinaryInstallRecord? record = await ReadAndVerifyInstallRecordAsync( + targetDirectory, + plan.Rid, + cancellationToken) + .ConfigureAwait(false); + if (record is null + || !string.Equals(record.Version, plan.Version, StringComparison.Ordinal) + || !record.Files.SequenceEqual(plan.Files)) + { + throw new LauncherUpdateException( + "The installed launcher ownership record does not match the pending plan."); + } + } + private async Task WritePlanAsync( SelfUpdatePlan plan, CancellationToken cancellationToken) @@ -607,37 +939,15 @@ public sealed class LauncherSelfUpdateManager "The self-update target does not match the running launcher directory."); } - if (plan.Files is null || plan.Files.Count == 0) - { - throw new LauncherUpdateException("The self-update file list is empty."); - } - - var paths = new HashSet(StringComparer.OrdinalIgnoreCase); - string? prior = null; - foreach (InstalledFileRecord file in plan.Files) - { - if (!ClientVersionStore.IsNormalizedRelative(file.Path) - || !paths.Add(file.Path) - || !ReleaseManifestClient.IsSha256(file.Sha256) - || file.Size < 0 - || file.UnixMode is < 0 or > 0x1FF - || (prior is not null - && string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0)) - { - throw new LauncherUpdateException( - "The self-update file list is invalid, duplicated, or unsorted."); - } - - prior = file.Path; - } - - string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) - ? ".exe" - : string.Empty; - if (!paths.Contains("acdream-launcher" + suffix)) + ValidateFileRecords(plan.Files, "self-update file list"); + var newPaths = new HashSet( + plan.Files.Select(file => file.Path), + StringComparer.OrdinalIgnoreCase); + if (newPaths.Contains(InstallRecordFileName) + || !newPaths.Contains(GetLauncherFileName(plan.Rid))) { throw new LauncherUpdateException( - "The self-update plan lacks the launcher root executable."); + "The self-update file list has a reserved path or lacks the launcher executable."); } if (plan.State == SelfUpdatePlanState.Staged && plan.Apply is not null @@ -649,22 +959,137 @@ public sealed class LauncherSelfUpdateManager if (plan.Apply is not null) { - if (plan.Apply.Count != plan.Files.Count - || !plan.Apply.Select(entry => entry.Path) - .SequenceEqual(plan.Files.Select(file => file.Path), StringComparer.Ordinal)) + var applyPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + string? prior = null; + foreach (SelfUpdateApplyEntry entry in plan.Apply) + { + if (!ClientVersionStore.IsNormalizedRelative(entry.Path) + || !applyPaths.Add(entry.Path) + || !Enum.IsDefined(entry.Operation) + || (entry.Operation == SelfUpdateApplyOperation.Remove + && !entry.HadOriginal) + || (prior is not null + && string.Compare(prior, entry.Path, StringComparison.Ordinal) >= 0)) + { + throw new LauncherUpdateException( + "The self-update apply journal is invalid, duplicated, or unsorted."); + } + + prior = entry.Path; + } + + foreach (string required in newPaths.Append(InstallRecordFileName)) + { + SelfUpdateApplyEntry? entry = plan.Apply.FirstOrDefault(candidate => + string.Equals(candidate.Path, required, StringComparison.OrdinalIgnoreCase)); + if (entry?.Operation != SelfUpdateApplyOperation.Install) + { + throw new LauncherUpdateException( + "The self-update apply journal does not install every new owned file."); + } + } + + if (plan.Apply.Any(entry => + entry.Operation == SelfUpdateApplyOperation.Remove + && (newPaths.Contains(entry.Path) + || string.Equals( + entry.Path, + InstallRecordFileName, + StringComparison.OrdinalIgnoreCase)))) { throw new LauncherUpdateException( - "The self-update apply journal does not match the file list."); + "The self-update journal removes a new or reserved file."); } } string transactionDirectory = GetTransactionDirectory(plan.TransactionId); - if (!IsContained(TransactionsDirectory, transactionDirectory)) + if (!IsContained(TransactionsDirectory, transactionDirectory) + || !IsContained(target, GetTargetTransactionDirectory(plan))) { - throw new LauncherUpdateException("The self-update transaction path escaped."); + throw new LauncherUpdateException("A self-update transaction path escaped."); } } + private static void ValidateInstallRecord(LauncherBinaryInstallRecord? record, string rid) + { + if (record is null + || record.SchemaVersion != LauncherBinaryInstallRecord.CurrentSchemaVersion + || !LauncherVersion.TryParse(record.Version, out _) + || !string.Equals(record.Rid, rid, StringComparison.Ordinal)) + { + throw new LauncherUpdateException("The launcher ownership record is invalid."); + } + + ValidateFileRecords(record.Files, "launcher ownership file list"); + if (record.Files.Any(file => string.Equals( + file.Path, + InstallRecordFileName, + StringComparison.OrdinalIgnoreCase)) + || !record.Files.Any(file => string.Equals( + file.Path, + GetLauncherFileName(rid), + StringComparison.OrdinalIgnoreCase))) + { + throw new LauncherUpdateException( + "The launcher ownership record contains its reserved metadata path " + + "or lacks the canonical launcher executable."); + } + } + + private static void ValidateFileRecords( + IReadOnlyList? files, + string description) + { + if (files is null || files.Count == 0) + { + throw new LauncherUpdateException($"The {description} is empty."); + } + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + string? prior = null; + foreach (InstalledFileRecord file in files) + { + if (!ClientVersionStore.IsNormalizedRelative(file.Path) + || !paths.Add(file.Path) + || !ReleaseManifestClient.IsSha256(file.Sha256) + || file.Size < 0 + || file.UnixMode is < 0 or > 0x1FF + || (prior is not null + && string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0)) + { + throw new LauncherUpdateException( + $"The {description} is invalid, duplicated, or unsorted."); + } + + prior = file.Path; + } + } + + private static async Task CopyFileDurablyAsync( + string source, + string destination, + CancellationToken cancellationToken) + { + await using var input = new FileStream( + source, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await using var output = new FileStream( + destination, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough); + await input.CopyToAsync(output, 64 * 1024, cancellationToken) + .ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + private static string NormalizeTargetDirectory(string targetDirectory) { ArgumentException.ThrowIfNullOrWhiteSpace(targetDirectory); @@ -741,7 +1166,7 @@ public sealed class LauncherSelfUpdateManager } } - private void CleanupOwnedResidue(string? keepTransactionId) + private void CleanupDataResidue(string? keepTransactionId) { if (Directory.Exists(TransactionsDirectory)) { @@ -751,12 +1176,7 @@ public sealed class LauncherSelfUpdateManager SearchOption.TopDirectoryOnly)) { string name = Path.GetFileName(directory); - if (name.Length == 32 - && Guid.TryParseExact(name, "N", out Guid transaction) - && string.Equals( - transaction.ToString("N"), - name, - StringComparison.Ordinal) + if (IsCanonicalTransactionId(name) && !string.Equals(name, keepTransactionId, StringComparison.Ordinal)) { SafeZipExtractor.TryDeleteDirectory(directory); @@ -775,12 +1195,72 @@ public sealed class LauncherSelfUpdateManager SearchOption.TopDirectoryOnly)) { string name = Path.GetFileName(temporary); - string prefix = ".pending.json."; - string transaction = name[prefix.Length..^".tmp".Length]; - if (Guid.TryParseExact(transaction, "N", out _)) + const string prefix = ".pending.json."; + const string suffix = ".tmp"; + if (name.Length == prefix.Length + 32 + suffix.Length + && name.StartsWith(prefix, StringComparison.Ordinal) + && name.EndsWith(suffix, StringComparison.Ordinal) + && IsCanonicalTransactionId(name.Substring(prefix.Length, 32))) { VerifiedArtifactDownloader.TryDelete(temporary); } } } + + private static void CleanupTargetResidue( + string targetDirectory, + string? keepTransactionId) + { + foreach (string directory in Directory.EnumerateDirectories( + targetDirectory, + TargetTransactionPrefix + "*", + SearchOption.TopDirectoryOnly)) + { + string name = Path.GetFileName(directory); + string transaction = name[TargetTransactionPrefix.Length..]; + if (name.Length == TargetTransactionPrefix.Length + 32 + && IsCanonicalTransactionId(transaction) + && !string.Equals(transaction, keepTransactionId, StringComparison.Ordinal)) + { + SafeZipExtractor.TryDeleteDirectory(directory); + } + } + } + + private bool HasReclaimableResidue( + string? keepDataTransactionId, + string targetDirectory, + string? keepTargetTransactionId) + { + bool data = Directory.Exists(TransactionsDirectory) + && Directory.EnumerateDirectories( + TransactionsDirectory, + "*", + SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .Any(name => name is not null + && IsCanonicalTransactionId(name) + && !string.Equals( + name, + keepDataTransactionId, + StringComparison.Ordinal)); + bool target = Directory.EnumerateDirectories( + targetDirectory, + TargetTransactionPrefix + "*", + SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .Any(name => name is not null + && name.Length == TargetTransactionPrefix.Length + 32 + && IsCanonicalTransactionId(name[TargetTransactionPrefix.Length..]) + && !string.Equals( + name[TargetTransactionPrefix.Length..], + keepTargetTransactionId, + StringComparison.Ordinal)); + return data || target; + } + + private static bool IsCanonicalTransactionId(string value) => + value.Length == 32 + && Guid.TryParseExact(value, "N", out Guid parsed) + && string.Equals(parsed.ToString("N"), value, StringComparison.Ordinal); } diff --git a/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs b/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs index 810ac57c..01d7676b 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs @@ -292,9 +292,6 @@ public sealed class LauncherUpdater : ILauncherUpdater try { RefuseRunningSessions(); - using UpdateSessionBarrier.ExclusiveLease lease = - _versions.Barrier.AcquireExclusive(); - RefuseRunningSessions(); if (check.Manifest.Version <= _launcherVersion) { throw new LauncherUpdateException( diff --git a/src/AcDream.Launcher.Core/Updates/PortablePathRules.cs b/src/AcDream.Launcher.Core/Updates/PortablePathRules.cs new file mode 100644 index 00000000..1f5ec179 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/PortablePathRules.cs @@ -0,0 +1,38 @@ +namespace AcDream.Launcher.Core.Updates; + +/// +/// Host-independent path rules for payloads that must remain safe when moved +/// between Linux and Windows. Windows device aliases are rejected on every +/// host so a release cannot verify on one platform and become ambiguous on +/// another. +/// +internal static class PortablePathRules +{ + public static bool IsWindowsDeviceName(string segment) + { + ArgumentNullException.ThrowIfNull(segment); + string stem = segment.Split('.')[0]; + if (stem.Equals("CON", StringComparison.OrdinalIgnoreCase) + || stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) + || stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) + || stem.Equals("NUL", StringComparison.OrdinalIgnoreCase) + || stem.Equals("CLOCK$", StringComparison.OrdinalIgnoreCase) + || stem.Equals("CONIN$", StringComparison.OrdinalIgnoreCase) + || stem.Equals("CONOUT$", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (stem.Length != 4 + || (!stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase) + && !stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return stem[3] is >= '1' and <= '9' + or '\u00b9' + or '\u00b2' + or '\u00b3'; + } +} diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs index 6718ddcb..a31625c1 100644 --- a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs @@ -10,9 +10,11 @@ public interface IReleaseManifestClient } /// -/// Strict, bounded reader for the pinned GitHub Releases manifest. HTTP is -/// accepted only for a loopback fixture; production and artifact URLs are -/// HTTPS-only. +/// Strict, bounded reader for the pinned GitHub Releases manifest. Production +/// construction is HTTPS-only. The loopback HTTP allowance is available only +/// through an internal fixture factory and is never inferred from a URI. +/// Redirects are followed manually so every hop is checked before any bytes +/// cross that hop. /// public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable { @@ -20,6 +22,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable public const string GitHubRepository = "acdream"; public const int MaximumManifestBytes = 1024 * 1024; public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024; + public const int MaximumRedirects = 5; public static Uri ProductionManifestUri { get; } = new( $"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json"); @@ -33,65 +36,103 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable }; private readonly HttpClient _httpClient; - private readonly bool _ownsHttpClient; private readonly Uri _manifestUri; + private readonly bool _allowLoopbackHttp; - public ReleaseManifestClient(HttpClient? httpClient = null, Uri? manifestUri = null) + public ReleaseManifestClient(TimeSpan? timeout = null) + : this( + ProductionManifestUri, + allowLoopbackHttp: false, + CreateRedirectDisabledHandler(), + timeout) { - _httpClient = httpClient ?? new HttpClient(); - _ownsHttpClient = httpClient is null; - _manifestUri = manifestUri ?? ProductionManifestUri; - RequireSecureOrLoopback(_manifestUri, "manifest"); - if (_ownsHttpClient) - { - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); - } } + private ReleaseManifestClient( + Uri manifestUri, + bool allowLoopbackHttp, + HttpMessageHandler handler, + TimeSpan? timeout) + { + ArgumentNullException.ThrowIfNull(manifestUri); + ArgumentNullException.ThrowIfNull(handler); + _manifestUri = manifestUri; + _allowLoopbackHttp = allowLoopbackHttp; + RequireTransport(_manifestUri, "manifest", _allowLoopbackHttp); + _httpClient = new HttpClient(handler, disposeHandler: true) + { + Timeout = timeout ?? TimeSpan.FromSeconds(15), + }; + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); + } + + internal static ReleaseManifestClient CreateLoopbackFixture( + Uri manifestUri, + TimeSpan? timeout = null) => new( + manifestUri, + allowLoopbackHttp: true, + CreateRedirectDisabledHandler(), + timeout); + + internal static ReleaseManifestClient CreateForTransportTest( + Uri manifestUri, + bool allowLoopbackHttp, + HttpMessageHandler handler) => new( + manifestUri, + allowLoopbackHttp, + handler, + TimeSpan.FromSeconds(15)); + public async Task FetchAsync( CancellationToken cancellationToken = default) { try { - using HttpResponseMessage response = await _httpClient.GetAsync( - _manifestUri, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken) - .ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - Uri finalUri = response.RequestMessage?.RequestUri ?? _manifestUri; - RequireSecureOrLoopback(finalUri, "manifest redirect"); - if (response.Content.Headers.ContentLength is long contentLength - && contentLength > MaximumManifestBytes) + Uri current = _manifestUri; + var visited = new HashSet(StringComparer.Ordinal); + for (int redirectCount = 0;;) { - throw new LauncherUpdateException( - $"The release manifest is larger than {MaximumManifestBytes} bytes."); - } - - await using Stream input = await response.Content - .ReadAsStreamAsync(cancellationToken) - .ConfigureAwait(false); - using var output = new MemoryStream(); - byte[] buffer = new byte[16 * 1024]; - while (true) - { - int read = await input.ReadAsync(buffer, cancellationToken) - .ConfigureAwait(false); - if (read == 0) - { - break; - } - - if (output.Length + read > MaximumManifestBytes) + RequireTransport(current, "manifest redirect", _allowLoopbackHttp); + if (!visited.Add(current.AbsoluteUri)) { throw new LauncherUpdateException( - $"The release manifest is larger than {MaximumManifestBytes} bytes."); + "The release manifest redirect chain contains a loop."); } - output.Write(buffer, 0, read); - } + using var request = new HttpRequestMessage(HttpMethod.Get, current); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + if (IsRedirect(response.StatusCode)) + { + if (redirectCount >= MaximumRedirects) + { + throw new LauncherUpdateException( + $"The release manifest exceeded {MaximumRedirects} redirects."); + } - return Parse(output.ToArray()); + Uri? location = response.Headers.Location; + if (location is null) + { + throw new LauncherUpdateException( + "The release manifest redirect has no Location header."); + } + + Uri next = location.IsAbsoluteUri + ? location + : new Uri(current, location); + RequireTransport(next, "manifest redirect", _allowLoopbackHttp); + current = next; + redirectCount++; + continue; + } + + response.EnsureSuccessStatusCode(); + return await ReadAndParseAsync(response, cancellationToken) + .ConfigureAwait(false); + } } catch (OperationCanceledException) { @@ -112,7 +153,9 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable } } - internal static ReleaseManifest Parse(ReadOnlySpan utf8) + internal static ReleaseManifest Parse( + ReadOnlySpan utf8, + bool allowLoopbackHttpArtifacts = false) { try { @@ -127,7 +170,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable RejectDuplicateProperties(document.RootElement, "$" ); ManifestDocument? value = document.RootElement.Deserialize( SerializerOptions); - return Validate(value); + return Validate(value, allowLoopbackHttpArtifacts); } catch (LauncherUpdateException) { @@ -143,26 +186,68 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable } } - public void Dispose() - { - if (_ownsHttpClient) - { - _httpClient.Dispose(); - } - } + public void Dispose() => _httpClient.Dispose(); - internal static void RequireSecureOrLoopback(Uri uri, string description) + internal static void RequireTransport( + Uri uri, + string description, + bool allowLoopbackHttp) { if (!uri.IsAbsoluteUri || (uri.Scheme != Uri.UriSchemeHttps - && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback))) + && !(allowLoopbackHttp + && uri.Scheme == Uri.UriSchemeHttp + && uri.IsLoopback))) { throw new LauncherUpdateException( - $"The {description} URI must use HTTPS (loopback HTTP is test-only)."); + $"The {description} URI must use HTTPS" + + (allowLoopbackHttp ? " (or fixture-only loopback HTTP)." : ".")); } } - private static ReleaseManifest Validate(ManifestDocument? document) + internal static void RequireSecureOrLoopback(Uri uri, string description) => + RequireTransport(uri, description, allowLoopbackHttp: true); + + private async Task ReadAndParseAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + if (response.Content.Headers.ContentLength is long contentLength + && contentLength > MaximumManifestBytes) + { + throw new LauncherUpdateException( + $"The release manifest is larger than {MaximumManifestBytes} bytes."); + } + + await using Stream input = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + using var output = new MemoryStream(); + byte[] buffer = new byte[16 * 1024]; + while (true) + { + int read = await input.ReadAsync(buffer, cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + if (output.Length + read > MaximumManifestBytes) + { + throw new LauncherUpdateException( + $"The release manifest is larger than {MaximumManifestBytes} bytes."); + } + + output.Write(buffer, 0, read); + } + + return Parse(output.ToArray(), _allowLoopbackHttp); + } + + private static ReleaseManifest Validate( + ManifestDocument? document, + bool allowLoopbackHttpArtifacts) { if (document is null) { @@ -187,18 +272,22 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable throw new LauncherUpdateException( "The minimum launcher version cannot exceed the release version."); } + IReadOnlyDictionary clients = ValidateArtifacts( document.Clients, - "clients"); + "clients", + allowLoopbackHttpArtifacts); IReadOnlyDictionary launchers = ValidateArtifacts( document.Launchers, - "launchers"); + "launchers", + allowLoopbackHttpArtifacts); return new ReleaseManifest(version, minimum, clients, launchers); } private static IReadOnlyDictionary ValidateArtifacts( Dictionary? artifacts, - string field) + string field, + bool allowLoopbackHttpArtifacts) { if (artifacts is null || artifacts.Count == 0) { @@ -226,7 +315,10 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable $"Manifest payload '{field}.{rid}' has an invalid URL."); } - RequireSecureOrLoopback(uri, $"{field}.{rid} artifact"); + RequireTransport( + uri, + $"{field}.{rid} artifact", + allowLoopbackHttpArtifacts); if (!IsSha256(value.Sha256)) { throw new LauncherUpdateException( @@ -250,6 +342,21 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable internal static bool IsSha256(string? value) => value is { Length: 64 } && value.All(Uri.IsHexDigit); + private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is + HttpStatusCode.MovedPermanently + or HttpStatusCode.Found + or HttpStatusCode.SeeOther + or HttpStatusCode.TemporaryRedirect + or HttpStatusCode.PermanentRedirect; + + private static HttpMessageHandler CreateRedirectDisabledHandler() => + new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false, + AutomaticDecompression = DecompressionMethods.None, + }; + private static void RejectDuplicateProperties(JsonElement element, string path) { if (element.ValueKind == JsonValueKind.Object) diff --git a/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs b/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs index 6d2c77ee..e7a39b5f 100644 --- a/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs +++ b/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs @@ -297,7 +297,7 @@ public sealed class SafeZipExtractor || segment.Any(character => char.IsControl(character) || character is '<' or '>' or '"' or '|' or '?' or '*') - || IsWindowsDeviceName(segment)) + || PortablePathRules.IsWindowsDeviceName(segment)) { throw new LauncherUpdateException( $"ZIP path '{name}' contains an unsafe segment."); @@ -396,19 +396,6 @@ public sealed class SafeZipExtractor } } - private static bool IsWindowsDeviceName(string segment) - { - string stem = segment.Split('.')[0]; - return stem.Equals("CON", StringComparison.OrdinalIgnoreCase) - || stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) - || stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) - || stem.Equals("NUL", StringComparison.OrdinalIgnoreCase) - || (stem.Length == 4 - && (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase) - || stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) - && stem[3] is >= '1' and <= '9'); - } - internal static void TryDeleteDirectory(string path) { try diff --git a/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs index 6c73ff35..1ea640fb 100644 --- a/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs +++ b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs @@ -34,7 +34,53 @@ public sealed class UpdateSessionBarrier FileShare.None, "A launcher session or another update transaction is running. " + "Stop every launcher session before updating."); - return new ExclusiveLease(stream); + return new ExclusiveLease(this, stream); + } + + /// + /// Non-blocking startup probe. Contention is an expected "not now" + /// result; permission and path failures remain hard errors. + /// + public bool TryAcquireExclusive(out ExclusiveLease? lease) + { + Directory.CreateDirectory( + Path.GetDirectoryName(_lockPath) + ?? throw new InvalidOperationException( + "The update/session lock path has no parent directory.")); + try + { + lease = new ExclusiveLease( + this, + new FileStream( + _lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None)); + return true; + } + catch (IOException) + { + lease = null; + return false; + } + catch (UnauthorizedAccessException ex) + { + throw new LauncherUpdateException( + $"The update/session lease could not be opened: {ex.Message}", + ex); + } + } + + internal void RequireOwned(ExclusiveLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (!lease.IsHeldBy(this)) + { + throw new LauncherUpdateException( + "The cleanup operation does not hold this update barrier's exclusive lease."); + } } private FileStream Open(FileShare share, string refusal) @@ -76,9 +122,18 @@ public sealed class UpdateSessionBarrier public sealed class ExclusiveLease : IDisposable { + private readonly UpdateSessionBarrier _owner; private FileStream? _stream; - internal ExclusiveLease(FileStream stream) => _stream = stream; + internal ExclusiveLease(UpdateSessionBarrier owner, FileStream stream) + { + _owner = owner; + _stream = stream; + } + + internal bool IsHeldBy(UpdateSessionBarrier owner) => + ReferenceEquals(_owner, owner) + && Volatile.Read(ref _stream) is not null; public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose(); } diff --git a/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs b/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs index 608207ff..6589d102 100644 --- a/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs +++ b/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Net; using System.Security.Cryptography; namespace AcDream.Launcher.Core.Updates; @@ -55,15 +56,11 @@ public sealed class VerifiedArtifactDownloader bool ownsDestination = false; try { - using HttpResponseMessage response = await _httpClient.GetAsync( + using HttpResponseMessage response = await SendWithValidatedRedirectsAsync( artifact.Url, - HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); response.EnsureSuccessStatusCode(); - ReleaseManifestClient.RequireSecureOrLoopback( - response.RequestMessage?.RequestUri ?? artifact.Url, - "artifact redirect"); if (response.Content.Headers.ContentLength is long contentLength && contentLength != artifact.Size) { @@ -183,6 +180,86 @@ public sealed class VerifiedArtifactDownloader } } + private async Task SendWithValidatedRedirectsAsync( + Uri initialUri, + CancellationToken cancellationToken) + { + bool allowLoopbackHttp = initialUri.Scheme == Uri.UriSchemeHttp + && initialUri.IsLoopback; + Uri current = initialUri; + var visited = new HashSet(StringComparer.Ordinal); + for (int redirectCount = 0;;) + { + ReleaseManifestClient.RequireTransport( + current, + "artifact redirect", + allowLoopbackHttp); + if (!visited.Add(current.AbsoluteUri)) + { + throw new LauncherUpdateException( + "The release artifact redirect chain contains a loop."); + } + + using var request = new HttpRequestMessage(HttpMethod.Get, current); + HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + Uri effectiveUri = response.RequestMessage?.RequestUri ?? current; + if (!Uri.Equals(effectiveUri, current)) + { + response.Dispose(); + throw new LauncherUpdateException( + "The artifact HTTP transport followed an automatic redirect; " + + "every redirect must be validated before it is requested."); + } + + if (!IsRedirect(response.StatusCode)) + { + return response; + } + + try + { + if (redirectCount >= ReleaseManifestClient.MaximumRedirects) + { + throw new LauncherUpdateException( + $"The release artifact exceeded " + + $"{ReleaseManifestClient.MaximumRedirects} redirects."); + } + + Uri? location = response.Headers.Location; + if (location is null) + { + throw new LauncherUpdateException( + "The release artifact redirect has no Location header."); + } + + Uri next = location.IsAbsoluteUri + ? location + : new Uri(current, location); + ReleaseManifestClient.RequireTransport( + next, + "artifact redirect", + allowLoopbackHttp); + current = next; + redirectCount++; + } + finally + { + response.Dispose(); + } + } + } + + private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is + HttpStatusCode.MovedPermanently + or HttpStatusCode.Found + or HttpStatusCode.SeeOther + or HttpStatusCode.TemporaryRedirect + or HttpStatusCode.PermanentRedirect; + internal static void TryDelete(string path) { try diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index 63fa57ff..350260b2 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -14,6 +14,10 @@ true + + + + diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs index dae76b75..c42f0de0 100644 --- a/src/AcDream.Launcher/App.axaml.cs +++ b/src/AcDream.Launcher/App.axaml.cs @@ -16,8 +16,7 @@ public sealed partial class App : Application { private LauncherOrchestrator? _orchestrator; private LauncherWindowViewModel? _viewModel; - private HttpClient? _updateHttpClient; - private ReleaseManifestClient? _manifestClient; + private LauncherUpdateComposition? _updateComposition; public override void Initialize() => AvaloniaXamlLoader.Load(this); @@ -52,38 +51,27 @@ public sealed partial class App : Application $"Client content verification failed: {ex.Message}"); } - var clientVersions = new ClientVersionStore(paths); - _ = clientVersions.LoadAndRecoverAsync(rid) - .GetAwaiter() - .GetResult(); + LauncherUpdateComposition updates = LauncherUpdateComposition.Create( + paths, + rid, + GetLauncherVersion(), + AppContext.BaseDirectory, + () => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive) + == true); + _updateComposition = updates; _orchestrator = new LauncherOrchestrator( profiles, paths, - LauncherExecutableSet.FromCurrentVersionStore(clientVersions), + updates.Executables, verification.Record, installationStatus: verification.Status, - updateSessionBarrier: clientVersions.Barrier); - _updateHttpClient = new HttpClient(); - _updateHttpClient.Timeout = TimeSpan.FromSeconds(15); - _updateHttpClient.DefaultRequestHeaders.UserAgent.ParseAdd( - "acdream-launcher/1"); - _manifestClient = new ReleaseManifestClient(_updateHttpClient); - var selfUpdates = new LauncherSelfUpdateManager(paths, _updateHttpClient); - var updater = new LauncherUpdater( - _manifestClient, - _updateHttpClient, - clientVersions, - selfUpdates, - GetLauncherVersion(), - rid, - AppContext.BaseDirectory, - () => _orchestrator.GetSnapshot().Sessions.Any(session => session.IsActive)); + updateSessionBarrier: updates.Versions.Barrier); _viewModel = new LauncherWindowViewModel( _orchestrator, new AvaloniaUiDispatcher(), installer, - updater); + updates.Updater); _viewModel.Initialize(); desktop.MainWindow = new MainWindow @@ -100,12 +88,10 @@ public sealed partial class App : Application { _viewModel?.Dispose(); _orchestrator?.Dispose(); - _manifestClient?.Dispose(); - _updateHttpClient?.Dispose(); + _updateComposition?.Dispose(); _viewModel = null; _orchestrator = null; - _manifestClient = null; - _updateHttpClient = null; + _updateComposition = null; } private static LauncherVersion GetLauncherVersion() diff --git a/src/AcDream.Launcher/LauncherUpdateComposition.cs b/src/AcDream.Launcher/LauncherUpdateComposition.cs new file mode 100644 index 00000000..3b395042 --- /dev/null +++ b/src/AcDream.Launcher/LauncherUpdateComposition.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Security; +using System.Text.Json; +using AcDream.Launcher.Core.Orchestration; +using AcDream.Launcher.Core.Updates; +using AcDream.Launcher.ViewModels; +using AcDream.Platform; + +namespace AcDream.Launcher; + +/// +/// Testable startup transaction for versioned-client/update services. Storage +/// failures produce a fail-closed executable resolver and an unavailable UI +/// projection; they do not abort profile/installer window construction. +/// +internal sealed class LauncherUpdateComposition : IDisposable +{ + private readonly HttpClient? _artifactClient; + private readonly ReleaseManifestClient? _manifestClient; + + private LauncherUpdateComposition( + ClientVersionStore versions, + LauncherExecutableSet executables, + ILauncherUpdater updater, + HttpClient? artifactClient, + ReleaseManifestClient? manifestClient) + { + Versions = versions; + Executables = executables; + Updater = updater; + _artifactClient = artifactClient; + _manifestClient = manifestClient; + } + + public ClientVersionStore Versions { get; } + + public LauncherExecutableSet Executables { get; } + + public ILauncherUpdater Updater { get; } + + public static LauncherUpdateComposition Create( + ApplicationPathSet paths, + string rid, + LauncherVersion launcherVersion, + string launcherTargetDirectory, + Func hasRunningSessions, + Func? initialize = null) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(launcherVersion); + ArgumentNullException.ThrowIfNull(hasRunningSessions); + var versions = new ClientVersionStore(paths); + HttpClient? artifactClient = null; + ReleaseManifestClient? manifestClient = null; + try + { + _ = initialize is null + ? versions.LoadAndRecoverAsync(rid).GetAwaiter().GetResult() + : initialize(versions, rid); + artifactClient = new HttpClient( + new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false, + AutomaticDecompression = DecompressionMethods.None, + }, + disposeHandler: true) + { + Timeout = TimeSpan.FromSeconds(15), + }; + artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); + manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15)); + var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient); + var updater = new LauncherUpdater( + manifestClient, + artifactClient, + versions, + selfUpdates, + launcherVersion, + rid, + launcherTargetDirectory, + hasRunningSessions); + return new LauncherUpdateComposition( + versions, + LauncherExecutableSet.FromCurrentVersionStore(versions), + updater, + artifactClient, + manifestClient); + } + catch (Exception ex) when (IsStorageFailure(ex)) + { + manifestClient?.Dispose(); + artifactClient?.Dispose(); + string status = "Versioned client update storage is unavailable: " + + (string.IsNullOrWhiteSpace(ex.Message) + ? "the storage operation failed." + : ex.Message); + var resolution = new ClientVersionResolution( + ClientVersionState.Invalid, + status, + null, + null, + null, + null); + return new LauncherUpdateComposition( + versions, + LauncherExecutableSet.Unavailable(status), + new UnavailableLauncherUpdater(status, resolution), + artifactClient: null, + manifestClient: null); + } + } + + public void Dispose() + { + _manifestClient?.Dispose(); + _artifactClient?.Dispose(); + } + + private static bool IsStorageFailure(Exception exception) => exception is + IOException + or UnauthorizedAccessException + or SecurityException + or JsonException + or FormatException + or NotSupportedException + or LauncherUpdateException; +} diff --git a/src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs b/src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs index 97161d89..c196cdd8 100644 --- a/src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs +++ b/src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs @@ -479,42 +479,52 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable internal sealed class UnavailableLauncherUpdater : ILauncherUpdater { - private static readonly ClientVersionResolution Missing = new( - ClientVersionState.Missing, - "Versioned client updater is unavailable.", - null, - null, - null, - null); + private readonly ClientVersionResolution _resolution; + private readonly string _status; - public ClientVersionResolution CurrentClient => Missing; + public UnavailableLauncherUpdater( + string status = "Versioned client updater is unavailable.", + ClientVersionResolution? resolution = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(status); + _status = status; + _resolution = resolution ?? new ClientVersionResolution( + ClientVersionState.Invalid, + status, + null, + null, + null, + null); + } + + public ClientVersionResolution CurrentClient => _resolution; public Task InitializeAsync( CancellationToken cancellationToken = default) => - Task.FromResult(Missing); + Task.FromResult(_resolution); public Task CheckAsync( CancellationToken cancellationToken = default) => Task.FromException( - new LauncherUpdateException("Versioned client updater is unavailable.")); + new LauncherUpdateException(_status)); public Task InstallClientAsync( LauncherUpdateCheckResult check, IProgress? progress = null, CancellationToken cancellationToken = default) => Task.FromException( - new LauncherUpdateException("Versioned client updater is unavailable.")); + new LauncherUpdateException(_status)); public Task StageLauncherAsync( LauncherUpdateCheckResult check, IProgress? progress = null, CancellationToken cancellationToken = default) => Task.FromException( - new LauncherUpdateException("Versioned client updater is unavailable.")); + new LauncherUpdateException(_status)); public Task RollbackClientAsync( IProgress? progress = null, CancellationToken cancellationToken = default) => Task.FromException( - new LauncherUpdateException("Versioned client updater is unavailable.")); + new LauncherUpdateException(_status)); } diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs index c33d0a93..8e3f45ac 100644 --- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs @@ -5,15 +5,176 @@ using AcDream.Launcher.Core.Installation; using AcDream.Launcher.Core.Updates; using AcDream.Platform; -return args.FirstOrDefault() switch +const string SelfUpdateDataEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_DATA"; +const string SelfUpdateTargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET"; +const string SelfUpdateHelperPidEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID"; + +string[] effectiveArgs = args; +string? selfUpdateData = Environment.GetEnvironmentVariable(SelfUpdateDataEnvironment); +string? selfUpdateTarget = Environment.GetEnvironmentVariable(SelfUpdateTargetEnvironment); +if (!string.IsNullOrWhiteSpace(selfUpdateData) + && !string.IsNullOrWhiteSpace(selfUpdateTarget) + && IsBootstrapInvocation(effectiveArgs)) { - "hold-install-lease" => await HoldInstallLeaseAsync(args[1..]), - "hold-update-lease" => await HoldUpdateLeaseAsync(args[1..]), - "orphan-parent" => await RunOrphanParentAsync(args[1..]), - "orphan-child" => RunOrphanChild(args[1..]), + if (effectiveArgs[0] == LauncherSelfUpdateBootstrap.HelperArgument + && Environment.GetEnvironmentVariable(SelfUpdateHelperPidEnvironment) is string helperPid + && !string.IsNullOrWhiteSpace(helperPid)) + { + File.WriteAllText( + Path.GetFullPath(helperPid), + Environment.ProcessId.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + } + + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(Paths(selfUpdateData), http); + SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync( + effectiveArgs, + manager, + Path.GetFullPath(selfUpdateTarget), + Path.GetFullPath( + Environment.ProcessPath + ?? throw new InvalidOperationException("Process path is unavailable."))); + if (startup.ShouldExit) + { + return startup.ExitCode; + } + + effectiveArgs = startup.RemainingArguments; +} + +return effectiveArgs.FirstOrDefault() switch +{ + "hold-install-lease" => await HoldInstallLeaseAsync(effectiveArgs[1..]), + "hold-update-lease" => await HoldUpdateLeaseAsync(effectiveArgs[1..]), + "orphan-parent" => await RunOrphanParentAsync(effectiveArgs[1..]), + "orphan-child" => RunOrphanChild(effectiveArgs[1..]), + "crash-self-update" => await CrashSelfUpdateAsync(effectiveArgs[1..]), + "stage-self-update" => await StageSelfUpdateAsync(effectiveArgs[1..]), + "bootstrap-probe" => await BootstrapProbeAsync(effectiveArgs[1..]), + "canonical-probe" => CanonicalProbe(effectiveArgs[1..]), _ => 2, }; +static bool IsBootstrapInvocation(string[] arguments) => + arguments.Length > 0 + && arguments[0] is LauncherSelfUpdateBootstrap.HelperArgument + or LauncherSelfUpdateBootstrap.ConfirmArgument + or LauncherSelfUpdateBootstrap.DeferredArgument + or "canonical-probe"; + +static ApplicationPathSet Paths(string dataDirectory) +{ + string data = Path.GetFullPath(dataDirectory); + return new ApplicationPathSet( + Path.Combine(data, "fixture-config"), + data, + Path.Combine(data, "fixture-cache"), + null); +} + +static async Task CrashSelfUpdateAsync(string[] arguments) +{ + if (arguments.Length != 4) + { + return 2; + } + + string dataDirectory = Path.GetFullPath(arguments[0]); + string targetDirectory = Path.GetFullPath(arguments[1]); + string readyPath = Path.GetFullPath(arguments[2]); + string canonicalName = arguments[3]; + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager( + Paths(dataDirectory), + http, + null, + observation => + { + if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation + && string.Equals( + observation.Path, + canonicalName, + StringComparison.Ordinal)) + { + if (!File.Exists(Path.Combine(targetDirectory, canonicalName))) + { + throw new InvalidOperationException( + "The canonical launcher vanished at the apply boundary."); + } + + File.WriteAllText(readyPath, Environment.ProcessId.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + Thread.Sleep(Timeout.Infinite); + } + }); + using UpdateSessionBarrier.ExclusiveLease lease = manager.Barrier.AcquireExclusive(); + _ = await manager.ApplyPendingAsync(targetDirectory); + return 0; +} + +static async Task StageSelfUpdateAsync(string[] arguments) +{ + if (arguments.Length != 7 + || !long.TryParse( + arguments[6], + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out long size)) + { + return 2; + } + + string dataDirectory = Path.GetFullPath(arguments[0]); + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(Paths(dataDirectory), http); + _ = await manager.StageAsync( + LauncherVersion.Parse(arguments[2]), + arguments[3], + new ReleaseArtifact(new Uri(arguments[4]), arguments[5], size), + Path.GetFullPath(arguments[1]), + progress: null, + CancellationToken.None); + return 0; +} + +static async Task BootstrapProbeAsync(string[] arguments) +{ + if (arguments.Length != 4) + { + return 2; + } + + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(Paths(arguments[0]), http); + SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], + manager, + Path.GetFullPath(arguments[1]), + Path.GetFullPath(arguments[2])); + File.WriteAllText( + Path.GetFullPath(arguments[3]), + result.ShouldExit ? "exit" : string.Join("\n", result.RemainingArguments)); + return result.ShouldExit ? 3 : 0; +} + +static int CanonicalProbe(string[] arguments) +{ + if (arguments.Length != 1) + { + return 2; + } + + File.WriteAllText( + Path.GetFullPath(arguments[0]), + Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) + + "|" + + Path.GetFullPath( + Environment.ProcessPath + ?? throw new InvalidOperationException("Process path is unavailable."))); + return 0; +} + static async Task HoldUpdateLeaseAsync(string[] arguments) { if (arguments.Length != 4 diff --git a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs index 283d680e..83ad5402 100644 --- a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs @@ -55,6 +55,42 @@ public sealed class LauncherOrchestratorTests : IDisposable using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive(); } + [Fact] + public async Task DisposeKeepsUpdateLeaseUntilLiveChildIsObservedTerminal() + { + var supervisors = new BlockingStopSupervisorFactory(); + var barrier = new UpdateSessionBarrier(_paths.DataDirectory); + LauncherOrchestrator orchestrator = CreateOrchestrator( + supervisorFactory: supervisors, + updateSessionBarrier: barrier); + try + { + _ = await orchestrator.LaunchAsync( + "Local ACE", + "testaccount", + "+Acdream", + LaunchMode.Headless); + BlockingStopSupervisor supervisor = Assert.Single(supervisors.Created); + + Task disposal = Task.Run(orchestrator.Dispose); + Assert.True(supervisor.StopEntered.Wait(TimeSpan.FromSeconds(5))); + + Assert.False(disposal.IsCompleted); + Assert.Throws(barrier.AcquireExclusive); + + supervisor.AllowTerminal.Set(); + await disposal.WaitAsync(TimeSpan.FromSeconds(5)); + using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive(); + Assert.Equal(LauncherSessionState.Exited, supervisor.State); + Assert.True(supervisor.Disposed); + } + finally + { + supervisors.AllowEveryStop(); + orchestrator.Dispose(); + } + } + [Fact] public void SnapshotProjectsTheFullHierarchyWithoutTheCredential() { @@ -756,6 +792,59 @@ public sealed class LauncherOrchestratorTests : IDisposable } } + private sealed class BlockingStopSupervisorFactory : ILauncherProcessSupervisorFactory + { + public List Created { get; } = []; + + public ILauncherProcessSupervisor Create() + { + var supervisor = new BlockingStopSupervisor(); + Created.Add(supervisor); + return supervisor; + } + + public void AllowEveryStop() + { + foreach (BlockingStopSupervisor supervisor in Created) + { + supervisor.AllowTerminal.Set(); + } + } + } + + private sealed class BlockingStopSupervisor : ILauncherProcessSupervisor + { + public ManualResetEventSlim StopEntered { get; } = new(false); + + public ManualResetEventSlim AllowTerminal { get; } = new(false); + + public LauncherSessionState State { get; private set; } = + LauncherSessionState.Starting; + + public int? ExitCode { get; private set; } + + public bool Disposed { get; private set; } + + public event EventHandler? StateChanged; + + public void Start(LauncherProcessSpec spec, string? password) + { + State = LauncherSessionState.Running; + StateChanged?.Invoke(this, State); + } + + public void Stop(TimeSpan timeout) + { + StopEntered.Set(); + AllowTerminal.Wait(); + State = LauncherSessionState.Exited; + ExitCode = 0; + StateChanged?.Invoke(this, State); + } + + public void Dispose() => Disposed = true; + } + private sealed class QueueStatusSourceFactory : IStatusEventSourceFactory { public List Created { get; } = []; diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/ClientVersionStoreTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/ClientVersionStoreTests.cs index 33ab8d86..ede382c9 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/ClientVersionStoreTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/ClientVersionStoreTests.cs @@ -177,6 +177,61 @@ public sealed class ClientVersionStoreTests : IDisposable Path.Combine(resolution.Directory!, "AcDream.App" + ExecutableSuffix))); } + [Fact] + public async Task LinuxTreatsNonCanonicalInstallJsonCasingAsUnrecordedContent() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + var store = new ClientVersionStore(_paths); + ClientVersionResolution installed = await PromoteAsync(store, "1.0.0", "linux-case"); + await File.WriteAllTextAsync( + Path.Combine(installed.Directory!, "INSTALL.JSON"), + "must-not-be-hidden"); + + ClientVersionResolution resolution = await new ClientVersionStore(_paths) + .LoadAndRecoverAsync(_rid); + + Assert.Equal(ClientVersionState.Invalid, resolution.State); + Assert.Contains("unrecorded", resolution.Status, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExclusiveStartupReclaimsOnlyCanonicalGuidOwnedResidue() + { + var store = new ClientVersionStore(_paths); + Directory.CreateDirectory(store.AppDirectory); + string id = Guid.NewGuid().ToString("N"); + string nearId = id[..31] + "g"; + string exactStaging = Path.Combine(store.AppDirectory, ".client-staging-" + id); + string exactCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + id); + string exactDownload = Path.Combine( + store.AppDirectory, + ".client-download-" + id + ".zip"); + string nearStaging = exactStaging + "-user"; + string nearCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + nearId); + string nearDownload = Path.Combine( + store.AppDirectory, + ".client-download-" + id + ".zip.user"); + Directory.CreateDirectory(exactStaging); + Directory.CreateDirectory(exactCorrupt); + Directory.CreateDirectory(nearStaging); + Directory.CreateDirectory(nearCorrupt); + await File.WriteAllTextAsync(exactDownload, "owned"); + await File.WriteAllTextAsync(nearDownload, "preserve"); + + _ = await store.LoadAndRecoverAsync(_rid); + + Assert.False(Directory.Exists(exactStaging)); + Assert.False(Directory.Exists(exactCorrupt)); + Assert.False(File.Exists(exactDownload)); + Assert.True(Directory.Exists(nearStaging)); + Assert.True(Directory.Exists(nearCorrupt)); + Assert.True(File.Exists(nearDownload)); + } + private string ExecutableSuffix => _rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty; diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs index 326f6011..fe4f0621 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs @@ -1,23 +1,9 @@ -using System.Text.Json; -using System.Text.Json.Serialization; using AcDream.Launcher.Core.Updates; namespace AcDream.Launcher.Core.Tests.Updates; public sealed class LauncherSelfUpdateManagerTests : IDisposable { - private static readonly JsonSerializerOptions PlanOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - Converters = - { - new JsonStringEnumConverter( - JsonNamingPolicy.CamelCase, - allowIntegerValues: false), - }, - }; - private readonly string _root = Path.Combine( Path.GetTempPath(), "acdream-self-update-tests", @@ -47,6 +33,12 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable await File.WriteAllTextAsync(unrelated, "preserve"); Assert.Null(await harness.Manager.LoadPendingAsync()); + using UpdateSessionBarrier.ExclusiveLease lease = + harness.Manager.Barrier.AcquireExclusive(); + Assert.True(harness.Manager.CleanupOwnedResidueUnderLease( + pending: null, + harness.Target, + lease)); Assert.False(Directory.Exists(orphan)); Assert.False(File.Exists(temporary)); @@ -121,49 +113,91 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable } [Fact] - public async Task CrashDuringApplyingReplaysReverseJournalToStagedState() + public async Task PriorOwnershipRemovesObsoleteFilesAndRollbackRestoresThem() + { + using var harness = new Harness(_root); + byte[] firstArchive = UpdateTestData.CreateZip( + [ + (harness.LauncherName, "launcher-v2"u8.ToArray(), 0x81ED), + ("support.dat", "support-v2"u8.ToArray(), 0x81A4), + ("obsolete.dll", "obsolete-v2"u8.ToArray(), 0x81A4), + ]); + _ = await harness.StageAsync("2.0.0", firstArchive); + SelfUpdatePlan first = await harness.Manager.ApplyPendingAsync(harness.Target); + await harness.Manager.ConfirmAsync( + first.TransactionId, + harness.Target, + harness.LauncherPath); + await harness.Manager.CompleteConfirmedAsync(first.TransactionId, harness.Target); + string obsoletePath = Path.Combine(harness.Target, "obsolete.dll"); + Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath)); + + byte[] secondArchive = UpdateTestData.CreateZip( + [ + (harness.LauncherName, "launcher-v3"u8.ToArray(), 0x81ED), + ("support.dat", "support-v3"u8.ToArray(), 0x81A4), + ]); + _ = await harness.StageAsync("3.0.0", secondArchive); + SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target); + + Assert.False(File.Exists(obsoletePath)); + Assert.Contains( + applied.Apply!, + entry => entry.Path == "obsolete.dll" + && entry.Operation == SelfUpdateApplyOperation.Remove + && entry.HadOriginal); + + SelfUpdatePlan rolledBack = await harness.Manager + .RollbackAwaitingConfirmationAsync(harness.Target); + + Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State); + Assert.Equal("launcher-v2", await File.ReadAllTextAsync(harness.LauncherPath)); + Assert.Equal("support-v2", await File.ReadAllTextAsync(harness.SupportPath)); + Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath)); + + SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target); + await harness.Manager.ConfirmAsync( + retried.TransactionId, + harness.Target, + harness.LauncherPath); + await harness.Manager.CompleteConfirmedAsync(retried.TransactionId, harness.Target); + + Assert.False(File.Exists(obsoletePath)); + string ownership = await File.ReadAllTextAsync(Path.Combine( + harness.Target, + LauncherSelfUpdateManager.InstallRecordFileName)); + Assert.DoesNotContain("obsolete.dll", ownership, StringComparison.Ordinal); + } + + [Fact] + public async Task ApplyFailpointAfterCanonicalAtomicReplaceRollsBackToStagedState() { using var harness = new Harness(_root); _ = await harness.StageAsync(); - SelfUpdatePlan staged = Assert.IsType( - await harness.Manager.LoadPendingAsync()); - SelfUpdateApplyEntry[] apply = staged.Files - .Select(file => new SelfUpdateApplyEntry( - file.Path, - File.Exists(Path.Combine( - harness.Target, - file.Path.Replace('/', Path.DirectorySeparatorChar))))) - .ToArray(); - SelfUpdatePlan applying = staged with + LauncherSelfUpdateManager faulting = harness.CreateManagerWithObserver(observation => { - State = SelfUpdatePlanState.Applying, - Apply = apply, - }; - await File.WriteAllTextAsync( - harness.Manager.PendingPlanPath, - JsonSerializer.Serialize(applying, PlanOptions)); + if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation + && string.Equals( + observation.Path, + harness.LauncherName, + StringComparison.Ordinal)) + { + Assert.True(File.Exists(harness.LauncherPath)); + throw new InvalidOperationException("failpoint"); + } + }); - SelfUpdateApplyEntry first = apply[0]; - string payload = Path.Combine( - harness.Manager.GetPayloadDirectory(staged.TransactionId), - first.Path.Replace('/', Path.DirectorySeparatorChar)); - string target = Path.Combine( - harness.Target, - first.Path.Replace('/', Path.DirectorySeparatorChar)); - string backup = Path.Combine( - harness.Manager.GetBackupDirectory(staged.TransactionId), - first.Path.Replace('/', Path.DirectorySeparatorChar)); - Directory.CreateDirectory(Path.GetDirectoryName(backup)!); - File.Move(target, backup); - File.Move(payload, target); - - SelfUpdatePlan recovered = await harness.Manager.RecoverApplyingAsync(harness.Target); + InvalidOperationException failure = await Assert.ThrowsAsync( + () => faulting.ApplyPendingAsync(harness.Target)); + SelfUpdatePlan recovered = Assert.IsType( + await harness.Manager.LoadPendingAsync()); + Assert.Equal("failpoint", failure.Message); Assert.Equal(SelfUpdatePlanState.Staged, recovered.State); Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath)); Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine( - harness.Manager.GetPayloadDirectory(staged.TransactionId), + harness.Manager.GetPayloadDirectory(recovered.TransactionId), harness.LauncherName))); } @@ -219,8 +253,8 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable Assert.False(confirmation.ShouldExit); Assert.Empty(confirmation.RemainingArguments); - Assert.True(harness.Manager.IsConfirmed(applied.TransactionId)); - await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target); + Assert.False(File.Exists(harness.Manager.PendingPlanPath)); + Assert.False(harness.Manager.IsConfirmed(applied.TransactionId)); } private sealed class Harness : IDisposable @@ -229,9 +263,11 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable private readonly HttpClient _http = new(); private readonly byte[] _archive; private readonly ReleaseArtifact _artifact; + private readonly string _root; public Harness(string root) { + _root = root; Target = Path.Combine(root, "published launcher"); Directory.CreateDirectory(Target); Rid = LauncherRuntimeIdentity.DetectRid(); @@ -270,6 +306,25 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable progress: null, CancellationToken.None); + public Task StageAsync(string version, byte[] archive) + { + _server.Add("launcher.zip", archive); + return Manager.StageAsync( + LauncherVersion.Parse(version), + Rid, + new ReleaseArtifact( + _server.UriFor("launcher.zip"), + UpdateTestData.Sha256(archive), + archive.LongLength), + Target, + progress: null, + CancellationToken.None); + } + + public LauncherSelfUpdateManager CreateManagerWithObserver( + Action observer) => + new(UpdateTestData.Paths(_root), _http, null, observer); + public void Dispose() { _http.Dispose(); diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs new file mode 100644 index 00000000..14731094 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs @@ -0,0 +1,453 @@ +using System.Diagnostics; +using AcDream.Launcher.Core.Integrity; +using AcDream.Launcher.Core.Updates; + +namespace AcDream.Launcher.Core.Tests.Updates; + +public sealed class LauncherSelfUpdateProcessTests : IDisposable +{ + private const string FixtureBaseName = + "AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder"; + private const string DataEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_DATA"; + private const string TargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET"; + private const string HelperPidEnvironment = + "ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID"; + + private readonly string _root = Path.Combine( + Path.GetTempPath(), + "acdream-self-update-process-tests", + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public async Task KilledAfterCanonicalReplaceCanInvokeCanonicalAndConvergeAutomatically() + { + string data = Path.Combine(_root, "data"); + string target = Path.Combine(_root, "launcher"); + string ready = Path.Combine(_root, "crash.ready"); + string launched = Path.Combine(_root, "replacement.ready"); + string helperPidPath = Path.Combine(_root, "helper.pid"); + Directory.CreateDirectory(_root); + string rid = LauncherRuntimeIdentity.DetectRid(); + PreparedLauncher prepared = PrepareLauncherClosure(target, rid); + string oldHash = await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath); + using var server = new LocalHttpFixture(); + server.Add("launcher.zip", prepared.NewArchive); + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http); + SelfUpdateStageResult staged = await manager.StageAsync( + LauncherVersion.Parse("2.0.0"), + rid, + new ReleaseArtifact( + server.UriFor("launcher.zip"), + UpdateTestData.Sha256(prepared.NewArchive), + prepared.NewArchive.LongLength), + target, + progress: null, + CancellationToken.None); + SelfUpdatePlan plan = Assert.IsType(await manager.LoadPendingAsync()); + + using Process crash = StartFixture( + ["crash-self-update", data, target, ready, prepared.CanonicalName]); + try + { + await WaitForFileAsync(ready, crash, TimeSpan.FromSeconds(20)); + Assert.True(File.Exists(prepared.CanonicalPath)); + crash.Kill(entireProcessTree: true); + await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.True(File.Exists(prepared.CanonicalPath)); + string boundaryHash = await FileIntegrity.ComputeSha256HexAsync( + prepared.CanonicalPath); + Assert.Contains(boundaryHash, new[] { oldHash, prepared.NewCanonicalHash }); + + var environment = new Dictionary + { + [DataEnvironment] = data, + [TargetEnvironment] = target, + [HelperPidEnvironment] = helperPidPath, + }; + using Process canonical = StartProcess( + prepared.CanonicalPath, + ["canonical-probe", launched], + environment); + await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); + Assert.Equal(0, canonical.ExitCode); + await WaitForFileAsync(launched, process: null, TimeSpan.FromSeconds(30)); + await WaitUntilAsync( + () => !File.Exists(manager.PendingPlanPath), + TimeSpan.FromSeconds(30), + "The self-update journal did not converge."); + + Assert.Equal( + prepared.NewCanonicalHash, + await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath)); + Assert.True(File.Exists(Path.Combine( + target, + LauncherSelfUpdateManager.InstallRecordFileName))); + Assert.False(Directory.Exists(manager.GetTransactionDirectory( + plan.TransactionId))); + Assert.Empty(Directory.EnumerateDirectories( + target, + ".acdream-self-update-*", + SearchOption.TopDirectoryOnly)); + Assert.Null(await manager.LoadPendingAsync()); + + int replacementPid = ParsePid(await File.ReadAllTextAsync(launched)); + int helperPid = int.Parse( + await File.ReadAllTextAsync(helperPidPath), + System.Globalization.CultureInfo.InvariantCulture); + await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10)); + await WaitForProcessExitAsync(helperPid, TimeSpan.FromSeconds(10)); + if (OperatingSystem.IsLinux()) + { + Assert.True( + (File.GetUnixFileMode(prepared.CanonicalPath) + & (UnixFileMode.UserExecute + | UnixFileMode.GroupExecute + | UnixFileMode.OtherExecute)) != 0); + } + } + finally + { + if (!crash.HasExited) + { + crash.Kill(entireProcessTree: true); + await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + } + } + + [Fact] + public async Task ConcurrentStartupCannotDeleteAVisibleSlowStageTransaction() + { + string data = Path.Combine(_root, "data"); + string target = Path.Combine(_root, "launcher"); + string resultPath = Path.Combine(_root, "startup.result"); + Directory.CreateDirectory(target); + string rid = LauncherRuntimeIdentity.DetectRid(); + string canonicalName = LauncherName(rid); + string canonical = Path.Combine(target, canonicalName); + await File.WriteAllTextAsync(canonical, "running launcher"); + byte[] archive = UpdateTestData.LauncherZip( + rid, + new string('s', 2 * 1024 * 1024)); + using var server = new LocalHttpFixture(); + server.Add( + "slow-launcher.zip", + archive, + chunkSize: 4096, + chunkDelay: TimeSpan.FromMilliseconds(2)); + var observer = new LauncherSelfUpdateManager( + UpdateTestData.Paths(_root), + new HttpClient()); + using Process staging = StartFixture( + [ + "stage-self-update", + data, + target, + "2.0.0", + rid, + server.UriFor("slow-launcher.zip").AbsoluteUri, + UpdateTestData.Sha256(archive), + archive.LongLength.ToString(System.Globalization.CultureInfo.InvariantCulture), + ]); + try + { + await WaitUntilAsync( + () => Directory.Exists(observer.TransactionsDirectory) + && Directory.EnumerateDirectories(observer.TransactionsDirectory).Any(), + TimeSpan.FromSeconds(10), + "The slow staging transaction did not become visible.", + staging); + Assert.False(File.Exists(observer.PendingPlanPath)); + string transaction = Assert.Single( + Directory.EnumerateDirectories(observer.TransactionsDirectory)); + + using Process startup = StartFixture( + ["bootstrap-probe", data, target, canonical, resultPath]); + await startup.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(0, startup.ExitCode); + Assert.Equal("ordinary", await File.ReadAllTextAsync(resultPath)); + Assert.True(Directory.Exists(transaction)); + Assert.False(File.Exists(observer.PendingPlanPath)); + + await staging.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); + Assert.Equal(0, staging.ExitCode); + SelfUpdatePlan plan = Assert.IsType( + await observer.LoadPendingAsync()); + Assert.Equal(SelfUpdatePlanState.Staged, plan.State); + Assert.True(Directory.Exists(transaction)); + } + finally + { + if (!staging.HasExited) + { + staging.Kill(entireProcessTree: true); + await staging.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + } + } + + [Fact] + public async Task HelperDefersWithoutRestartWhenSharedSessionLeaseAppears() + { + string data = Path.Combine(_root, "data"); + string target = Path.Combine(_root, "launcher"); + string unexpectedLaunch = Path.Combine(_root, "unexpected-launch"); + string helperPid = Path.Combine(_root, "helper.pid"); + Directory.CreateDirectory(target); + string rid = LauncherRuntimeIdentity.DetectRid(); + string canonical = Path.Combine(target, LauncherName(rid)); + await File.WriteAllTextAsync(canonical, "old-launcher"); + byte[] archive = UpdateTestData.LauncherZip(rid, "new-launcher"); + using var server = new LocalHttpFixture(); + server.Add("launcher.zip", archive); + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http); + _ = await manager.StageAsync( + LauncherVersion.Parse("2.0.0"), + rid, + new ReleaseArtifact( + server.UriFor("launcher.zip"), + UpdateTestData.Sha256(archive), + archive.LongLength), + target, + progress: null, + CancellationToken.None); + SelfUpdatePlan plan = Assert.IsType(await manager.LoadPendingAsync()); + using UpdateSessionBarrier.SessionLease session = manager.Barrier.AcquireSession(); + var environment = new Dictionary + { + [DataEnvironment] = data, + [TargetEnvironment] = target, + [HelperPidEnvironment] = helperPid, + }; + + using Process helper = StartFixture( + [ + LauncherSelfUpdateBootstrap.HelperArgument, + int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture), + target, + plan.TransactionId, + "canonical-probe", + unexpectedLaunch, + ], environment); + await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(LauncherSelfUpdateBootstrap.DeferredLeaseExitCode, helper.ExitCode); + Assert.True(File.Exists(helperPid)); + Assert.False(File.Exists(unexpectedLaunch)); + Assert.Equal("old-launcher", await File.ReadAllTextAsync(canonical)); + SelfUpdatePlan deferred = Assert.IsType( + await manager.LoadPendingAsync()); + Assert.Equal(SelfUpdatePlanState.Staged, deferred.State); + Assert.Equal(plan.TransactionId, deferred.TransactionId); + } + + private PreparedLauncher PrepareLauncherClosure(string target, string rid) + { + string fixtureDirectory = GetFixtureDirectory(); + string fixtureAppHost = Path.Combine( + fixtureDirectory, + FixtureBaseName + (OperatingSystem.IsWindows() ? ".exe" : string.Empty)); + Assert.True(File.Exists(fixtureAppHost), $"Missing fixture apphost: {fixtureAppHost}"); + Directory.CreateDirectory(target); + string canonicalName = LauncherName(rid); + string canonicalPath = Path.Combine(target, canonicalName); + var archiveEntries = new List<(string Name, byte[] Content, int? UnixAttributes)>(); + foreach (string source in Directory.EnumerateFiles( + fixtureDirectory, + "*", + SearchOption.TopDirectoryOnly)) + { + string sourceName = Path.GetFileName(source); + bool isAppHost = PathsEqual(source, fixtureAppHost); + string targetName = isAppHost ? canonicalName : sourceName; + byte[] oldContent = File.ReadAllBytes(source); + byte[] newContent = oldContent; + if (isAppHost) + { + oldContent = [.. oldContent, .. "-old"u8.ToArray()]; + newContent = [.. newContent, .. "-new"u8.ToArray()]; + } + + string targetPath = Path.Combine(target, targetName); + File.WriteAllBytes(targetPath, oldContent); + int unixAttributes = 0x81A4; + if (OperatingSystem.IsLinux()) + { + UnixFileMode mode = File.GetUnixFileMode(source); + if (isAppHost) + { + mode |= UnixFileMode.UserExecute; + } + + File.SetUnixFileMode(targetPath, mode); + unixAttributes = 0x8000 | (int)mode; + } + else if (isAppHost) + { + unixAttributes = 0x81ED; + } + + archiveEntries.Add((targetName, newContent, unixAttributes)); + } + + byte[] archive = UpdateTestData.CreateZip(archiveEntries); + byte[] newCanonical = Assert.Single( + archiveEntries, + entry => entry.Name == canonicalName).Content; + return new PreparedLauncher( + canonicalName, + canonicalPath, + archive, + UpdateTestData.Sha256(newCanonical)); + } + + private static Process StartFixture( + IReadOnlyList arguments, + IReadOnlyDictionary? environment = null) => + StartProcess("dotnet", [GetFixtureDllPath(), .. arguments], environment); + + private static Process StartProcess( + string executable, + IReadOnlyList arguments, + IReadOnlyDictionary? environment = null) + { + var start = new ProcessStartInfo(executable) + { + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + if (environment is not null) + { + foreach ((string name, string value) in environment) + { + start.Environment[name] = value; + } + } + + return Process.Start(start) + ?? throw new InvalidOperationException($"Could not start '{executable}'."); + } + + private static async Task WaitForFileAsync( + string path, + Process? process, + TimeSpan timeout) => + await WaitUntilAsync( + () => File.Exists(path), + timeout, + $"Timed out waiting for '{path}'.", + process); + + private static async Task WaitUntilAsync( + Func condition, + TimeSpan timeout, + string failure, + Process? process = null) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow + timeout; + while (!condition()) + { + if (process?.HasExited == true) + { + throw new InvalidOperationException( + $"{failure} Process exited {process.ExitCode}. stdout: " + + await process.StandardOutput.ReadToEndAsync() + + " stderr: " + + await process.StandardError.ReadToEndAsync()); + } + + if (DateTimeOffset.UtcNow >= deadline) + { + throw new TimeoutException(failure); + } + + await Task.Delay(20); + } + } + + private static int ParsePid(string marker) + { + string value = marker.Split('|', 2)[0]; + return int.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + } + + private static async Task WaitForProcessExitAsync(int pid, TimeSpan timeout) + { + try + { + using Process process = Process.GetProcessById(pid); + await process.WaitForExitAsync().WaitAsync(timeout); + } + catch (ArgumentException) + { + // It exited before the test opened the process handle. + } + } + + private static string LauncherName(string rid) => + "acdream-launcher" + + (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); + + private static string GetFixtureDllPath() => + Path.Combine(GetFixtureDirectory(), FixtureBaseName + ".dll"); + + private static string GetFixtureDirectory() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent?.Name ?? "Release"; + return Path.Combine( + FindRepositoryRoot(), + "tests", + "AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder", + "bin", + configuration, + "net10.0"); + } + + private static string FindRepositoryRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return directory.FullName; + } + } + + throw new InvalidOperationException("Repository root was not found."); + } + + private static bool PathsEqual(string left, string right) => string.Equals( + Path.GetFullPath(left), + Path.GetFullPath(right), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + private sealed record PreparedLauncher( + string CanonicalName, + string CanonicalPath, + byte[] NewArchive, + string NewCanonicalHash); +} diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherUpdaterIntegrationTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherUpdaterIntegrationTests.cs index eaa480ad..7430d701 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherUpdaterIntegrationTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherUpdaterIntegrationTests.cs @@ -30,7 +30,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable byte[] launcher = UpdateTestData.LauncherZip(_rid, "release-2"); ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher); using var http = new HttpClient(); - using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); + using var source = ReleaseManifestClient.CreateLoopbackFixture( + server.UriFor("manifest.json")); var versions = new ClientVersionStore(_paths); var updater = new LauncherUpdater( source, @@ -74,7 +75,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable byte[] launcher = UpdateTestData.LauncherZip(_rid); ConfigureRelease(server, "3.0.0", "2.0.0", _rid, client, launcher); using var http = new HttpClient(); - using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); + using var source = ReleaseManifestClient.CreateLoopbackFixture( + server.UriFor("manifest.json")); var versions = new ClientVersionStore(_paths); var updater = new LauncherUpdater( source, @@ -108,7 +110,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable byte[] launcher = UpdateTestData.LauncherZip(_rid); ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher); using var http = new HttpClient(); - using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); + using var source = ReleaseManifestClient.CreateLoopbackFixture( + server.UriFor("manifest.json")); var versions = new ClientVersionStore(_paths); bool running = true; var updater = new LauncherUpdater( @@ -156,7 +159,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable launcher), contentType: "application/json"); using var http = new HttpClient(); - using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); + using var source = ReleaseManifestClient.CreateLoopbackFixture( + server.UriFor("manifest.json")); var versions = new ClientVersionStore(_paths); var updater = new LauncherUpdater( source, diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs index 84d788eb..b3d80e11 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Text; using AcDream.Launcher.Core.Updates; @@ -63,7 +64,8 @@ public sealed class ReleaseManifestClientTests launcher), contentType: "application/json"); using var http = new HttpClient(); - using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); + using var source = ReleaseManifestClient.CreateLoopbackFixture( + server.UriFor("manifest.json")); ReleaseManifest manifest = await source.FetchAsync(); @@ -96,16 +98,132 @@ public sealed class ReleaseManifestClientTests ValidJson().Replace("\"size\":12", "\"size\":0"), ValidJson().Replace("\"size\":12", "\"size\":12,\"extra\":true"), ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"2.0.0\",\"version\":\"2.0.1\""), - ValidJson().Replace("http://127.0.0.1", "http://example.test"), + ValidJson().Replace("https://example.test/client", "http://example.test/client"), }; + [Theory] + [InlineData("clients", "client")] + [InlineData("launchers", "launcher")] + public void ProductionManifestRejectsLoopbackHttpArtifacts( + string section, + string artifact) + { + string json = ValidJson().Replace( + $"https://example.test/{artifact}", + $"http://127.0.0.1/{artifact}", + StringComparison.Ordinal); + + LauncherUpdateException error = Assert.Throws(() => + ReleaseManifestClient.Parse(Encoding.UTF8.GetBytes(json))); + + Assert.Contains(section, error.Message, StringComparison.Ordinal); + Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ProductionRedirectToLoopbackIsRejectedBeforePlaintextRequest() + { + var handler = new SequenceHandler((request, _) => Redirect( + HttpStatusCode.Found, + new Uri("http://127.0.0.1/manifest.json"))); + using var source = ReleaseManifestClient.CreateForTransportTest( + ReleaseManifestClient.ProductionManifestUri, + allowLoopbackHttp: false, + handler); + + LauncherUpdateException error = await Assert.ThrowsAsync( + () => source.FetchAsync()); + + Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal); + Assert.Equal([ReleaseManifestClient.ProductionManifestUri], handler.Requests); + } + + [Fact] + public async Task HttpsRedirectDowngradeIsRejectedBeforeIntermediateHop() + { + var start = new Uri("https://example.test/start"); + var handler = new SequenceHandler((request, _) => Redirect( + HttpStatusCode.TemporaryRedirect, + new Uri("http://example.test/plaintext-hop"))); + using var source = ReleaseManifestClient.CreateForTransportTest( + start, + allowLoopbackHttp: false, + handler); + + await Assert.ThrowsAsync(() => source.FetchAsync()); + + Assert.Equal([start], handler.Requests); + } + + [Fact] + public async Task RedirectLoopIsRejectedWithoutRepeatingARequest() + { + var first = new Uri("https://example.test/first"); + var second = new Uri("https://example.test/second"); + var handler = new SequenceHandler((request, _) => Redirect( + HttpStatusCode.PermanentRedirect, + request.RequestUri == first ? second : first)); + using var source = ReleaseManifestClient.CreateForTransportTest( + first, + allowLoopbackHttp: false, + handler); + + LauncherUpdateException error = await Assert.ThrowsAsync( + () => source.FetchAsync()); + + Assert.Contains("loop", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal([first, second], handler.Requests); + } + + [Fact] + public async Task RedirectLimitRejectsBeforeRequestingTheSixthHop() + { + var start = new Uri("https://example.test/hop-0"); + var handler = new SequenceHandler((_, index) => Redirect( + HttpStatusCode.Found, + new Uri($"https://example.test/hop-{index + 1}"))); + using var source = ReleaseManifestClient.CreateForTransportTest( + start, + allowLoopbackHttp: false, + handler); + + LauncherUpdateException error = await Assert.ThrowsAsync( + () => source.FetchAsync()); + + Assert.Contains("5 redirects", error.Message, StringComparison.Ordinal); + Assert.Equal(6, handler.Requests.Count); + Assert.Equal(new Uri("https://example.test/hop-5"), handler.Requests[^1]); + } + private static string ValidJson() => - "{\"schemaVersion\":1,\"version\":\"2.0.0\"," - + "\"minimumLauncherVersion\":\"1.0.0\"," - + "\"clients\":{\"win-x64\":{\"url\":\"http://127.0.0.1/client\"," - + $"\"sha256\":\"{new string('a', 64)}\",\"size\":12}}," - + "\"launchers\":{\"win-x64\":{\"url\":\"https://example.test/launcher\"," - + $"\"sha256\":\"{new string('b', 64)}\",\"size\":12}}}}"; + $$$$""" + {"schemaVersion":1,"version":"2.0.0","minimumLauncherVersion":"1.0.0","clients":{"win-x64":{"url":"https://example.test/client","sha256":"{{{{new string('a', 64)}}}}","size":12}},"launchers":{"win-x64":{"url":"https://example.test/launcher","sha256":"{{{{new string('b', 64)}}}}","size":12}}} + """; + + private static HttpResponseMessage Redirect(HttpStatusCode status, Uri location) + { + var response = new HttpResponseMessage(status); + response.Headers.Location = location; + return response; + } + + private sealed class SequenceHandler( + Func respond) + : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Uri uri = request.RequestUri + ?? throw new InvalidOperationException("Test request has no URI."); + int index = Requests.Count; + Requests.Add(uri); + return Task.FromResult(respond(request, index)); + } + } } public sealed class VerifiedArtifactDownloaderTests : IDisposable @@ -211,9 +329,45 @@ public sealed class VerifiedArtifactDownloaderTests : IDisposable Assert.Equal("preserve", await File.ReadAllTextAsync(destination)); } + [Fact] + public async Task HttpsArtifactRedirectDowngradeIsRejectedBeforePlaintextHop() + { + var handler = new RedirectHandler(); + using var http = new HttpClient(handler); + var downloader = new VerifiedArtifactDownloader(http); + string destination = Path.Combine(_root, "redirect.zip"); + + LauncherUpdateException error = await Assert.ThrowsAsync(() => + downloader.DownloadAsync( + new ReleaseArtifact( + new Uri("https://example.test/artifact"), + new string('a', 64), + 12), + destination)); + + Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal); + Assert.Equal([new Uri("https://example.test/artifact")], handler.Requests); + Assert.False(File.Exists(destination)); + } + private sealed class ImmediateProgress(Action callback) : IProgress { public void Report(ArtifactDownloadProgress value) => callback(value); } + + private sealed class RedirectHandler : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(request.RequestUri!); + var response = new HttpResponseMessage(HttpStatusCode.Found); + response.Headers.Location = new Uri("http://example.test/plaintext"); + return Task.FromResult(response); + } + } } diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/SafeZipExtractorTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/SafeZipExtractorTests.cs index e94f8580..c65efcdf 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/SafeZipExtractorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/SafeZipExtractorTests.cs @@ -50,6 +50,12 @@ public sealed class SafeZipExtractorTests : IDisposable [InlineData("a/./b")] [InlineData("CON")] [InlineData("aux.txt")] + [InlineData("CLOCK$/value")] + [InlineData("CONIN$.txt")] + [InlineData("CONOUT$/value")] + [InlineData("COM¹.dll")] + [InlineData("com²/value")] + [InlineData("LPT³.log")] [InlineData("trailing.")] [InlineData("trailing ")] public async Task RejectsTraversalRootedAdsAndPortableUnsafeNames(string entry) @@ -66,6 +72,15 @@ public sealed class SafeZipExtractorTests : IDisposable Assert.False(File.Exists(Path.Combine(_root, "escape"))); } + [Theory] + [InlineData("CONIN$.txt")] + [InlineData("CONOUT$/child")] + [InlineData("COM¹.dll")] + [InlineData("LPT³/child")] + [InlineData("CLOCK$")] + public void VersionMetadataUsesTheSameCompletePortableDeviceRules(string path) => + Assert.False(ClientVersionStore.IsNormalizedRelative(path)); + [Fact] public async Task RejectsDuplicateCaseAndFileDirectoryCollisionsBeforeExtraction() { diff --git a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs new file mode 100644 index 00000000..37365efe --- /dev/null +++ b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using AcDream.Launcher.Core.Orchestration; +using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.Core.Updates; +using AcDream.Platform; + +namespace AcDream.Launcher.Tests; + +public sealed class LauncherUpdateCompositionTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), + "acdream-launcher-composition-tests", + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Theory] + [InlineData("io")] + [InlineData("permission")] + [InlineData("corrupt")] + public async Task StartupStorageFailureComposesUnavailableUpdaterWithoutThrowing( + string failure) + { + Directory.CreateDirectory(_root); + var paths = new ApplicationPathSet( + Path.Combine(_root, "config"), + Path.Combine(_root, "data"), + Path.Combine(_root, "cache"), + null); + Exception exception = failure switch + { + "io" => new IOException("storage offline"), + "permission" => new UnauthorizedAccessException("storage denied"), + "corrupt" => new JsonException("pointer corrupt"), + _ => throw new InvalidOperationException("Unknown fixture failure."), + }; + + using LauncherUpdateComposition composition = LauncherUpdateComposition.Create( + paths, + LauncherRuntimeIdentity.DetectRid(), + LauncherVersion.Parse("1.0.0"), + _root, + () => false, + (_, _) => throw exception); + + Assert.Equal(ClientVersionState.Invalid, composition.Updater.CurrentClient.State); + Assert.Contains( + exception.Message, + composition.Updater.CurrentClient.Status, + StringComparison.Ordinal); + LauncherCapability capability = composition.Executables.GetAvailability(LaunchMode.Gui); + Assert.False(capability.IsAvailable); + Assert.Contains(exception.Message, capability.Reason, StringComparison.Ordinal); + LauncherUpdateException updateError = await Assert.ThrowsAsync( + () => composition.Updater.CheckAsync()); + Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal); + } +}