diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 900f68a4..e910244a 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -339,11 +339,21 @@ src/ adjacent `..acdream-bake..tmp` files are transaction-owned crash residue + Updates/ -> pinned GitHub manifest + strict SemVer/RID + authority, bounded verified streaming download, + hardened ZIP extraction, immutable + `app//` installs, atomic `current.json` + activation/rollback, and durable next-start + launcher self-update journal; one OS-handle + shared-session/exclusive-update barrier spans + every launcher process -> references Platform only; no Avalonia or game-host dependency AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell ViewModels/ -> thin MVVM projection over Launcher.Core, - including the first-run DAT/bake wizard + including the first-run DAT/bake wizard and + nonfatal startup/manual update state, actions, + progress, cancellation, rollback, and errors -> references Launcher.Core only (Platform transitively); it never owns a second profile, process, status, or credential state graph -> every per-RID publish composes the separately published self-contained diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 56ab2c07..17a59c0a 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -471,13 +471,230 @@ 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 fixture; rollback test; refusal-while-running test; self-update staging test; suites green. Connected gate (user): staged-manifest update swap end-to-end. +### Pinned updater contracts (v1, BINDING) + +This section is the single source of truth for every LA10 feed and on-disk +shape. Readers use strict, case-sensitive `System.Text.Json` parsing, reject +unknown or duplicate properties, and reject unsupported schema versions +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 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 +{ + "schemaVersion": 1, + "version": "1.2.3", + "minimumLauncherVersion": "1.1.0", + "clients": { + "win-x64": { + "url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-client-win-x64.zip", + "sha256": "<64 hex characters>", + "size": 123 + } + }, + "launchers": { + "win-x64": { + "url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-launcher-win-x64.zip", + "sha256": "<64 hex characters>", + "size": 123 + } + } +} +``` + +`version` and `minimumLauncherVersion` are strict SemVer 2.0 strings. Build +metadata is ignored for precedence; numeric identifiers are compared without +fixed-width integer overflow. RID keys are exact lowercase portable RIDs. +Both dictionaries are required and the running RID must have a client and a +launcher row. Artifact sizes are positive and capped by the launcher's +download limit; SHA-256 is exactly 64 hex characters. ZIP URLs are absolute. +Client ZIPs have the two host executables at their root +(`AcDream.App[.exe]`, `acdream-headless[.exe]`); launcher ZIPs have +`acdream-launcher[.exe]` at their root. No implicit wrapper directory exists. + +Every extracted client version has +`DataDirectory/app//install.json`: + +```json +{ + "schemaVersion": 1, + "version": "1.2.3", + "rid": "win-x64", + "archiveSha256": "<64 hex characters>", + "archiveSize": 123, + "files": [ + { "path": "AcDream.App.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 } + ] +} +``` + +Paths use `/`, are relative, normalized, unique under ordinal-ignore-case, +and sorted ordinally. `unixMode` contains only the portable permission bits +captured from the ZIP entry. Startup verifies every recorded regular file by +size/SHA, rejects unrecorded files/reparse points, and requires the two host +executables before admitting a version. Extraction uses a random sibling +directory under `DataDirectory/app/`; promotion to `/` is one +same-volume directory rename. + +`DataDirectory/app/current.json` is the only activation authority: + +```json +{ "schemaVersion": 1, "currentVersion": "1.2.3", "previousVersion": "1.1.0" } +``` + +`previousVersion` is omitted for the first activation. Pointer writes are +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, 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; 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, +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` (schema 3): + +```json +{ + "schemaVersion": 3, + "transactionId": "0123456789abcdef0123456789abcdef", + "state": "staged", + "version": "1.2.3", + "rid": "win-x64", + "targetDirectory": "", + "archiveSha256": "<64 hex characters>", + "archiveSize": 123, + "files": [ + { "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 } + ], + "apply": null +} +``` + +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, + "priorSha256": "<64 hex characters>", + "priorSize": 123, + "priorUnixMode": 0, + "replacementSha256": "<64 hex characters>", + "replacementSize": 456, + "replacementUnixMode": 0 + }, + { + "path": "new-support.dat", + "operation": "install", + "hadOriginal": false, + "priorSha256": null, + "priorSize": null, + "priorUnixMode": null, + "replacementSha256": "<64 hex characters>", + "replacementSize": 456, + "replacementUnixMode": 0 + } +] +``` + +Every `hadOriginal` entry persists the exact pre-mutation SHA-256, length, and +Linux mode bits; a no-original entry has all three prior fields null. Every +install entry likewise persists the verified replacement metadata, while a +remove entry has all three replacement fields null. The journal is invalid +unless those fields agree with `hadOriginal` and `operation`. + +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 first performs a +zero-mutation preflight of the complete target-local transaction and every +journal entry. It rejects reparse points, unsafe parents, unrecorded paths, +ambiguous file layouts, and any SHA-256/length/mode mismatch in a prior, +incoming, or discard file. Only a fully preflighted rollback may atomically +restore backups; newly created files move to target-local discard rather than +being deleted. The complete prior target set is then reverified before the +plan enters durable `rolledBack` state while retaining the journal. Retry is +allowed only after that prior set is reverified again and the plan returns to +`staged`. Thus rollback is atomic per file and idempotent after a process/power +loss. Any ambiguity preserves the applying plan and transaction evidence and +forbids launching the canonical path for manual recovery. 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). +The helper restarts the restored canonical launcher only after a fresh complete +verification of the retained `rolledBack` journal; rollback corruption or an +unsafe backup/discard tree exits without starting either launcher. +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 - One connected-gate script `docs/research/2026-XX-XX-campaign-la-test-script.md` 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 954a65fe..db477433 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -298,11 +298,23 @@ 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 and launcher ownership record, shared-session/exclusive-update OS +lease, and durable self-update plan schema 3 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 +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. + ## 10. Testing - **Launcher.Core unit tests** (new test project, registered in 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 ef4635c5..4c3ffe16 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -1,18 +1,19 @@ using AcDream.Launcher.Core.Launching; using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.Core.Updates; namespace AcDream.Launcher.Core.Orchestration; /// -/// Resolves and validates the co-deployed graphical/headless hosts. LA10 will -/// replace the directory lookup with its versioned-current resolver; until -/// then a missing host disables the corresponding action instead of deferring -/// failure until process creation. +/// Resolves and validates the graphical/headless hosts. Production uses the +/// verified DataDirectory/app/current.json resolver; the explicit-path +/// constructor remains the injectable test seam. /// public sealed class LauncherExecutableSet { private readonly Func _fileExists; private readonly Func _hasUnixExecutePermission; + private readonly Func _resolve; public LauncherExecutableSet( string graphicalHostPath, @@ -23,25 +24,50 @@ public sealed class LauncherExecutableSet { ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath); ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath); - GraphicalHostPath = graphicalHostPath; - HeadlessHostPath = headlessHostPath; - WorkingDirectory = workingDirectory; + string graphical = graphicalHostPath; + string headless = headlessHostPath; + _resolve = () => new ExecutablePaths(graphical, headless, workingDirectory); _fileExists = fileExists ?? File.Exists; _hasUnixExecutePermission = hasUnixExecutePermission ?? HasUnixExecutePermission; } - public string GraphicalHostPath { get; } + private LauncherExecutableSet( + Func resolve, + Func? fileExists = null, + Func? hasUnixExecutePermission = null) + { + _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); + _fileExists = fileExists ?? File.Exists; + _hasUnixExecutePermission = + hasUnixExecutePermission ?? HasUnixExecutePermission; + } - public string HeadlessHostPath { get; } + public string GraphicalHostPath => _resolve().GraphicalHostPath; - public string? WorkingDirectory { get; } + public string HeadlessHostPath => _resolve().HeadlessHostPath; + + public string? WorkingDirectory => _resolve().WorkingDirectory; public LauncherCapability GetAvailability(LaunchMode mode) { + ExecutablePaths paths; + try + { + paths = _resolve(); + } + catch (Exception ex) when (ex is LauncherUpdateException + or InvalidOperationException + or IOException + or UnauthorizedAccessException) + { + return LauncherCapability.Unavailable( + $"The active versioned client is unavailable: {ex.Message}"); + } + string path = mode == LaunchMode.Headless - ? HeadlessHostPath - : GraphicalHostPath; + ? paths.HeadlessHostPath + : paths.GraphicalHostPath; string host = mode == LaunchMode.Headless ? "headless host" : "graphical client"; @@ -68,27 +94,27 @@ public sealed class LauncherExecutableSet string configFilePath) { ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); - RequireAvailable(mode); + ExecutablePaths paths = RequireAvailable(mode); return mode == LaunchMode.Headless ? new LauncherProcessSpec( - HeadlessHostPath, + paths.HeadlessHostPath, ["--config", configFilePath], - WorkingDirectory) + paths.WorkingDirectory) : new LauncherProcessSpec( - GraphicalHostPath, + paths.GraphicalHostPath, ["--session-config", configFilePath], - WorkingDirectory); + paths.WorkingDirectory); } public LauncherProcessSpec CreateProbeSpec(string configFilePath) { ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); - RequireAvailable(LaunchMode.Headless); + ExecutablePaths paths = RequireAvailable(LaunchMode.Headless); return new LauncherProcessSpec( - HeadlessHostPath, + paths.HeadlessHostPath, ["--config", configFilePath], - WorkingDirectory); + paths.WorkingDirectory); } public static LauncherExecutableSet FromDirectory(string directory) @@ -102,7 +128,35 @@ public sealed class LauncherExecutableSet fullDirectory); } - private void RequireAvailable(LaunchMode mode) + /// + /// Dynamic production resolver. The store cache is admitted only after a + /// strict startup/update verification, and a pointer swap changes the + /// binaries selected for the next session without replacing LA9 content. + /// + public static LauncherExecutableSet FromCurrentVersionStore( + ClientVersionStore store) + { + ArgumentNullException.ThrowIfNull(store); + return new LauncherExecutableSet(() => + { + ClientVersionResolution resolution = store.CachedResolution; + if (!resolution.IsVerified || resolution.Directory is null) + { + throw new LauncherUpdateException(resolution.Status); + } + + return FromDirectoryPaths(resolution.Directory); + }); + } + + 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); if (!capability.IsAvailable) @@ -110,6 +164,18 @@ public sealed class LauncherExecutableSet throw new LauncherOperationException( capability.Reason ?? "The selected launcher host is unavailable."); } + + return _resolve(); + } + + private static ExecutablePaths FromDirectoryPaths(string directory) + { + string fullDirectory = Path.GetFullPath(directory); + string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + return new ExecutablePaths( + Path.Combine(fullDirectory, "AcDream.App" + executableSuffix), + Path.Combine(fullDirectory, "acdream-headless" + executableSuffix), + fullDirectory); } private static bool HasUnixExecutePermission(string path) @@ -134,4 +200,9 @@ public sealed class LauncherExecutableSet return false; } } + + private sealed record ExecutablePaths( + string GraphicalHostPath, + string HeadlessHostPath, + string? WorkingDirectory); } diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index c9a7a582..a734fcaf 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -1,6 +1,7 @@ using AcDream.Launcher.Core.Launching; using AcDream.Launcher.Core.Profiles; using AcDream.Launcher.Core.Status; +using AcDream.Launcher.Core.Updates; using AcDream.Platform; namespace AcDream.Launcher.Core.Orchestration; @@ -26,6 +27,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator private readonly ILauncherProcessSupervisorFactory _supervisorFactory; private readonly IStatusEventSourceFactory _statusSourceFactory; private readonly Func _sessionIdFactory; + private readonly UpdateSessionBarrier _updateSessionBarrier; private readonly List _activities = []; private LauncherInstallRecord? _installRecord; @@ -42,7 +44,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator ILauncherProcessSupervisorFactory? supervisorFactory = null, IStatusEventSourceFactory? statusSourceFactory = null, Func? sessionIdFactory = null, - string? installationStatus = null) + string? installationStatus = null, + UpdateSessionBarrier? updateSessionBarrier = null) { _profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore)); _paths = paths ?? throw new ArgumentNullException(nameof(paths)); @@ -53,6 +56,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator _supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory(); _statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory(); _sessionIdFactory = sessionIdFactory ?? CreateSessionId; + _updateSessionBarrier = updateSessionBarrier + ?? new UpdateSessionBarrier(paths.DataDirectory); _installationStatus = installationStatus ?? (installRecord is null ? FirstRunRequired @@ -622,6 +627,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator } request.Cancellation.Dispose(); + request.Activity.StartCompleted.Set(); } } @@ -629,10 +635,18 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator { ILauncherProcessSupervisor? supervisor = null; string? password = request.Password; + bool hostStarted = false; try { request.Cancellation.Token.ThrowIfCancellationRequested(); + UpdateSessionBarrier.SessionLease sessionLease = + _updateSessionBarrier.AcquireSession(); + lock (_gate) + { + request.Activity.UpdateSessionLease = sessionLease; + } + ComposedSessionConfig composed = request.IsProbe ? _configService.ComposeProbeAndWrite( request.Server, @@ -674,6 +688,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator request.Activity.LaunchMode!.Value, composed.ConfigFilePath); supervisor.Start(processSpec, password); + hostStarted = true; request.Password = null; password = null; @@ -728,6 +743,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator finally { request.Password = null; + if (!hostStarted) + { + ReleaseUpdateSessionLease(request.Activity); + } } } @@ -735,6 +754,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator ManagedActivity activity, LauncherSessionState processState) { + UpdateSessionBarrier.SessionLease? sessionLease = null; try { lock (_gate) @@ -774,10 +794,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator { activity.Status = activity.HostTerminalStatus; } + sessionLease = activity.UpdateSessionLease; + activity.UpdateSessionLease = null; break; } } + sessionLease?.Dispose(); RaiseStateChanged(); } catch @@ -1179,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; @@ -1192,6 +1220,16 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator activity.Supervisor.Dispose(); activity.Supervisor = null; } + + ReleaseUpdateSessionLease(activity); + activity.StartCompleted.Dispose(); + } + + private static void ReleaseUpdateSessionLease(ManagedActivity activity) + { + UpdateSessionBarrier.SessionLease? lease = + Interlocked.Exchange(ref activity.UpdateSessionLease, null); + lease?.Dispose(); } private void ThrowIfDisposed() @@ -1252,6 +1290,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator public CancellationTokenSource? StartCancellation { get; set; } + 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/AtomicJsonFile.cs b/src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs new file mode 100644 index 00000000..919bd737 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs @@ -0,0 +1,84 @@ +using System.Text.Json; + +namespace AcDream.Launcher.Core.Updates; + +internal static class AtomicJsonFile +{ + internal static async Task WriteAsync( + string path, + T value, + JsonSerializerOptions options, + CancellationToken cancellationToken = default) + { + string fullPath = Path.GetFullPath(path); + string directory = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException("The JSON path has no parent directory."); + Directory.CreateDirectory(directory); + string temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp"); + try + { + await using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 16 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync( + stream, + value, + options, + cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporaryPath, fullPath, overwrite: true); + } + finally + { + VerifiedArtifactDownloader.TryDelete(temporaryPath); + } + } + + internal static async Task WriteBytesAsync( + string path, + ReadOnlyMemory bytes, + CancellationToken cancellationToken = default) + { + string fullPath = Path.GetFullPath(path); + string directory = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException("The file path has no parent directory."); + Directory.CreateDirectory(directory); + string temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp"); + try + { + await using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 16 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporaryPath, fullPath, overwrite: true); + } + finally + { + VerifiedArtifactDownloader.TryDelete(temporaryPath); + } + } +} diff --git a/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs b/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs new file mode 100644 index 00000000..3b008184 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs @@ -0,0 +1,1015 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Launcher.Core.Integrity; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Updates; + +public sealed record InstalledFileRecord( + string Path, + string Sha256, + long Size, + int UnixMode); + +public sealed record ClientVersionRecord( + int SchemaVersion, + string Version, + string Rid, + string ArchiveSha256, + long ArchiveSize, + IReadOnlyList Files) +{ + public const int CurrentSchemaVersion = 1; +} + +public sealed record ClientActivationPointer( + int SchemaVersion, + string CurrentVersion, + string? PreviousVersion) +{ + public const int CurrentSchemaVersion = 1; +} + +public enum ClientVersionState +{ + Missing, + Verified, + Invalid, +} + +public sealed record ClientVersionResolution( + ClientVersionState State, + string Status, + LauncherVersion? Version, + string? Directory, + string? PreviousVersion, + ClientVersionRecord? Record) +{ + public bool IsVerified => State == ClientVersionState.Verified; +} + +/// +/// Strict installed-version and activation-pointer authority. LA9's DAT/pak +/// record is intentionally not represented here. +/// +public sealed class ClientVersionStore +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + MaxDepth = 32, + }; + + private readonly object _gate = new(); + private readonly Func> _computeSha256; + private ClientVersionResolution _cached = new( + ClientVersionState.Missing, + "No versioned client is installed. Check for updates to install one.", + null, + null, + null, + null); + + public ClientVersionStore( + ApplicationPathSet paths, + Func>? computeSha256 = null) + { + ArgumentNullException.ThrowIfNull(paths); + AppDirectory = Path.Combine(Path.GetFullPath(paths.DataDirectory), "app"); + CurrentPointerPath = Path.Combine(AppDirectory, "current.json"); + PreviousPointerPath = Path.Combine(AppDirectory, "current.previous.json"); + Barrier = new UpdateSessionBarrier(paths.DataDirectory); + _computeSha256 = computeSha256 + ?? ((path, token) => FileIntegrity.ComputeSha256HexAsync(path, token)); + } + + public string AppDirectory { get; } + + public string CurrentPointerPath { get; } + + public string PreviousPointerPath { get; } + + public UpdateSessionBarrier Barrier { get; } + + public ClientVersionResolution CachedResolution + { + get + { + lock (_gate) + { + return _cached; + } + } + } + + public string GetVersionDirectory(LauncherVersion version) => + Path.Combine(AppDirectory, version.Value); + + public static string GetMetadataPath(string versionDirectory) => + Path.Combine(Path.GetFullPath(versionDirectory), "install.json"); + + public async Task LoadAndRecoverAsync( + string rid, + CancellationToken cancellationToken = default) + { + try + { + using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive(); + return await LoadAndRecoverUnderLeaseAsync(rid, cancellationToken) + .ConfigureAwait(false); + } + catch (LauncherUpdateException ex) when (ex.InnerException is IOException) + { + // Another launcher may legitimately hold a shared session lease. + // Pointer publication is atomic and old versions are retained, so + // a read-only verification remains safe; mutation/recovery waits + // for the next startup without active sessions. + return await LoadCurrentReadOnlyAsync(rid, cancellationToken) + .ConfigureAwait(false); + } + } + + public async Task LoadCurrentReadOnlyAsync( + string rid, + CancellationToken cancellationToken = default) + { + RequireRid(rid); + PointerRead current = await ReadPointerAsync(CurrentPointerPath, cancellationToken) + .ConfigureAwait(false); + ClientVersionResolution resolution = current.Pointer is null + ? (!File.Exists(CurrentPointerPath) + ? new ClientVersionResolution( + ClientVersionState.Missing, + "No versioned client is installed. Check for updates to install one.", + null, + null, + null, + null) + : Invalid(current.Error ?? "The client activation pointer is invalid.")) + : await ResolvePointerAsync(current.Pointer, rid, cancellationToken) + .ConfigureAwait(false); + SetCached(resolution); + return resolution; + } + + internal async Task LoadAndRecoverUnderLeaseAsync( + string rid, + CancellationToken cancellationToken = default) + { + RequireRid(rid); + Directory.CreateDirectory(AppDirectory); + CleanupOwnedResidue(); + + PointerRead current = await ReadPointerAsync(CurrentPointerPath, cancellationToken) + .ConfigureAwait(false); + if (current.Pointer is not null) + { + ClientVersionResolution resolution = await ResolvePointerAsync( + current.Pointer, + rid, + cancellationToken) + .ConfigureAwait(false); + SetCached(resolution); + return resolution; + } + + PointerRead previous = await ReadPointerAsync(PreviousPointerPath, cancellationToken) + .ConfigureAwait(false); + if (previous.Pointer is not null) + { + ClientVersionResolution recovered = await ResolvePointerAsync( + previous.Pointer, + rid, + cancellationToken) + .ConfigureAwait(false); + if (recovered.IsVerified) + { + await WritePointerFileAsync( + CurrentPointerPath, + previous.Pointer, + cancellationToken) + .ConfigureAwait(false); + recovered = recovered with + { + Status = "Recovered the last valid client activation pointer.", + }; + SetCached(recovered); + return recovered; + } + } + + ClientVersionResolution missingOrInvalid = + !File.Exists(CurrentPointerPath) && !File.Exists(PreviousPointerPath) + ? new ClientVersionResolution( + ClientVersionState.Missing, + "No versioned client is installed. Check for updates to install one.", + null, + null, + null, + null) + : new ClientVersionResolution( + ClientVersionState.Invalid, + current.Error + ?? previous.Error + ?? "No valid client activation pointer could be recovered.", + null, + null, + null, + null); + SetCached(missingOrInvalid); + return missingOrInvalid; + } + + internal async Task PromoteAndActivateUnderLeaseAsync( + string stagingDirectory, + LauncherVersion version, + string rid, + ReleaseArtifact artifact, + IReadOnlyList extractedFiles, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stagingDirectory); + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(artifact); + ArgumentNullException.ThrowIfNull(extractedFiles); + RequireRid(rid); + + string staging = Path.GetFullPath(stagingDirectory); + RequireOwnedStagingPath(staging); + ValidateRequiredExecutables(extractedFiles, rid, launcherPayload: false); + if (extractedFiles.Any(file => string.Equals( + file.Path, + "install.json", + StringComparison.OrdinalIgnoreCase))) + { + throw new LauncherUpdateException( + "The client ZIP may not provide the launcher's install.json record."); + } + + var record = new ClientVersionRecord( + ClientVersionRecord.CurrentSchemaVersion, + version.Value, + rid, + artifact.Sha256.ToLowerInvariant(), + artifact.Size, + extractedFiles + .Select(file => new InstalledFileRecord( + file.Path, + file.Sha256, + file.Size, + file.UnixMode)) + .OrderBy(file => file.Path, StringComparer.Ordinal) + .ToArray()); + ValidateRecord(record, version, rid); + await AtomicJsonFile.WriteAsync( + GetMetadataPath(staging), + record, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + ClientVersionResolution staged = await VerifyVersionDirectoryAsync( + staging, + version, + rid, + cancellationToken) + .ConfigureAwait(false); + if (!staged.IsVerified) + { + throw new LauncherUpdateException(staged.Status); + } + + ClientActivationPointer? oldPointer = (await ReadPointerAsync( + CurrentPointerPath, + cancellationToken) + .ConfigureAwait(false)).Pointer; + string target = GetVersionDirectory(version); + if (Directory.Exists(target)) + { + ClientVersionResolution existing = await VerifyVersionDirectoryAsync( + target, + version, + rid, + cancellationToken) + .ConfigureAwait(false); + if (existing.IsVerified + && existing.Record is not null + && string.Equals( + existing.Record.ArchiveSha256, + artifact.Sha256, + StringComparison.OrdinalIgnoreCase) + && existing.Record.ArchiveSize == artifact.Size) + { + SafeZipExtractor.TryDeleteDirectory(staging); + } + else + { + if (oldPointer is not null + && string.Equals( + oldPointer.CurrentVersion, + version.Value, + StringComparison.Ordinal)) + { + throw new LauncherUpdateException( + "The active client version is corrupt and cannot be replaced in place. " + + "Roll back before repairing it."); + } + + string quarantine = Path.Combine( + AppDirectory, + $".client-corrupt-{Guid.NewGuid():N}"); + Directory.Move(target, quarantine); + try + { + Directory.Move(staging, target); + } + catch + { + Directory.Move(quarantine, target); + throw; + } + + SafeZipExtractor.TryDeleteDirectory(quarantine); + } + } + else + { + Directory.Move(staging, target); + } + + string? previousVersion = oldPointer is null + || string.Equals( + oldPointer.CurrentVersion, + version.Value, + StringComparison.Ordinal) + ? oldPointer?.PreviousVersion + : oldPointer.CurrentVersion; + var pointer = new ClientActivationPointer( + ClientActivationPointer.CurrentSchemaVersion, + version.Value, + previousVersion); + await SavePointerAsync(pointer, cancellationToken).ConfigureAwait(false); + ClientVersionResolution resolution = await ResolvePointerAsync( + pointer, + rid, + cancellationToken) + .ConfigureAwait(false); + if (!resolution.IsVerified) + { + throw new LauncherUpdateException(resolution.Status); + } + + SetCached(resolution); + return resolution; + } + + public async Task RollbackAsync( + string rid, + CancellationToken cancellationToken = default) + { + using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive(); + PointerRead read = await ReadPointerAsync(CurrentPointerPath, cancellationToken) + .ConfigureAwait(false); + ClientActivationPointer pointer = read.Pointer + ?? throw new LauncherUpdateException( + read.Error ?? "There is no active client version to roll back."); + if (string.IsNullOrEmpty(pointer.PreviousVersion)) + { + throw new LauncherUpdateException( + "There is no previous client version available for rollback."); + } + + LauncherVersion previous = LauncherVersion.Parse(pointer.PreviousVersion); + ClientVersionResolution verified = await VerifyVersionDirectoryAsync( + GetVersionDirectory(previous), + previous, + rid, + cancellationToken) + .ConfigureAwait(false); + if (!verified.IsVerified) + { + throw new LauncherUpdateException( + $"The previous client version cannot be activated: {verified.Status}"); + } + + var swapped = new ClientActivationPointer( + ClientActivationPointer.CurrentSchemaVersion, + previous.Value, + pointer.CurrentVersion); + await SavePointerAsync(swapped, cancellationToken).ConfigureAwait(false); + ClientVersionResolution resolution = await ResolvePointerAsync( + swapped, + rid, + cancellationToken) + .ConfigureAwait(false); + SetCached(resolution); + return resolution; + } + + internal string CreateClientStagingDirectory(Guid transactionId) + { + Directory.CreateDirectory(AppDirectory); + return Path.Combine(AppDirectory, $".client-staging-{transactionId:N}"); + } + + internal static void ValidateRequiredExecutables( + IReadOnlyList files, + string rid, + bool launcherPayload) + { + string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty; + string[] required = launcherPayload + ? ["acdream-launcher" + suffix] + : ["AcDream.App" + suffix, "acdream-headless" + suffix]; + foreach (string path in required) + { + ExtractedFileRecord? file = files.SingleOrDefault(candidate => + string.Equals(candidate.Path, path, StringComparison.Ordinal)); + if (file is null) + { + throw new LauncherUpdateException( + $"The release ZIP is missing required root executable '{path}'."); + } + + if (rid.StartsWith("linux-", StringComparison.Ordinal) + && (file.UnixMode & (int)UnixFileMode.UserExecute) == 0) + { + throw new LauncherUpdateException( + $"The Linux release executable '{path}' lacks owner execute permission."); + } + } + } + + private async Task ResolvePointerAsync( + ClientActivationPointer pointer, + string rid, + CancellationToken cancellationToken) + { + string? error = ValidatePointer(pointer); + if (error is not null) + { + return Invalid(error); + } + + LauncherVersion version = LauncherVersion.Parse(pointer.CurrentVersion); + ClientVersionResolution resolution = await VerifyVersionDirectoryAsync( + GetVersionDirectory(version), + version, + rid, + cancellationToken) + .ConfigureAwait(false); + return resolution.IsVerified + ? resolution with { PreviousVersion = pointer.PreviousVersion } + : resolution; + } + + private async Task VerifyVersionDirectoryAsync( + string directory, + LauncherVersion version, + string rid, + CancellationToken cancellationToken) + { + if (!Directory.Exists(directory)) + { + return Invalid($"Client version {version} directory is missing."); + } + + try + { + RejectReparseTree(directory); + ClientVersionRecord? record = await ReadStrictAsync( + GetMetadataPath(directory), + cancellationToken) + .ConfigureAwait(false); + if (record is null) + { + return Invalid($"Client version {version} install.json is missing."); + } + + string? contractError = ValidateRecord(record, version, rid); + if (contractError is not null) + { + return Invalid(contractError); + } + + string[] actualFiles = Directory.EnumerateFiles( + directory, + "*", + SearchOption.AllDirectories) + .Select(path => NormalizeRelative(directory, path)) + .Where(path => !string.Equals( + path, + "install.json", + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + string[] recordedFiles = record.Files + .Select(file => file.Path) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + if (!actualFiles.SequenceEqual(recordedFiles, StringComparer.Ordinal)) + { + return Invalid( + $"Client version {version} contains missing or unrecorded files."); + } + + foreach (InstalledFileRecord file in record.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + string path = ResolveContained(directory, file.Path); + var info = new FileInfo(path); + if (!info.Exists || info.Length != file.Size) + { + return Invalid( + $"Client version {version} file '{file.Path}' size is corrupt."); + } + + string sha256 = await _computeSha256(path, cancellationToken) + .ConfigureAwait(false); + if (!string.Equals( + sha256, + file.Sha256, + StringComparison.OrdinalIgnoreCase)) + { + return Invalid( + $"Client version {version} file '{file.Path}' SHA-256 is corrupt."); + } + + if (OperatingSystem.IsLinux() + && ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode) + { + return Invalid( + $"Client version {version} file '{file.Path}' mode is corrupt."); + } + } + + return new ClientVersionResolution( + ClientVersionState.Verified, + $"Client version {version} verified.", + version, + directory, + null, + record); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException + or NotSupportedException + or FormatException + or LauncherUpdateException) + { + return Invalid( + $"Client version {version} could not be verified: {ex.Message}"); + } + } + + private async Task SavePointerAsync( + ClientActivationPointer pointer, + CancellationToken cancellationToken) + { + string? error = ValidatePointer(pointer); + if (error is not null) + { + throw new LauncherUpdateException(error); + } + + if (File.Exists(CurrentPointerPath)) + { + byte[] previous = await File.ReadAllBytesAsync( + CurrentPointerPath, + cancellationToken) + .ConfigureAwait(false); + PointerRead validPrevious = ParsePointer(previous); + if (validPrevious.Pointer is not null) + { + await AtomicJsonFile.WriteBytesAsync( + PreviousPointerPath, + previous, + cancellationToken) + .ConfigureAwait(false); + } + } + + await WritePointerFileAsync(CurrentPointerPath, pointer, cancellationToken) + .ConfigureAwait(false); + } + + private static Task WritePointerFileAsync( + string path, + ClientActivationPointer pointer, + CancellationToken cancellationToken) => + AtomicJsonFile.WriteAsync(path, pointer, SerializerOptions, cancellationToken); + + private static async Task ReadPointerAsync( + string path, + CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return new PointerRead(null, null); + } + + try + { + byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken) + .ConfigureAwait(false); + return ParsePointer(bytes); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new PointerRead(null, $"Client pointer could not be read: {ex.Message}"); + } + } + + private static PointerRead ParsePointer(ReadOnlyMemory bytes) + { + try + { + ClientActivationPointer? pointer = ParseStrict(bytes.Span); + string? error = pointer is null + ? "Client pointer is empty." + : ValidatePointer(pointer); + return error is null + ? new PointerRead(pointer, null) + : new PointerRead(null, error); + } + catch (Exception ex) when (ex is JsonException + or LauncherUpdateException + or FormatException) + { + return new PointerRead(null, $"Client pointer is invalid: {ex.Message}"); + } + } + + private static string? ValidatePointer(ClientActivationPointer pointer) + { + if (pointer.SchemaVersion != ClientActivationPointer.CurrentSchemaVersion) + { + return $"Client pointer schema version {pointer.SchemaVersion} is not supported."; + } + + if (!LauncherVersion.TryParse(pointer.CurrentVersion, out _)) + { + return "Client pointer currentVersion is invalid."; + } + + if (pointer.PreviousVersion is not null + && (!LauncherVersion.TryParse(pointer.PreviousVersion, out _) + || string.Equals( + pointer.PreviousVersion, + pointer.CurrentVersion, + StringComparison.Ordinal))) + { + return "Client pointer previousVersion is invalid."; + } + + return null; + } + + private static string? ValidateRecord( + ClientVersionRecord record, + LauncherVersion version, + string rid) + { + if (record.SchemaVersion != ClientVersionRecord.CurrentSchemaVersion) + { + return $"Client install schema version {record.SchemaVersion} is not supported."; + } + + if (!string.Equals(record.Version, version.Value, StringComparison.Ordinal) + || !LauncherVersion.TryParse(record.Version, out _)) + { + return "Client install version does not match its directory."; + } + + if (!string.Equals(record.Rid, rid, StringComparison.Ordinal) + || !LauncherRuntimeIdentity.IsValidRid(record.Rid)) + { + return $"Client install RID does not match '{rid}'."; + } + + if (!ReleaseManifestClient.IsSha256(record.ArchiveSha256) + || record.ArchiveSize <= 0 + || record.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes) + { + return "Client install archive metadata is invalid."; + } + + if (record.Files is null || record.Files.Count == 0) + { + return "Client install file list is empty."; + } + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + string? prior = null; + foreach (InstalledFileRecord file in record.Files) + { + if (!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)) + { + return "Client install file metadata is invalid, duplicated, or unsorted."; + } + + prior = file.Path; + } + + string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty; + foreach (string required in new[] + { + "AcDream.App" + suffix, + "acdream-headless" + suffix, + }) + { + if (!paths.Contains(required)) + { + return $"Client install is missing '{required}'."; + } + } + + return null; + } + + private static async Task ReadStrictAsync( + string path, + CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return default; + } + + byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken) + .ConfigureAwait(false); + return ParseStrict(bytes); + } + + internal static T? ParseStrict( + ReadOnlySpan bytes, + JsonSerializerOptions? serializerOptions = null) + { + using JsonDocument document = JsonDocument.Parse( + bytes.ToArray(), + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 32, + }); + RejectDuplicateProperties(document.RootElement, "$" ); + return document.RootElement.Deserialize( + serializerOptions ?? SerializerOptions); + } + + private static void RejectDuplicateProperties(JsonElement element, string path) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new LauncherUpdateException( + $"Duplicate JSON property '{path}.{property.Name}' is not allowed."); + } + + RejectDuplicateProperties(property.Value, $"{path}.{property.Name}"); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + int index = 0; + foreach (JsonElement item in element.EnumerateArray()) + { + RejectDuplicateProperties(item, $"{path}[{index++}]"); + } + } + } + + private void CleanupOwnedResidue() + { + foreach (string path in Directory.EnumerateDirectories( + AppDirectory, + ".client-staging-*", + SearchOption.TopDirectoryOnly)) + { + 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", + SearchOption.TopDirectoryOnly)) + { + string fileName = Path.GetFileName(path); + string[] parts = fileName.Split('.'); + if (parts.Length >= 4 + && string.Equals(parts[^1], "tmp", StringComparison.Ordinal) + && 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) + || !HasCanonicalGuidName( + fileName, + ".client-staging-", + string.Empty)) + { + throw new LauncherUpdateException( + "The client extraction path is not an owned LA10 staging directory."); + } + } + + internal static void RejectReparseTree(string root) + { + if ((File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException("The client version directory is a reparse point."); + } + + var pending = new Stack(); + pending.Push(root); + while (pending.TryPop(out string? directory)) + { + foreach (string path in Directory.EnumerateFileSystemEntries( + directory, + "*", + SearchOption.TopDirectoryOnly)) + { + FileAttributes attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"Client install path '{NormalizeRelative(root, path)}' is a reparse point."); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + pending.Push(path); + } + } + } + } + + private static string NormalizeRelative(string root, string path) => + Path.GetRelativePath(root, path).Replace('\\', '/'); + + internal static bool IsNormalizedRelative(string? path) + { + if (string.IsNullOrEmpty(path) + || path.Length > 512 + || path.IndexOf('\0') >= 0 + || path.Contains('\\', StringComparison.Ordinal) + || path.Contains(':', StringComparison.Ordinal) + || path.StartsWith("/", StringComparison.Ordinal) + || Path.IsPathRooted(path)) + { + return false; + } + + string[] parts = path.Split('/'); + return parts.All(part => + part.Length > 0 + && part is not ("." or "..") + && !part.EndsWith(' ') + && !part.EndsWith('.') + && !part.Any(character => + char.IsControl(character) + || character is '<' or '>' or '"' or '|' or '?' or '*') + && !PortablePathRules.IsWindowsDeviceName(part)); + } + + internal static string ResolveContained(string root, string relative) + { + if (!IsNormalizedRelative(relative)) + { + throw new LauncherUpdateException($"Unsafe relative path '{relative}'."); + } + + string fullRoot = Path.GetFullPath(root); + string path = Path.GetFullPath( + Path.Combine(fullRoot, relative.Replace('/', Path.DirectorySeparatorChar))); + string prefix = Path.EndsInDirectorySeparator(fullRoot) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + if (!path.StartsWith( + prefix, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) + { + throw new LauncherUpdateException($"Path '{relative}' escaped its root."); + } + + return path; + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + private static void RequireRid(string rid) + { + if (!LauncherRuntimeIdentity.IsValidRid(rid)) + { + throw new ArgumentException("RID is invalid.", nameof(rid)); + } + } + + private void SetCached(ClientVersionResolution resolution) + { + lock (_gate) + { + _cached = resolution; + } + } + + private static ClientVersionResolution Invalid(string status) => + new(ClientVersionState.Invalid, status, null, null, null, null); + + private sealed record PointerRead(ClientActivationPointer? Pointer, string? Error); +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs b/src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs new file mode 100644 index 00000000..7bf37410 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs @@ -0,0 +1,33 @@ +using System.Runtime.InteropServices; + +namespace AcDream.Launcher.Core.Updates; + +public static class LauncherRuntimeIdentity +{ + public static string DetectRid() + { + string os = OperatingSystem.IsWindows() + ? "win" + : OperatingSystem.IsLinux() + ? "linux" + : throw new PlatformNotSupportedException( + "The launcher updater supports Windows and Linux only."); + string architecture = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => throw new PlatformNotSupportedException( + $"The launcher updater does not support {RuntimeInformation.ProcessArchitecture}."), + }; + return $"{os}-{architecture}"; + } + + internal static bool IsValidRid(string? rid) => + !string.IsNullOrEmpty(rid) + && rid.Length <= 64 + && rid[0] is >= 'a' and <= 'z' + && rid.All(character => + character is >= 'a' and <= 'z' + or >= '0' and <= '9' + or '-'); +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs new file mode 100644 index 00000000..c37b51e8 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs @@ -0,0 +1,418 @@ +using System.Diagnostics; + +namespace AcDream.Launcher.Core.Updates; + +public sealed record SelfUpdateStartupResult( + bool ShouldExit, + int ExitCode, + string[] RemainingArguments); + +/// +/// 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, + LauncherSelfUpdateManager manager, + string launcherBaseDirectory, + string currentExecutablePath, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(manager); + string baseDirectory = Path.TrimEndingDirectorySeparator( + 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 + || !int.TryParse( + args[1], + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out int parentPid) + || parentPid <= 0) + { + return new SelfUpdateStartupResult(true, 64, []); + } + + int exitCode = await RunHelperAsync( + manager, + parentPid, + args[2], + args[3], + args[4..], + cancellationToken) + .ConfigureAwait(false); + return new SelfUpdateStartupResult(true, exitCode, []); + } + + if (args.Length > 0 + && string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal)) + { + if (args.Length < 2) + { + return new SelfUpdateStartupResult(true, 64, []); + } + + await manager.ConfirmAsync( + args[1], + baseDirectory, + executable, + cancellationToken) + .ConfigureAwait(false); + 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); + } + + using (UpdateSessionBarrier.ExclusiveLease lease = startupLease + ?? throw new InvalidOperationException("Exclusive startup lease is missing.")) + { + SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + _ = manager.CleanupOwnedResidueUnderLease( + plan, + baseDirectory, + lease); + 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, + 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( + LauncherSelfUpdateManager manager, + int parentPid, + string targetDirectory, + string transactionId, + IReadOnlyList publicArguments, + CancellationToken cancellationToken) + { + SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("The helper found no pending self-update."); + if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)) + { + throw new LauncherUpdateException( + "The helper transaction does not match the pending self-update."); + } + + if (!PathsEqual(plan.TargetDirectory, targetDirectory)) + { + throw new LauncherUpdateException( + "The helper target does not match the pending self-update."); + } + + string launcherPath = ClientVersionStore.ResolveContained( + targetDirectory, + GetLauncherFileName(plan.Rid)); + var startInfo = new ProcessStartInfo(launcherPath) + { + UseShellExecute = false, + WorkingDirectory = Path.GetFullPath(targetDirectory), + }; + startInfo.ArgumentList.Add(ConfirmArgument); + startInfo.ArgumentList.Add(transactionId); + foreach (string argument in publicArguments) + { + startInfo.ArgumentList.Add(argument); + } + + await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false); + if (!manager.Barrier.TryAcquireExclusive( + out UpdateSessionBarrier.ExclusiveLease? updateLease)) + { + // 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)) + { + throw new LauncherUpdateException( + "The pending self-update changed before the helper acquired its lease."); + } + + _ = manager.CleanupOwnedResidueUnderLease( + plan, + targetDirectory, + lease); + Process? replacement = null; + try + { + plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken) + .ConfigureAwait(false); + 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; + } + catch + { + if (replacement is { HasExited: false }) + { + replacement.Kill(entireProcessTree: true); + await replacement.WaitForExitAsync(CancellationToken.None) + .ConfigureAwait(false); + } + + try + { + SelfUpdatePlan? pending = await manager.LoadPendingAsync( + CancellationToken.None) + .ConfigureAwait(false); + SelfUpdatePlan? rollbackReceipt = pending?.State switch + { + SelfUpdatePlanState.Applying => + await manager.RecoverApplyingAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false), + SelfUpdatePlanState.AwaitingConfirmation => + await manager.RollbackAwaitingConfirmationAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false), + SelfUpdatePlanState.RolledBack => pending, + _ => null, + }; + if (rollbackReceipt?.State != SelfUpdatePlanState.RolledBack) + { + return 75; + } + + await manager.VerifyRestoredPriorAsync( + 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 + { + 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) + { + try + { + using Process parent = Process.GetProcessById(parentPid); + if (parent.Id == Environment.ProcessId) + { + throw new LauncherUpdateException( + "The self-update helper cannot wait on itself."); + } + + await parent.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + } + catch (ArgumentException) + { + // The parent exited before the helper opened it. + } + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs new file mode 100644 index 00000000..4d36a2bb --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs @@ -0,0 +1,1803 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Updates; + +public enum SelfUpdatePlanState +{ + Staged, + Applying, + AwaitingConfirmation, + RolledBack, +} + +public enum SelfUpdateApplyOperation +{ + Install, + Remove, +} + +public sealed record SelfUpdateApplyEntry( + string Path, + SelfUpdateApplyOperation Operation, + bool HadOriginal, + string? PriorSha256, + long? PriorSize, + int? PriorUnixMode, + string? ReplacementSha256, + long? ReplacementSize, + int? ReplacementUnixMode); + +public sealed record SelfUpdatePlan( + int SchemaVersion, + string TransactionId, + SelfUpdatePlanState State, + string Version, + string Rid, + string TargetDirectory, + string ArchiveSha256, + long ArchiveSize, + IReadOnlyList Files, + IReadOnlyList? Apply) +{ + public const int CurrentSchemaVersion = 3; +} + +public sealed record LauncherBinaryInstallRecord( + int SchemaVersion, + string Version, + string Rid, + IReadOnlyList Files) +{ + public const int CurrentSchemaVersion = 1; +} + +public sealed record SelfUpdateStageResult( + LauncherVersion Version, + string PendingPlanPath, + string Status); + +internal enum SelfUpdateApplyBoundary +{ + AfterTargetMutation, +} + +internal sealed record SelfUpdateApplyObservation( + SelfUpdateApplyBoundary Boundary, + string Path, + SelfUpdateApplyOperation Operation); + +/// +/// 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, + PropertyNameCaseInsensitive = false, + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + MaxDepth = 32, + 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; + + private sealed record JournalFileMetadata(string Sha256, long Size, int UnixMode); + + private sealed record RollbackAction( + SelfUpdateApplyEntry Entry, + string TargetPath, + string BackupPath, + string DiscardPath); + + 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( + Path.GetFullPath(paths.DataDirectory), + "launcher-update"); + TransactionsDirectory = Path.Combine(RootDirectory, "transactions"); + PendingPlanPath = Path.Combine(RootDirectory, "pending.json"); + Barrier = new UpdateSessionBarrier(paths.DataDirectory); + _downloader = new VerifiedArtifactDownloader( + httpClient ?? throw new ArgumentNullException(nameof(httpClient))); + _extractor = extractor ?? new SafeZipExtractor(); + _applyObserver = applyObserver; + } + + public string RootDirectory { get; } + + public string TransactionsDirectory { get; } + + public string PendingPlanPath { get; } + + public UpdateSessionBarrier Barrier { get; } + + public Task StageAsync( + ReleaseManifest manifest, + string rid, + string targetDirectory, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + return StageAsync( + manifest.Version, + rid, + manifest.RequireLauncher(rid), + targetDirectory, + progress, + cancellationToken); + } + + internal async Task StageAsync( + LauncherVersion version, + string rid, + ReleaseArtifact artifact, + string targetDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(artifact); + if (!LauncherRuntimeIdentity.IsValidRid(rid)) + { + 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( + $"Launcher self-update {existing.Version} is already {existing.State}. " + + "Restart the launcher to finish it before staging another."); + } + + string transactionId = Guid.NewGuid().ToString("N"); + string transactionDirectory = GetTransactionDirectory(transactionId); + string payloadDirectory = GetPayloadDirectory(transactionId); + string archivePath = Path.Combine(transactionDirectory, "launcher.zip"); + Directory.CreateDirectory(transactionDirectory); + try + { + _ = await _downloader.DownloadAsync( + artifact, + archivePath, + progress, + cancellationToken) + .ConfigureAwait(false); + IReadOnlyList extracted = await _extractor.ExtractAsync( + archivePath, + payloadDirectory, + cancellationToken) + .ConfigureAwait(false); + ClientVersionStore.ValidateRequiredExecutables( + 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, + transactionId, + SelfUpdatePlanState.Staged, + version.Value, + rid, + target, + artifact.Sha256.ToLowerInvariant(), + artifact.Size, + extracted.Select(file => new InstalledFileRecord( + file.Path, + file.Sha256, + file.Size, + file.UnixMode)) + .OrderBy(file => file.Path, StringComparer.Ordinal) + .ToArray(), + null); + ValidatePlan(plan, target); + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + VerifiedArtifactDownloader.TryDelete(archivePath); + return new SelfUpdateStageResult( + version, + PendingPlanPath, + $"Launcher {version} is staged and will be applied on next start."); + } + catch + { + if (!File.Exists(PendingPlanPath)) + { + SafeZipExtractor.TryDeleteDirectory(transactionDirectory); + } + + throw; + } + } + + /// + /// 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)) + { + return null; + } + + try + { + byte[] bytes = await File.ReadAllBytesAsync(PendingPlanPath, cancellationToken) + .ConfigureAwait(false); + SelfUpdatePlan? plan = ClientVersionStore.ParseStrict( + bytes, + SerializerOptions); + if (plan is null) + { + throw new LauncherUpdateException("The self-update plan is empty."); + } + + ValidatePlan(plan, plan.TargetDirectory); + return plan; + } + catch (OperationCanceledException) + { + throw; + } + catch (LauncherUpdateException) + { + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException + or FormatException + or NotSupportedException) + { + throw new LauncherUpdateException( + $"The pending launcher self-update is invalid: {ex.Message}", + ex); + } + } + + public async Task ApplyPendingAsync( + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no staged launcher self-update."); + ValidatePlan(plan, expectedTarget); + + if (plan.State == SelfUpdatePlanState.AwaitingConfirmation) + { + return plan; + } + + if (plan.State == SelfUpdatePlanState.Applying) + { + plan = await RollbackApplyingAsync(plan, cancellationToken) + .ConfigureAwait(false); + } + + if (plan.State == SelfUpdatePlanState.RolledBack) + { + await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + plan = plan with + { + State = SelfUpdatePlanState.Staged, + Apply = null, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + } + + await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false); + LauncherBinaryInstallRecord? previous = await ReadAndVerifyInstallRecordAsync( + expectedTarget, + plan.Rid, + cancellationToken) + .ConfigureAwait(false); + IReadOnlyList apply = await BuildApplyJournalAsync( + plan, + previous, + expectedTarget, + cancellationToken) + .ConfigureAwait(false); + apply = await PrepareTargetTransactionAsync(plan, apply, cancellationToken) + .ConfigureAwait(false); + plan = plan with + { + State = SelfUpdatePlanState.Applying, + Apply = apply, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + + try + { + foreach (SelfUpdateApplyEntry entry in plan.Apply) + { + cancellationToken.ThrowIfCancellationRequested(); + await ApplyEntryAsync(plan, entry, cancellationToken) + .ConfigureAwait(false); + _applyObserver?.Invoke(new SelfUpdateApplyObservation( + SelfUpdateApplyBoundary.AfterTargetMutation, + entry.Path, + entry.Operation)); + } + + plan = plan with { State = SelfUpdatePlanState.AwaitingConfirmation }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + return plan; + } + catch + { + await RollbackApplyingAsync(plan, CancellationToken.None) + .ConfigureAwait(false); + throw; + } + } + + public async Task RecoverApplyingAsync( + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no pending self-update."); + ValidatePlan(plan, expectedTarget); + return plan.State == SelfUpdatePlanState.Applying + ? await RollbackApplyingAsync(plan, cancellationToken).ConfigureAwait(false) + : plan; + } + + internal async Task VerifyRestoredPriorAsync( + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no rolled-back self-update."); + ValidatePlan(plan, expectedTarget); + if (plan.State != SelfUpdatePlanState.RolledBack) + { + throw new LauncherUpdateException( + "The pending self-update has no verified rollback receipt."); + } + + await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + } + + public async Task ConfirmAsync( + string transactionId, + string expectedTargetDirectory, + string currentExecutablePath, + CancellationToken cancellationToken = default) + { + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no self-update to confirm."); + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + ValidatePlan(plan, expectedTarget); + if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal) + || plan.State != SelfUpdatePlanState.AwaitingConfirmation) + { + throw new LauncherUpdateException( + "The running launcher does not match the pending confirmation plan."); + } + + string expectedExecutable = ClientVersionStore.ResolveContained( + expectedTarget, + GetLauncherFileName(plan.Rid)); + if (!PathsEqual(expectedExecutable, currentExecutablePath)) + { + throw new LauncherUpdateException( + "Only the newly installed launcher executable may confirm self-update."); + } + + await VerifyAppliedTargetsAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + await VerifyInstalledOwnershipMatchesPlanAsync( + plan, + expectedTarget, + cancellationToken) + .ConfigureAwait(false); + string confirmationPath = GetConfirmationPath(transactionId); + await AtomicJsonFile.WriteBytesAsync( + confirmationPath, + "confirmed"u8.ToArray(), + cancellationToken) + .ConfigureAwait(false); + } + + public bool IsConfirmed(string transactionId) => + File.Exists(GetConfirmationPath(transactionId)); + + public async Task CompleteConfirmedAsync( + string transactionId, + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no self-update to complete."); + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + ValidatePlan(plan, expectedTarget); + if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal) + || plan.State != SelfUpdatePlanState.AwaitingConfirmation + || !IsConfirmed(transactionId)) + { + throw new LauncherUpdateException("The self-update is not confirmed."); + } + + File.Delete(PendingPlanPath); + SafeZipExtractor.TryDeleteDirectory(GetTargetTransactionDirectory(plan)); + SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId)); + } + + public async Task RollbackAwaitingConfirmationAsync( + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no self-update to roll back."); + ValidatePlan(plan, expectedTarget); + if (plan.State != SelfUpdatePlanState.AwaitingConfirmation) + { + throw new LauncherUpdateException( + "The pending self-update is not awaiting confirmation."); + } + + plan = plan with { State = SelfUpdatePlanState.Applying }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + return await RollbackApplyingAsync(plan, cancellationToken) + .ConfigureAwait(false); + } + + public string GetTransactionDirectory(string transactionId) + { + RequireTransactionId(transactionId); + return Path.Combine(TransactionsDirectory, transactionId); + } + + public string GetPayloadDirectory(string transactionId) => + Path.Combine(GetTransactionDirectory(transactionId), "payload"); + + public string GetConfirmationPath(string transactionId) => + Path.Combine(GetTransactionDirectory(transactionId), "confirmed"); + + 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) + { + 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 async Task> BuildApplyJournalAsync( + SelfUpdatePlan plan, + LauncherBinaryInstallRecord? previous, + string targetDirectory, + CancellationToken cancellationToken) + { + 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); + JournalFileMetadata? prior = await CaptureOptionalFileMetadataAsync( + targetPath, + $"Self-update target '{path}'", + cancellationToken) + .ConfigureAwait(false); + bool hadOriginal = prior is not null; + 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, + prior?.Sha256, + prior?.Size, + prior?.UnixMode, + ReplacementSha256: null, + ReplacementSize: null, + ReplacementUnixMode: null)); + } + + 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."); + } + + var completed = new List(apply.Count); + foreach (SelfUpdateApplyEntry entry in apply) + { + if (entry.Operation == SelfUpdateApplyOperation.Remove) + { + completed.Add(entry); + continue; + } + + JournalFileMetadata replacement = await CaptureRequiredFileMetadataAsync( + ClientVersionStore.ResolveContained(incoming, entry.Path), + $"Target-local incoming launcher file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + completed.Add(entry with + { + ReplacementSha256 = replacement.Sha256, + ReplacementSize = replacement.Size, + ReplacementUnixMode = replacement.UnixMode, + }); + } + + return completed; + } + + private async Task ApplyEntryAsync( + SelfUpdatePlan plan, + SelfUpdateApplyEntry entry, + CancellationToken cancellationToken) + { + 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); + string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path); + ClientVersionStore.RejectReparseTree(swap); + EnsureExistingParentsSafe(plan.TargetDirectory, targetPath); + EnsureExistingParentsSafe(swap, incomingPath); + EnsureExistingParentsSafe(swap, backupPath); + EnsurePathMissing(backupPath, $"Self-update backup '{entry.Path}'"); + + if (entry.HadOriginal) + { + await VerifyPriorFileAsync(entry, targetPath, cancellationToken) + .ConfigureAwait(false); + } + else + { + EnsurePathMissing(targetPath, $"Self-update target '{entry.Path}'"); + } + + if (entry.Operation == SelfUpdateApplyOperation.Remove) + { + EnsureSafeParent(swap, backupPath); + File.Move(targetPath, backupPath); + return; + } + + await VerifyReplacementFileAsync(entry, incomingPath, cancellationToken) + .ConfigureAwait(false); + EnsureSafeParent(swap, backupPath); + + if (entry.HadOriginal) + { + File.Replace(incomingPath, targetPath, backupPath, ignoreMetadataErrors: true); + } + else + { + File.Move(incomingPath, targetPath); + } + } + + private async Task RollbackApplyingAsync( + SelfUpdatePlan plan, + CancellationToken cancellationToken) + { + if (plan.State != SelfUpdatePlanState.Applying || plan.Apply is null) + { + throw new LauncherUpdateException("The self-update rollback journal is missing."); + } + + string swap = GetTargetTransactionDirectory(plan); + IReadOnlyList actions = await PreflightRollbackAsync( + plan, + cancellationToken) + .ConfigureAwait(false); + foreach (RollbackAction action in actions) + { + cancellationToken.ThrowIfCancellationRequested(); + ClientVersionStore.RejectReparseTree(swap); + EnsureExistingParentsSafe(plan.TargetDirectory, action.TargetPath); + EnsureExistingParentsSafe(swap, action.BackupPath); + EnsureExistingParentsSafe(swap, action.DiscardPath); + if (action.Entry.Operation == SelfUpdateApplyOperation.Remove) + { + EnsureSafeParent(plan.TargetDirectory, action.TargetPath); + File.Move(action.BackupPath, action.TargetPath); + continue; + } + + EnsureSafeParent(swap, action.DiscardPath); + if (action.Entry.HadOriginal) + { + File.Replace( + action.BackupPath, + action.TargetPath, + action.DiscardPath, + ignoreMetadataErrors: true); + } + else + { + File.Move(action.TargetPath, action.DiscardPath); + } + } + + await VerifyRestoredPriorAsync(plan, plan.TargetDirectory, cancellationToken) + .ConfigureAwait(false); + plan = plan with + { + State = SelfUpdatePlanState.RolledBack, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + SafeZipExtractor.TryDeleteDirectory(swap); + return plan; + } + + private async Task> PreflightRollbackAsync( + SelfUpdatePlan plan, + CancellationToken cancellationToken) + { + string swap = GetTargetTransactionDirectory(plan); + if (!Directory.Exists(swap)) + { + throw new LauncherUpdateException( + "The target-local self-update rollback transaction is missing."); + } + + ClientVersionStore.RejectReparseTree(swap); + ValidateRollbackTree(plan, swap); + string incoming = Path.Combine(swap, "incoming"); + string backup = Path.Combine(swap, "backup"); + string discard = Path.Combine(swap, "rollback-discard"); + var actions = new List(); + foreach (SelfUpdateApplyEntry entry in plan.Apply!.Reverse()) + { + cancellationToken.ThrowIfCancellationRequested(); + string targetPath = ClientVersionStore.ResolveContained( + plan.TargetDirectory, + entry.Path); + string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path); + string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); + string discardPath = ClientVersionStore.ResolveContained(discard, entry.Path); + EnsureExistingParentsSafe(plan.TargetDirectory, targetPath); + EnsureExistingParentsSafe(swap, incomingPath); + EnsureExistingParentsSafe(swap, backupPath); + EnsureExistingParentsSafe(swap, discardPath); + + JournalFileMetadata? target = await CaptureOptionalFileMetadataAsync( + targetPath, + $"Rollback target '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + JournalFileMetadata? incomingFile = await CaptureOptionalFileMetadataAsync( + incomingPath, + $"Rollback incoming file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + JournalFileMetadata? backupFile = await CaptureOptionalFileMetadataAsync( + backupPath, + $"Rollback backup file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + JournalFileMetadata? discardedFile = await CaptureOptionalFileMetadataAsync( + discardPath, + $"Rollback discard file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + + if (entry.Operation == SelfUpdateApplyOperation.Remove) + { + RequireMissing(incomingFile, entry.Path, "incoming"); + RequireMissing(discardedFile, entry.Path, "discard"); + if (backupFile is not null && target is null) + { + RequirePriorMetadata(entry, backupFile, "rollback backup"); + actions.Add(new RollbackAction( + entry, + targetPath, + backupPath, + discardPath)); + continue; + } + + if (backupFile is null && target is not null) + { + RequirePriorMetadata(entry, target, "restored rollback target"); + continue; + } + + throw AmbiguousRollback(entry.Path); + } + + if (entry.HadOriginal) + { + if (backupFile is not null + && target is not null + && incomingFile is null + && discardedFile is null) + { + RequirePriorMetadata(entry, backupFile, "rollback backup"); + RequireReplacementMetadata(entry, target, "applied rollback target"); + actions.Add(new RollbackAction( + entry, + targetPath, + backupPath, + discardPath)); + continue; + } + + if (backupFile is null && target is not null) + { + RequirePriorMetadata(entry, target, "restored rollback target"); + if (incomingFile is not null && discardedFile is null) + { + RequireReplacementMetadata( + entry, + incomingFile, + "unapplied rollback incoming file"); + continue; + } + + if (incomingFile is null && discardedFile is not null) + { + RequireReplacementMetadata( + entry, + discardedFile, + "completed rollback discard"); + continue; + } + } + + throw AmbiguousRollback(entry.Path); + } + + RequireMissing(backupFile, entry.Path, "backup"); + if (target is not null + && incomingFile is null + && discardedFile is null) + { + RequireReplacementMetadata(entry, target, "applied rollback target"); + actions.Add(new RollbackAction( + entry, + targetPath, + backupPath, + discardPath)); + continue; + } + + if (target is null && incomingFile is not null && discardedFile is null) + { + RequireReplacementMetadata( + entry, + incomingFile, + "unapplied rollback incoming file"); + continue; + } + + if (target is null && incomingFile is null && discardedFile is not null) + { + RequireReplacementMetadata( + entry, + discardedFile, + "completed rollback discard"); + continue; + } + + throw AmbiguousRollback(entry.Path); + } + + return actions; + } + + private static void ValidateRollbackTree(SelfUpdatePlan plan, string swap) + { + RequireTransactionContainer(Path.Combine(swap, "incoming"), required: true); + RequireTransactionContainer(Path.Combine(swap, "backup"), required: false); + RequireTransactionContainer( + Path.Combine(swap, "rollback-discard"), + required: false); + var allowed = new HashSet(StringComparer.Ordinal) + { + "incoming", + }; + foreach (SelfUpdateApplyEntry entry in plan.Apply!) + { + if (entry.Operation == SelfUpdateApplyOperation.Install) + { + AddAllowedTreePath(allowed, "incoming", entry.Path); + AddAllowedTreePath(allowed, "rollback-discard", entry.Path); + } + + if (entry.HadOriginal) + { + AddAllowedTreePath(allowed, "backup", entry.Path); + } + } + + foreach (string path in Directory.EnumerateFileSystemEntries( + swap, + "*", + SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(swap, path).Replace('\\', '/'); + if (!allowed.Contains(relative)) + { + throw new LauncherUpdateException( + $"The rollback transaction contains unrecorded path '{relative}'."); + } + } + } + + private static void RequireTransactionContainer(string path, bool required) + { + try + { + FileAttributes attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.Directory) == 0 + || (attributes & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"Rollback container '{Path.GetFileName(path)}' is not a safe directory."); + } + } + catch (FileNotFoundException) when (!required) + { + } + catch (DirectoryNotFoundException) when (!required) + { + } + catch (FileNotFoundException) + { + throw new LauncherUpdateException( + $"Required rollback container '{Path.GetFileName(path)}' is missing."); + } + catch (DirectoryNotFoundException) + { + throw new LauncherUpdateException( + $"Required rollback container '{Path.GetFileName(path)}' is missing."); + } + } + + private static void AddAllowedTreePath( + HashSet allowed, + string container, + string relativePath) + { + allowed.Add(container); + string current = container; + foreach (string segment in relativePath.Split('/')) + { + current += "/" + segment; + allowed.Add(current); + } + } + + private static LauncherUpdateException AmbiguousRollback(string path) => new( + $"Rollback state for '{path}' is corrupt or ambiguous; transaction evidence was preserved."); + + private static void RequireMissing( + JournalFileMetadata? metadata, + string path, + string location) + { + if (metadata is not null) + { + throw new LauncherUpdateException( + $"Rollback {location} for '{path}' is unexpected; transaction evidence was preserved."); + } + } + + private static async Task VerifyRestoredPriorAsync( + SelfUpdatePlan plan, + string targetDirectory, + CancellationToken cancellationToken) + { + if (plan.Apply is null) + { + throw new LauncherUpdateException("The rollback receipt is missing its apply journal."); + } + + foreach (SelfUpdateApplyEntry entry in plan.Apply) + { + cancellationToken.ThrowIfCancellationRequested(); + string targetPath = ClientVersionStore.ResolveContained(targetDirectory, entry.Path); + EnsureExistingParentsSafe(targetDirectory, targetPath); + if (entry.HadOriginal) + { + await VerifyPriorFileAsync(entry, targetPath, cancellationToken) + .ConfigureAwait(false); + } + else + { + EnsurePathMissing(targetPath, $"Restored rollback target '{entry.Path}'"); + } + } + } + + private static async Task VerifyPriorFileAsync( + SelfUpdateApplyEntry entry, + string path, + CancellationToken cancellationToken) + { + JournalFileMetadata actual = await CaptureRequiredFileMetadataAsync( + path, + $"Prior launcher file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + RequirePriorMetadata(entry, actual, "prior launcher file"); + } + + private static async Task VerifyReplacementFileAsync( + SelfUpdateApplyEntry entry, + string path, + CancellationToken cancellationToken) + { + JournalFileMetadata actual = await CaptureRequiredFileMetadataAsync( + path, + $"Replacement launcher file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + RequireReplacementMetadata(entry, actual, "replacement launcher file"); + } + + private static void RequirePriorMetadata( + SelfUpdateApplyEntry entry, + JournalFileMetadata actual, + string description) => + RequireMetadata( + entry.Path, + description, + actual, + entry.PriorSha256, + entry.PriorSize, + entry.PriorUnixMode); + + private static void RequireReplacementMetadata( + SelfUpdateApplyEntry entry, + JournalFileMetadata actual, + string description) => + RequireMetadata( + entry.Path, + description, + actual, + entry.ReplacementSha256, + entry.ReplacementSize, + entry.ReplacementUnixMode); + + private static void RequireMetadata( + string path, + string description, + JournalFileMetadata actual, + string? expectedSha256, + long? expectedSize, + int? expectedUnixMode) + { + if (!string.Equals(actual.Sha256, expectedSha256, StringComparison.OrdinalIgnoreCase) + || actual.Size != expectedSize + || actual.UnixMode != expectedUnixMode) + { + throw new LauncherUpdateException( + $"The {description} '{path}' failed its rollback integrity check; " + + "transaction evidence was preserved."); + } + } + + private static async Task CaptureRequiredFileMetadataAsync( + string path, + string description, + CancellationToken cancellationToken) => + await CaptureOptionalFileMetadataAsync(path, description, cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException($"{description} is missing."); + + private static async Task CaptureOptionalFileMetadataAsync( + string path, + string description, + CancellationToken cancellationToken) + { + FileAttributes attributes; + try + { + attributes = File.GetAttributes(path); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + { + throw new LauncherUpdateException($"{description} is a directory or reparse point."); + } + + var before = new FileInfo(path); + long size = before.Length; + int unixMode = OperatingSystem.IsLinux() + ? (int)File.GetUnixFileMode(path) & 0x1FF + : 0; + string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( + path, + cancellationToken) + .ConfigureAwait(false); + var after = new FileInfo(path); + after.Refresh(); + if (!after.Exists + || (after.Attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 + || after.Length != size + || (OperatingSystem.IsLinux() + && ((int)File.GetUnixFileMode(path) & 0x1FF) != unixMode)) + { + throw new LauncherUpdateException($"{description} changed while it was measured."); + } + + return new JournalFileMetadata(sha256, size, unixMode); + } + + private static void EnsurePathMissing(string path, string description) + { + try + { + _ = File.GetAttributes(path); + } + catch (FileNotFoundException) + { + return; + } + catch (DirectoryNotFoundException) + { + return; + } + + throw new LauncherUpdateException($"{description} already exists."); + } + + private async Task VerifyPayloadAsync( + SelfUpdatePlan plan, + CancellationToken cancellationToken) + { + string payload = GetPayloadDirectory(plan.TransactionId); + if (!Directory.Exists(payload)) + { + throw new LauncherUpdateException("The staged launcher payload is missing."); + } + + ClientVersionStore.RejectReparseTree(payload); + string[] actual = Directory.EnumerateFiles( + payload, + "*", + SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(payload, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + string[] expected = plan.Files + .Select(file => file.Path) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + if (!actual.SequenceEqual(expected, StringComparer.Ordinal)) + { + throw new LauncherUpdateException( + "The staged launcher contains missing or unrecorded files."); + } + + foreach (InstalledFileRecord file in plan.Files) + { + await VerifyFileAsync(payload, file, "Staged launcher", cancellationToken) + .ConfigureAwait(false); + } + } + + private static async Task VerifyAppliedTargetsAsync( + SelfUpdatePlan plan, + string targetDirectory, + CancellationToken cancellationToken) + { + foreach (InstalledFileRecord file in plan.Files) + { + await VerifyFileAsync( + targetDirectory, + file, + "Applied launcher", + cancellationToken) + .ConfigureAwait(false); + } + + if (plan.Apply is not null) + { + foreach (SelfUpdateApplyEntry obsolete in plan.Apply.Where(entry => + entry.Operation == SelfUpdateApplyOperation.Remove)) + { + 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) + { + ValidatePlan(plan, plan.TargetDirectory); + await AtomicJsonFile.WriteAsync( + PendingPlanPath, + plan, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + } + + private void ValidatePlan(SelfUpdatePlan plan, string expectedTargetDirectory) + { + if (plan.SchemaVersion != SelfUpdatePlan.CurrentSchemaVersion) + { + throw new LauncherUpdateException( + $"Self-update schema version {plan.SchemaVersion} is not supported."); + } + + RequireTransactionId(plan.TransactionId); + if (!LauncherVersion.TryParse(plan.Version, out _) + || !LauncherRuntimeIdentity.IsValidRid(plan.Rid) + || !ReleaseManifestClient.IsSha256(plan.ArchiveSha256) + || plan.ArchiveSize <= 0 + || plan.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes) + { + throw new LauncherUpdateException("The self-update plan metadata is invalid."); + } + + string target = NormalizeTargetDirectory(plan.TargetDirectory); + if (!PathsEqual(target, expectedTargetDirectory)) + { + throw new LauncherUpdateException( + "The self-update target does not match the running launcher directory."); + } + + 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 file list has a reserved path or lacks the launcher executable."); + } + + if (plan.State == SelfUpdatePlanState.Staged && plan.Apply is not null + || plan.State != SelfUpdatePlanState.Staged && plan.Apply is null) + { + throw new LauncherUpdateException( + "The self-update apply journal does not match its state."); + } + + if (plan.Apply is not null) + { + 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) + || entry.HadOriginal != ( + ReleaseManifestClient.IsSha256(entry.PriorSha256) + && entry.PriorSize is >= 0 + && entry.PriorUnixMode is >= 0 and <= 0x1FF) + || entry.HadOriginal == ( + entry.PriorSha256 is null + && entry.PriorSize is null + && entry.PriorUnixMode is null) + || (entry.Operation == SelfUpdateApplyOperation.Install) != ( + ReleaseManifestClient.IsSha256(entry.ReplacementSha256) + && entry.ReplacementSize is >= 0 + && entry.ReplacementUnixMode is >= 0 and <= 0x1FF) + || (entry.Operation == SelfUpdateApplyOperation.Install) == ( + entry.ReplacementSha256 is null + && entry.ReplacementSize is null + && entry.ReplacementUnixMode is null) + || (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 journal removes a new or reserved file."); + } + } + + string transactionDirectory = GetTransactionDirectory(plan.TransactionId); + if (!IsContained(TransactionsDirectory, transactionDirectory) + || !IsContained(target, GetTargetTransactionDirectory(plan))) + { + 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); + if (!Path.IsPathFullyQualified(targetDirectory)) + { + throw new LauncherUpdateException( + "The self-update target directory must be absolute."); + } + + string target = Path.TrimEndingDirectorySeparator(Path.GetFullPath(targetDirectory)); + if (!Directory.Exists(target) + || (File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + "The self-update target directory is missing or is a reparse point."); + } + + return target; + } + + private static void EnsureSafeParent(string root, string filePath) + { + string? parent = Path.GetDirectoryName(filePath); + if (parent is null) + { + throw new LauncherUpdateException("A self-update target has no parent."); + } + + EnsureExistingParentsSafe(root, filePath); + Directory.CreateDirectory(parent); + EnsureExistingParentsSafe(root, filePath); + } + + private static void EnsureExistingParentsSafe(string root, string filePath) + { + string? parent = Path.GetDirectoryName(filePath); + if (parent is null) + { + throw new LauncherUpdateException("A self-update target has no parent."); + } + + for (var directory = new DirectoryInfo(parent); + directory is not null && IsContained(root, directory.FullName); + directory = directory.Parent) + { + FileAttributes attributes; + try + { + attributes = File.GetAttributes(directory.FullName); + } + catch (FileNotFoundException) + { + continue; + } + catch (DirectoryNotFoundException) + { + continue; + } + + if ((attributes & FileAttributes.Directory) == 0 + || (attributes & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"Self-update target parent '{directory.FullName}' is not a safe directory."); + } + + if (PathsEqual(directory.FullName, root)) + { + break; + } + } + } + + private static bool IsContained(string root, string path) + { + string fullRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + string fullPath = Path.GetFullPath(path); + return PathsEqual(fullRoot, fullPath) + || fullPath.StartsWith( + fullRoot + Path.DirectorySeparatorChar, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + private static void RequireTransactionId(string transactionId) + { + if (transactionId.Length != 32 + || !Guid.TryParseExact(transactionId, "N", out Guid parsed) + || !string.Equals(parsed.ToString("N"), transactionId, StringComparison.Ordinal)) + { + throw new LauncherUpdateException("The self-update transaction id is invalid."); + } + } + + private void CleanupDataResidue(string? keepTransactionId) + { + if (Directory.Exists(TransactionsDirectory)) + { + foreach (string directory in Directory.EnumerateDirectories( + TransactionsDirectory, + "*", + SearchOption.TopDirectoryOnly)) + { + string name = Path.GetFileName(directory); + if (IsCanonicalTransactionId(name) + && !string.Equals(name, keepTransactionId, StringComparison.Ordinal)) + { + SafeZipExtractor.TryDeleteDirectory(directory); + } + } + } + + if (!Directory.Exists(RootDirectory)) + { + return; + } + + foreach (string temporary in Directory.EnumerateFiles( + RootDirectory, + ".pending.json.*.tmp", + SearchOption.TopDirectoryOnly)) + { + string name = Path.GetFileName(temporary); + 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 new file mode 100644 index 00000000..01d7676b --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs @@ -0,0 +1,454 @@ +namespace AcDream.Launcher.Core.Updates; + +public enum LauncherUpdatePhase +{ + Idle, + Checking, + DownloadingClient, + ExtractingClient, + ActivatingClient, + DownloadingLauncher, + StagingLauncher, + RollingBack, + Completed, + Cancelled, + Failed, +} + +public sealed record LauncherUpdateProgress( + LauncherUpdatePhase Phase, + string Status, + long Completed = 0, + long Total = 0) +{ + public double Percent => Total <= 0 + ? 0 + : Math.Clamp(Completed * 100d / Total, 0, 100); +} + +public sealed record LauncherUpdateCheckResult( + ReleaseManifest Manifest, + string Rid, + LauncherVersion LauncherVersion, + LauncherVersion? InstalledClientVersion, + bool IsClientUpdateAvailable, + bool IsLauncherUpdateAvailable, + bool IsLauncherMinimumSatisfied, + string Status); + +public interface ILauncherUpdater +{ + ClientVersionResolution CurrentClient { get; } + + Task InitializeAsync( + CancellationToken cancellationToken = default); + + Task CheckAsync( + CancellationToken cancellationToken = default); + + Task InstallClientAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default); + + Task StageLauncherAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default); + + Task RollbackClientAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default); +} + +/// +/// Canonical LA10 update transaction. It holds the cross-process exclusive +/// barrier for recovery/download/extraction/promotion/pointer publication and +/// leaves LA9's verified DAT/pak record untouched. +/// +public sealed class LauncherUpdater : ILauncherUpdater +{ + private readonly IReleaseManifestClient _manifestClient; + private readonly ClientVersionStore _versions; + private readonly LauncherSelfUpdateManager _selfUpdates; + private readonly VerifiedArtifactDownloader _downloader; + private readonly SafeZipExtractor _extractor; + private readonly LauncherVersion _launcherVersion; + private readonly string _rid; + private readonly string _launcherTargetDirectory; + private readonly Func _hasRunningSessions; + private readonly SemaphoreSlim _operationGate = new(1, 1); + + public LauncherUpdater( + IReleaseManifestClient manifestClient, + HttpClient httpClient, + ClientVersionStore versions, + LauncherSelfUpdateManager selfUpdates, + LauncherVersion launcherVersion, + string rid, + string launcherTargetDirectory, + Func? hasRunningSessions = null, + SafeZipExtractor? extractor = null) + { + _manifestClient = manifestClient + ?? throw new ArgumentNullException(nameof(manifestClient)); + _versions = versions ?? throw new ArgumentNullException(nameof(versions)); + _selfUpdates = selfUpdates ?? throw new ArgumentNullException(nameof(selfUpdates)); + _launcherVersion = launcherVersion + ?? throw new ArgumentNullException(nameof(launcherVersion)); + if (!LauncherRuntimeIdentity.IsValidRid(rid)) + { + throw new ArgumentException("RID is invalid.", nameof(rid)); + } + + _rid = rid; + ArgumentException.ThrowIfNullOrWhiteSpace(launcherTargetDirectory); + _launcherTargetDirectory = Path.GetFullPath(launcherTargetDirectory); + _hasRunningSessions = hasRunningSessions ?? (() => false); + _downloader = new VerifiedArtifactDownloader( + httpClient ?? throw new ArgumentNullException(nameof(httpClient))); + _extractor = extractor ?? new SafeZipExtractor(); + } + + public ClientVersionResolution CurrentClient => _versions.CachedResolution; + + public Task InitializeAsync( + CancellationToken cancellationToken = default) => + _versions.LoadAndRecoverAsync(_rid, cancellationToken); + + public async Task CheckAsync( + CancellationToken cancellationToken = default) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ReleaseManifest manifest = await _manifestClient.FetchAsync(cancellationToken) + .ConfigureAwait(false); + _ = manifest.RequireClient(_rid); + _ = manifest.RequireLauncher(_rid); + ClientVersionResolution installed = _versions.CachedResolution; + LauncherVersion? installedVersion = installed.IsVerified + ? installed.Version + : null; + bool clientAvailable = installedVersion is null + || manifest.Version > installedVersion; + bool launcherAvailable = manifest.Version > _launcherVersion; + bool minimumSatisfied = _launcherVersion >= manifest.MinimumLauncherVersion; + string status = BuildCheckStatus( + manifest, + installedVersion, + clientAvailable, + launcherAvailable, + minimumSatisfied); + return new LauncherUpdateCheckResult( + manifest, + _rid, + _launcherVersion, + installedVersion, + clientAvailable, + launcherAvailable, + minimumSatisfied, + status); + } + finally + { + _operationGate.Release(); + } + } + + public async Task InstallClientAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(check); + ValidateCheck(check); + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + RefuseRunningSessions(); + using UpdateSessionBarrier.ExclusiveLease lease = + _versions.Barrier.AcquireExclusive(); + RefuseRunningSessions(); + ClientVersionResolution current = await _versions + .LoadAndRecoverUnderLeaseAsync(_rid, cancellationToken) + .ConfigureAwait(false); + if (!check.IsLauncherMinimumSatisfied) + { + throw new LauncherUpdateException( + $"Client {check.Manifest.Version} requires launcher " + + $"{check.Manifest.MinimumLauncherVersion} or newer. " + + "Stage the launcher update first."); + } + + if (current.IsVerified + && current.Version is not null + && current.Version >= check.Manifest.Version) + { + Report( + progress, + LauncherUpdatePhase.Completed, + $"Client {current.Version} is already current.", + 1, + 1); + return current; + } + + ReleaseArtifact artifact = check.Manifest.RequireClient(_rid); + Guid transactionId = Guid.NewGuid(); + string staging = _versions.CreateClientStagingDirectory(transactionId); + string archive = Path.Combine( + _versions.AppDirectory, + $".client-download-{transactionId:N}.zip"); + try + { + Report( + progress, + LauncherUpdatePhase.DownloadingClient, + $"Downloading client {check.Manifest.Version}...", + 0, + artifact.Size); + var downloadProgress = new ForwardProgress(value => + Report( + progress, + LauncherUpdatePhase.DownloadingClient, + $"Downloading client {check.Manifest.Version}: " + + $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes", + value.BytesReceived, + value.TotalBytes)); + _ = await _downloader.DownloadAsync( + artifact, + archive, + downloadProgress, + cancellationToken) + .ConfigureAwait(false); + + Report( + progress, + LauncherUpdatePhase.ExtractingClient, + "Verifying paths and extracting the client archive..."); + IReadOnlyList files = await _extractor.ExtractAsync( + archive, + staging, + cancellationToken) + .ConfigureAwait(false); + Report( + progress, + LauncherUpdatePhase.ActivatingClient, + $"Atomically activating client {check.Manifest.Version}..."); + ClientVersionResolution result = await _versions + .PromoteAndActivateUnderLeaseAsync( + staging, + check.Manifest.Version, + _rid, + artifact, + files, + cancellationToken) + .ConfigureAwait(false); + Report( + progress, + LauncherUpdatePhase.Completed, + $"Client {check.Manifest.Version} installed and activated.", + 1, + 1); + return result; + } + catch (OperationCanceledException) + { + Report( + progress, + LauncherUpdatePhase.Cancelled, + "Client update cancelled; the active version was not changed."); + throw; + } + catch (Exception ex) + { + Report( + progress, + LauncherUpdatePhase.Failed, + $"Client update failed: {ex.Message}"); + throw; + } + finally + { + VerifiedArtifactDownloader.TryDelete(archive); + SafeZipExtractor.TryDeleteDirectory(staging); + } + } + finally + { + _operationGate.Release(); + } + } + + public async Task StageLauncherAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(check); + ValidateCheck(check); + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + RefuseRunningSessions(); + if (check.Manifest.Version <= _launcherVersion) + { + throw new LauncherUpdateException( + $"Launcher {_launcherVersion} is already current."); + } + + Report( + progress, + LauncherUpdatePhase.DownloadingLauncher, + $"Downloading launcher {check.Manifest.Version}..."); + var downloadProgress = new ForwardProgress(value => + Report( + progress, + LauncherUpdatePhase.DownloadingLauncher, + $"Downloading launcher {check.Manifest.Version}: " + + $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes", + value.BytesReceived, + value.TotalBytes)); + try + { + SelfUpdateStageResult result = await _selfUpdates.StageAsync( + check.Manifest, + _rid, + _launcherTargetDirectory, + downloadProgress, + cancellationToken) + .ConfigureAwait(false); + Report( + progress, + LauncherUpdatePhase.StagingLauncher, + result.Status, + 1, + 1); + return result; + } + catch (OperationCanceledException) + { + Report( + progress, + LauncherUpdatePhase.Cancelled, + "Launcher update staging cancelled."); + throw; + } + catch (Exception ex) + { + Report( + progress, + LauncherUpdatePhase.Failed, + $"Launcher update staging failed: {ex.Message}"); + throw; + } + } + finally + { + _operationGate.Release(); + } + } + + public async Task RollbackClientAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + RefuseRunningSessions(); + Report( + progress, + LauncherUpdatePhase.RollingBack, + "Verifying and activating the previous client version..."); + ClientVersionResolution result = await _versions.RollbackAsync( + _rid, + cancellationToken) + .ConfigureAwait(false); + Report( + progress, + LauncherUpdatePhase.Completed, + $"Rolled back to client {result.Version}.", + 1, + 1); + return result; + } + finally + { + _operationGate.Release(); + } + } + + private void ValidateCheck(LauncherUpdateCheckResult check) + { + if (!string.Equals(check.Rid, _rid, StringComparison.Ordinal) + || !check.LauncherVersion.Equals(_launcherVersion)) + { + throw new LauncherUpdateException( + "The update check belongs to a different launcher runtime."); + } + + _ = check.Manifest.RequireClient(_rid); + _ = check.Manifest.RequireLauncher(_rid); + } + + private void RefuseRunningSessions() + { + if (_hasRunningSessions()) + { + throw new LauncherUpdateException( + "Stop every launcher session before installing or rolling back an update."); + } + } + + private static string BuildCheckStatus( + ReleaseManifest manifest, + LauncherVersion? installed, + bool clientAvailable, + bool launcherAvailable, + bool minimumSatisfied) + { + if (!minimumSatisfied) + { + return $"Release {manifest.Version} requires launcher " + + $"{manifest.MinimumLauncherVersion} or newer."; + } + + if (clientAvailable && launcherAvailable) + { + return $"Client and launcher {manifest.Version} are available."; + } + + if (clientAvailable) + { + return installed is null + ? $"Client {manifest.Version} is available for installation." + : $"Client update {installed} -> {manifest.Version} is available."; + } + + if (launcherAvailable) + { + return $"Launcher {manifest.Version} is available."; + } + + return "Client and launcher are up to date."; + } + + private static void Report( + IProgress? progress, + LauncherUpdatePhase phase, + string status, + long completed = 0, + long total = 0) => + progress?.Report(new LauncherUpdateProgress( + phase, + status, + completed, + total)); + + private sealed class ForwardProgress(Action callback) : IProgress + { + public void Report(T value) => callback(value); + } +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherVersion.cs b/src/AcDream.Launcher.Core/Updates/LauncherVersion.cs new file mode 100644 index 00000000..bf152c08 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherVersion.cs @@ -0,0 +1,199 @@ +using System.Diagnostics.CodeAnalysis; + +namespace AcDream.Launcher.Core.Updates; + +/// +/// Strict SemVer 2.0 value used by the release feed, client pointer, and +/// self-update plan. Numeric identifiers are compared as digit strings so a +/// maliciously large identifier cannot overflow a fixed-width integer. +/// +public sealed class LauncherVersion : IComparable, IEquatable +{ + private readonly string[] _core; + private readonly string[] _preRelease; + + private LauncherVersion( + string value, + string[] core, + string[] preRelease) + { + Value = value; + _core = core; + _preRelease = preRelease; + } + + public string Value { get; } + + public bool IsPreRelease => _preRelease.Length != 0; + + public static LauncherVersion Parse(string value) + { + if (!TryParse(value, out LauncherVersion? version)) + { + throw new FormatException($"'{value}' is not a strict SemVer 2.0 version."); + } + + return version; + } + + public static bool TryParse( + string? value, + [NotNullWhen(true)] out LauncherVersion? version) + { + version = null; + if (string.IsNullOrEmpty(value) + || value.Length > 128 + || !string.Equals(value, value.Trim(), StringComparison.Ordinal)) + { + return false; + } + + string precedence = value; + int plus = value.IndexOf('+', StringComparison.Ordinal); + if (plus >= 0) + { + if (plus == value.Length - 1 + || value.IndexOf('+', plus + 1) >= 0 + || !ValidIdentifiers(value[(plus + 1)..], numericLeadingZeroRule: false)) + { + return false; + } + + precedence = value[..plus]; + } + + string coreText = precedence; + string[] preRelease = []; + int dash = precedence.IndexOf('-', StringComparison.Ordinal); + if (dash >= 0) + { + if (dash == precedence.Length - 1 + || !ValidIdentifiers(precedence[(dash + 1)..], numericLeadingZeroRule: true)) + { + return false; + } + + coreText = precedence[..dash]; + preRelease = precedence[(dash + 1)..].Split('.'); + } + + string[] core = coreText.Split('.'); + if (core.Length != 3 || core.Any(part => !ValidCoreNumber(part))) + { + return false; + } + + version = new LauncherVersion(value, core, preRelease); + return true; + } + + public int CompareTo(LauncherVersion? other) + { + if (other is null) + { + return 1; + } + + for (int index = 0; index < _core.Length; index++) + { + int comparison = CompareNumeric(_core[index], other._core[index]); + if (comparison != 0) + { + return comparison; + } + } + + if (_preRelease.Length == 0 || other._preRelease.Length == 0) + { + return _preRelease.Length == other._preRelease.Length + ? 0 + : _preRelease.Length == 0 ? 1 : -1; + } + + int shared = Math.Min(_preRelease.Length, other._preRelease.Length); + for (int index = 0; index < shared; index++) + { + string left = _preRelease[index]; + string right = other._preRelease[index]; + bool leftNumeric = IsDigits(left); + bool rightNumeric = IsDigits(right); + int comparison = leftNumeric && rightNumeric + ? CompareNumeric(left, right) + : leftNumeric != rightNumeric + ? leftNumeric ? -1 : 1 + : string.Compare(left, right, StringComparison.Ordinal); + if (comparison != 0) + { + return comparison; + } + } + + return _preRelease.Length.CompareTo(other._preRelease.Length); + } + + public bool Equals(LauncherVersion? other) => + other is not null && CompareTo(other) == 0; + + public override bool Equals(object? obj) => Equals(obj as LauncherVersion); + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (string part in _core) + { + hash.Add(part, StringComparer.Ordinal); + } + + hash.Add(_preRelease.Length); + foreach (string part in _preRelease) + { + hash.Add(part, StringComparer.Ordinal); + } + + return hash.ToHashCode(); + } + + public override string ToString() => Value; + + public static bool operator >(LauncherVersion left, LauncherVersion right) => + left.CompareTo(right) > 0; + + public static bool operator <(LauncherVersion left, LauncherVersion right) => + left.CompareTo(right) < 0; + + public static bool operator >=(LauncherVersion left, LauncherVersion right) => + left.CompareTo(right) >= 0; + + public static bool operator <=(LauncherVersion left, LauncherVersion right) => + left.CompareTo(right) <= 0; + + private static bool ValidCoreNumber(string value) => + IsDigits(value) && (value.Length == 1 || value[0] != '0'); + + private static bool ValidIdentifiers(string value, bool numericLeadingZeroRule) + { + string[] identifiers = value.Split('.'); + return identifiers.All(identifier => + identifier.Length > 0 + && identifier.All(character => + character is >= '0' and <= '9' + or >= 'A' and <= 'Z' + or >= 'a' and <= 'z' + or '-') + && (!numericLeadingZeroRule + || !IsDigits(identifier) + || identifier.Length == 1 + || identifier[0] != '0')); + } + + private static bool IsDigits(string value) => + value.Length > 0 && value.All(character => character is >= '0' and <= '9'); + + private static int CompareNumeric(string left, string right) + { + int length = left.Length.CompareTo(right.Length); + return length != 0 + ? length + : string.Compare(left, right, StringComparison.Ordinal); + } +} 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/ReleaseManifest.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs new file mode 100644 index 00000000..5d922641 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs @@ -0,0 +1,37 @@ +namespace AcDream.Launcher.Core.Updates; + +public sealed record ReleaseArtifact(Uri Url, string Sha256, long Size); + +public sealed record ReleaseManifest( + LauncherVersion Version, + LauncherVersion MinimumLauncherVersion, + IReadOnlyDictionary Clients, + IReadOnlyDictionary Launchers) +{ + public const int CurrentSchemaVersion = 1; + + public ReleaseArtifact RequireClient(string rid) => + Clients.TryGetValue(rid, out ReleaseArtifact? artifact) + ? artifact + : throw new LauncherUpdateException( + $"Release {Version} has no client payload for RID '{rid}'."); + + public ReleaseArtifact RequireLauncher(string rid) => + Launchers.TryGetValue(rid, out ReleaseArtifact? artifact) + ? artifact + : throw new LauncherUpdateException( + $"Release {Version} has no launcher payload for RID '{rid}'."); +} + +public sealed class LauncherUpdateException : Exception +{ + public LauncherUpdateException(string message) + : base(message) + { + } + + public LauncherUpdateException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs new file mode 100644 index 00000000..a31625c1 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs @@ -0,0 +1,407 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Updates; + +public interface IReleaseManifestClient +{ + Task FetchAsync(CancellationToken cancellationToken = default); +} + +/// +/// 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 +{ + public const string GitHubOwner = "eriknihlen"; + 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"); + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + MaxDepth = 16, + }; + + private readonly HttpClient _httpClient; + private readonly Uri _manifestUri; + private readonly bool _allowLoopbackHttp; + + public ReleaseManifestClient(TimeSpan? timeout = null) + : this( + ProductionManifestUri, + allowLoopbackHttp: false, + CreateRedirectDisabledHandler(), + timeout) + { + } + + 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 + { + Uri current = _manifestUri; + var visited = new HashSet(StringComparer.Ordinal); + for (int redirectCount = 0;;) + { + RequireTransport(current, "manifest redirect", _allowLoopbackHttp); + if (!visited.Add(current.AbsoluteUri)) + { + throw new LauncherUpdateException( + "The release manifest redirect chain contains a loop."); + } + + 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."); + } + + 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) + { + throw; + } + catch (LauncherUpdateException) + { + throw; + } + catch (Exception ex) when (ex is HttpRequestException + or IOException + or JsonException + or NotSupportedException) + { + throw new LauncherUpdateException( + $"The release manifest could not be loaded: {ex.Message}", + ex); + } + } + + internal static ReleaseManifest Parse( + ReadOnlySpan utf8, + bool allowLoopbackHttpArtifacts = false) + { + try + { + using JsonDocument document = JsonDocument.Parse( + utf8.ToArray(), + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 16, + }); + RejectDuplicateProperties(document.RootElement, "$" ); + ManifestDocument? value = document.RootElement.Deserialize( + SerializerOptions); + return Validate(value, allowLoopbackHttpArtifacts); + } + catch (LauncherUpdateException) + { + throw; + } + catch (Exception ex) when (ex is JsonException + or FormatException + or InvalidOperationException) + { + throw new LauncherUpdateException( + $"The release manifest is invalid: {ex.Message}", + ex); + } + } + + public void Dispose() => _httpClient.Dispose(); + + internal static void RequireTransport( + Uri uri, + string description, + bool allowLoopbackHttp) + { + if (!uri.IsAbsoluteUri + || (uri.Scheme != Uri.UriSchemeHttps + && !(allowLoopbackHttp + && uri.Scheme == Uri.UriSchemeHttp + && uri.IsLoopback))) + { + throw new LauncherUpdateException( + $"The {description} URI must use HTTPS" + + (allowLoopbackHttp ? " (or fixture-only loopback HTTP)." : ".")); + } + } + + 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) + { + throw new LauncherUpdateException("The release manifest is empty."); + } + + if (document.SchemaVersion != ReleaseManifest.CurrentSchemaVersion) + { + throw new LauncherUpdateException( + $"Release manifest schema version {document.SchemaVersion} is not supported."); + } + + LauncherVersion version = LauncherVersion.Parse( + document.Version + ?? throw new LauncherUpdateException("The release version is missing.")); + LauncherVersion minimum = LauncherVersion.Parse( + document.MinimumLauncherVersion + ?? throw new LauncherUpdateException( + "The minimum launcher version is missing.")); + if (minimum > version) + { + throw new LauncherUpdateException( + "The minimum launcher version cannot exceed the release version."); + } + + IReadOnlyDictionary clients = ValidateArtifacts( + document.Clients, + "clients", + allowLoopbackHttpArtifacts); + IReadOnlyDictionary launchers = ValidateArtifacts( + document.Launchers, + "launchers", + allowLoopbackHttpArtifacts); + return new ReleaseManifest(version, minimum, clients, launchers); + } + + private static IReadOnlyDictionary ValidateArtifacts( + Dictionary? artifacts, + string field, + bool allowLoopbackHttpArtifacts) + { + if (artifacts is null || artifacts.Count == 0) + { + throw new LauncherUpdateException($"Manifest field '{field}' must not be empty."); + } + + var result = new Dictionary(StringComparer.Ordinal); + foreach ((string rid, ArtifactDocument value) in artifacts) + { + if (!LauncherRuntimeIdentity.IsValidRid(rid)) + { + throw new LauncherUpdateException( + $"Manifest field '{field}' contains invalid RID '{rid}'."); + } + + if (value is null) + { + throw new LauncherUpdateException( + $"Manifest payload '{field}.{rid}' is null."); + } + + if (!Uri.TryCreate(value.Url, UriKind.Absolute, out Uri? uri)) + { + throw new LauncherUpdateException( + $"Manifest payload '{field}.{rid}' has an invalid URL."); + } + + RequireTransport( + uri, + $"{field}.{rid} artifact", + allowLoopbackHttpArtifacts); + if (!IsSha256(value.Sha256)) + { + throw new LauncherUpdateException( + $"Manifest payload '{field}.{rid}' has an invalid SHA-256 digest."); + } + + if (value.Size <= 0 || value.Size > MaximumArtifactBytes) + { + throw new LauncherUpdateException( + $"Manifest payload '{field}.{rid}' has an invalid size."); + } + + result.Add( + rid, + new ReleaseArtifact(uri, value.Sha256!.ToLowerInvariant(), value.Size)); + } + + return result; + } + + 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) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new LauncherUpdateException( + $"Duplicate JSON property '{path}.{property.Name}' is not allowed."); + } + + RejectDuplicateProperties(property.Value, $"{path}.{property.Name}"); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + int index = 0; + foreach (JsonElement item in element.EnumerateArray()) + { + RejectDuplicateProperties(item, $"{path}[{index++}]"); + } + } + } + + private sealed class ManifestDocument + { + public int SchemaVersion { get; init; } + + public string? Version { get; init; } + + public string? MinimumLauncherVersion { get; init; } + + public Dictionary? Clients { get; init; } + + public Dictionary? Launchers { get; init; } + } + + private sealed class ArtifactDocument + { + public string? Url { get; init; } + + public string? Sha256 { get; init; } + + public long Size { get; init; } + } +} diff --git a/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs b/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs new file mode 100644 index 00000000..e7a39b5f --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs @@ -0,0 +1,469 @@ +using System.Buffers; +using System.IO.Compression; +using System.Security.Cryptography; + +namespace AcDream.Launcher.Core.Updates; + +public sealed record SafeZipExtractionLimits( + int MaximumEntries = 20_000, + long MaximumEntryBytes = 2L * 1024 * 1024 * 1024, + long MaximumTotalBytes = 8L * 1024 * 1024 * 1024, + double MaximumCompressionRatio = 200, + int MaximumRelativePathLength = 512); + +public sealed record ExtractedFileRecord( + string Path, + string Sha256, + long Size, + int UnixMode); + +/// +/// Portable ZIP extractor for release assets. The complete central-directory +/// shape is validated before the first output path is created. +/// +public sealed class SafeZipExtractor +{ + private const int BufferSize = 128 * 1024; + private const int UnixTypeMask = 0xF000; + private const int UnixRegularFile = 0x8000; + private const int UnixDirectory = 0x4000; + private const int UnixPermissionMask = 0x1FF; + private readonly SafeZipExtractionLimits _limits; + + public SafeZipExtractor(SafeZipExtractionLimits? limits = null) + { + _limits = limits ?? new SafeZipExtractionLimits(); + if (_limits.MaximumEntries <= 0 + || _limits.MaximumEntryBytes <= 0 + || _limits.MaximumTotalBytes <= 0 + || _limits.MaximumCompressionRatio <= 0 + || _limits.MaximumRelativePathLength <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(limits), + "ZIP extraction limits must all be positive."); + } + } + + public async Task> ExtractAsync( + string archivePath, + string destinationDirectory, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(archivePath); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory); + string archive = Path.GetFullPath(archivePath); + string destination = Path.GetFullPath(destinationDirectory); + + if (Directory.Exists(destination) + && Directory.EnumerateFileSystemEntries(destination).Any()) + { + throw new LauncherUpdateException( + "The ZIP extraction destination must be empty."); + } + + try + { + await using var stream = new FileStream( + archive, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false); + IReadOnlyList entries = ValidateArchive(zip); + + Directory.CreateDirectory(destination); + RejectReparsePoint(destination, "extraction root"); + foreach (string directory in entries + .SelectMany(entry => ParentPaths(entry.RelativePath)) + .Concat(entries.Where(entry => entry.IsDirectory) + .Select(entry => entry.RelativePath)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(path => path.Count(character => character == '/')) + .ThenBy(path => path, StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + string directoryPath = ResolveContained(destination, directory); + Directory.CreateDirectory(directoryPath); + RejectReparsePoint(directoryPath, $"directory '{directory}'"); + } + + var files = new List(); + long actualTotal = 0; + foreach (ValidatedEntry entry in entries.Where(entry => !entry.IsDirectory)) + { + cancellationToken.ThrowIfCancellationRequested(); + string outputPath = ResolveContained(destination, entry.RelativePath); + EnsureParentsAreDirectories(destination, entry.RelativePath); + await using Stream input = entry.Entry.Open(); + await using var output = new FileStream( + outputPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + BufferSize, + FileOptions.Asynchronous + | FileOptions.SequentialScan + | FileOptions.WriteThrough); + using IncrementalHash hash = IncrementalHash.CreateHash( + HashAlgorithmName.SHA256); + byte[] buffer = ArrayPool.Shared.Rent(BufferSize); + long actualEntry = 0; + try + { + while (true) + { + int read = await input.ReadAsync( + buffer.AsMemory(0, BufferSize), + cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + actualEntry = checked(actualEntry + read); + actualTotal = checked(actualTotal + read); + if (actualEntry > entry.Entry.Length + || actualEntry > _limits.MaximumEntryBytes + || actualTotal > _limits.MaximumTotalBytes) + { + throw new LauncherUpdateException( + $"ZIP entry '{entry.RelativePath}' exceeded its declared limits."); + } + + hash.AppendData(buffer, 0, read); + await output.WriteAsync( + buffer.AsMemory(0, read), + cancellationToken) + .ConfigureAwait(false); + } + + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + } + + if (actualEntry != entry.Entry.Length) + { + throw new LauncherUpdateException( + $"ZIP entry '{entry.RelativePath}' length changed while extracting."); + } + + int unixMode = entry.UnixMode & UnixPermissionMask; + if (OperatingSystem.IsLinux() && unixMode != 0) + { + File.SetUnixFileMode(outputPath, (UnixFileMode)unixMode); + } + + files.Add(new ExtractedFileRecord( + entry.RelativePath, + Convert.ToHexStringLower(hash.GetHashAndReset()), + actualEntry, + unixMode)); + } + + files.Sort((left, right) => string.Compare( + left.Path, + right.Path, + StringComparison.Ordinal)); + return files; + } + catch (OperationCanceledException) + { + TryDeleteDirectory(destination); + throw; + } + catch (LauncherUpdateException) + { + TryDeleteDirectory(destination); + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or InvalidDataException + or NotSupportedException + or CryptographicException) + { + TryDeleteDirectory(destination); + throw new LauncherUpdateException( + $"The release ZIP could not be extracted safely: {ex.Message}", + ex); + } + } + + private IReadOnlyList ValidateArchive(ZipArchive zip) + { + if (zip.Entries.Count == 0 || zip.Entries.Count > _limits.MaximumEntries) + { + throw new LauncherUpdateException( + $"ZIP entry count {zip.Entries.Count} is outside the allowed range."); + } + + var result = new List(zip.Entries.Count); + var explicitEntries = new HashSet(StringComparer.OrdinalIgnoreCase); + var nodes = new Dictionary(StringComparer.OrdinalIgnoreCase); + long totalLength = 0; + long totalCompressed = 0; + foreach (ZipArchiveEntry entry in zip.Entries) + { + string relative = NormalizeEntryPath(entry.FullName); + if (!explicitEntries.Add(relative)) + { + throw new LauncherUpdateException( + $"ZIP contains a duplicate/case-colliding entry '{relative}'."); + } + + int unixAttributes = entry.ExternalAttributes >> 16; + int unixType = unixAttributes & UnixTypeMask; + bool trailingDirectory = entry.FullName.EndsWith("/", StringComparison.Ordinal) + || entry.FullName.EndsWith("\\", StringComparison.Ordinal); + bool isDirectory = trailingDirectory || unixType == UnixDirectory; + if ((entry.ExternalAttributes & (int)FileAttributes.ReparsePoint) != 0 + || unixType is not (0 or UnixRegularFile or UnixDirectory) + || (unixType == UnixDirectory && !trailingDirectory) + || (isDirectory && (entry.Length != 0 || entry.CompressedLength != 0))) + { + throw new LauncherUpdateException( + $"ZIP entry '{relative}' is a symlink, reparse point, or unsupported type."); + } + + AddPathNodes(nodes, relative, isDirectory); + if (!isDirectory) + { + if (entry.Length < 0 + || entry.CompressedLength < 0 + || entry.Length > _limits.MaximumEntryBytes) + { + throw new LauncherUpdateException( + $"ZIP entry '{relative}' exceeds the per-file limit."); + } + + totalLength = checked(totalLength + entry.Length); + totalCompressed = checked(totalCompressed + entry.CompressedLength); + if (totalLength > _limits.MaximumTotalBytes + || IsRatioExceeded(entry.Length, entry.CompressedLength)) + { + throw new LauncherUpdateException( + $"ZIP entry '{relative}' exceeds extraction size/ratio limits."); + } + } + + result.Add(new ValidatedEntry(entry, relative, isDirectory, unixAttributes)); + } + + if (totalLength > 0 + && (totalCompressed == 0 || IsRatioExceeded(totalLength, totalCompressed))) + { + throw new LauncherUpdateException( + "ZIP aggregate compression ratio exceeds the allowed limit."); + } + + return result; + } + + private string NormalizeEntryPath(string name) + { + if (string.IsNullOrEmpty(name) + || name.IndexOf('\0') >= 0 + || name.Contains(':', StringComparison.Ordinal)) + { + throw new LauncherUpdateException("ZIP contains an empty, NUL, or ADS path."); + } + + string normalized = name.Replace('\\', '/'); + bool directory = normalized.EndsWith("/", StringComparison.Ordinal); + normalized = normalized.TrimEnd('/'); + if (normalized.Length == 0 + || normalized.Length > _limits.MaximumRelativePathLength + || normalized.StartsWith("/", StringComparison.Ordinal) + || Path.IsPathRooted(normalized)) + { + throw new LauncherUpdateException($"ZIP path '{name}' is rooted or too long."); + } + + string[] segments = normalized.Split('/'); + foreach (string segment in segments) + { + if (segment.Length == 0 + || segment is "." or ".." + || segment.EndsWith(' ') + || segment.EndsWith('.') + || segment.Any(character => + char.IsControl(character) + || character is '<' or '>' or '"' or '|' or '?' or '*') + || PortablePathRules.IsWindowsDeviceName(segment)) + { + throw new LauncherUpdateException( + $"ZIP path '{name}' contains an unsafe segment."); + } + } + + return string.Join('/', segments) + (directory ? "/" : string.Empty); + } + + private static void AddPathNodes( + Dictionary nodes, + string relative, + bool isDirectory) + { + string path = relative.TrimEnd('/'); + string[] segments = path.Split('/'); + string current = string.Empty; + for (int index = 0; index < segments.Length; index++) + { + current = current.Length == 0 + ? segments[index] + : current + "/" + segments[index]; + bool nodeIsDirectory = index < segments.Length - 1 || isDirectory; + if (nodes.TryGetValue(current, out PathNode? existing)) + { + if (!string.Equals(existing.Spelling, current, StringComparison.Ordinal) + || (!existing.IsDirectory || !nodeIsDirectory)) + { + throw new LauncherUpdateException( + $"ZIP path '{relative}' collides with '{existing.Spelling}'."); + } + + continue; + } + + nodes.Add(current, new PathNode(current, nodeIsDirectory)); + } + } + + private bool IsRatioExceeded(long expanded, long compressed) => + expanded > 0 + && (compressed <= 0 || expanded / (double)compressed > _limits.MaximumCompressionRatio); + + private static IEnumerable ParentPaths(string relative) + { + string path = relative.TrimEnd('/'); + int slash = path.IndexOf('/'); + while (slash >= 0) + { + yield return path[..slash]; + slash = path.IndexOf('/', slash + 1); + } + } + + private static string ResolveContained(string root, string relative) + { + string path = Path.GetFullPath( + Path.Combine(root, relative.TrimEnd('/').Replace('/', Path.DirectorySeparatorChar))); + string prefix = Path.EndsInDirectorySeparator(root) + ? root + : root + Path.DirectorySeparatorChar; + if (!path.StartsWith( + prefix, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) + { + throw new LauncherUpdateException( + $"ZIP path '{relative}' escaped the extraction directory."); + } + + return path; + } + + private static void EnsureParentsAreDirectories(string root, string relative) + { + foreach (string parent in ParentPaths(relative)) + { + string path = ResolveContained(root, parent); + if (!Directory.Exists(path)) + { + throw new LauncherUpdateException( + $"ZIP parent '{parent}' is not a directory."); + } + + RejectReparsePoint(path, $"directory '{parent}'"); + } + } + + private static void RejectReparsePoint(string path, string description) + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"The {description} is a reparse point."); + } + } + + internal static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + DeleteDirectoryWithoutFollowingReparsePoints(path); + } + } + catch + { + // The exact random staging name is reclaimed under the update lease. + } + } + + private static void DeleteDirectoryWithoutFollowingReparsePoints(string directory) + { + FileAttributes rootAttributes = File.GetAttributes(directory); + if ((rootAttributes & FileAttributes.ReparsePoint) != 0) + { + DeleteReparsePoint(directory); + return; + } + + foreach (string entry in Directory.EnumerateFileSystemEntries( + directory, + "*", + SearchOption.TopDirectoryOnly)) + { + FileAttributes attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + DeleteReparsePoint(entry); + } + else if ((attributes & FileAttributes.Directory) != 0) + { + DeleteDirectoryWithoutFollowingReparsePoints(entry); + } + else + { + File.Delete(entry); + } + } + + Directory.Delete(directory, recursive: false); + } + + private static void DeleteReparsePoint(string path) + { + try + { + File.Delete(path); + } + catch (UnauthorizedAccessException) + { + Directory.Delete(path, recursive: false); + } + catch (IOException) + { + Directory.Delete(path, recursive: false); + } + } + + private sealed record PathNode(string Spelling, bool IsDirectory); + + private sealed record ValidatedEntry( + ZipArchiveEntry Entry, + string RelativePath, + bool IsDirectory, + int UnixMode); +} diff --git a/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs new file mode 100644 index 00000000..1ea640fb --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs @@ -0,0 +1,140 @@ +namespace AcDream.Launcher.Core.Updates; + +/// +/// One portable OS-handle barrier shared by supervised sessions and held +/// exclusively by update/rollback/recovery transactions. File contents are +/// never authoritative. +/// +public sealed class UpdateSessionBarrier +{ + public const string LockFileName = ".update-session.lock"; + + private readonly string _lockPath; + + public UpdateSessionBarrier(string dataDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory); + _lockPath = Path.Combine( + Path.GetFullPath(dataDirectory), + "app", + LockFileName); + } + + public string LockPath => _lockPath; + + public SessionLease AcquireSession() + { + FileStream stream = Open(FileShare.ReadWrite, "A client update is in progress."); + return new SessionLease(stream); + } + + public ExclusiveLease AcquireExclusive() + { + FileStream stream = Open( + FileShare.None, + "A launcher session or another update transaction is running. " + + "Stop every launcher session before updating."); + 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) + { + Directory.CreateDirectory( + Path.GetDirectoryName(_lockPath) + ?? throw new InvalidOperationException( + "The update/session lock path has no parent directory.")); + try + { + return new FileStream( + _lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + share, + bufferSize: 1, + FileOptions.None); + } + catch (IOException ex) + { + throw new LauncherUpdateException(refusal, ex); + } + catch (UnauthorizedAccessException ex) + { + throw new LauncherUpdateException( + $"The update/session lease could not be opened: {ex.Message}", + ex); + } + } + + public sealed class SessionLease : IDisposable + { + private FileStream? _stream; + + internal SessionLease(FileStream stream) => _stream = stream; + + public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose(); + } + + public sealed class ExclusiveLease : IDisposable + { + private readonly UpdateSessionBarrier _owner; + private FileStream? _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 new file mode 100644 index 00000000..6589d102 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs @@ -0,0 +1,277 @@ +using System.Buffers; +using System.Net; +using System.Security.Cryptography; + +namespace AcDream.Launcher.Core.Updates; + +public sealed record ArtifactDownloadProgress(long BytesReceived, long TotalBytes) +{ + public double Percent => TotalBytes <= 0 + ? 0 + : Math.Clamp(BytesReceived * 100d / TotalBytes, 0, 100); +} + +public sealed record VerifiedArtifactDownload( + string FilePath, + long Size, + string Sha256); + +/// +/// Streams a bounded release asset directly to a caller-owned staging path, +/// computing SHA-256 during the write. A partial/cancelled/wrong artifact is +/// deleted before the call returns. +/// +public sealed class VerifiedArtifactDownloader +{ + private const int BufferSize = 128 * 1024; + private readonly HttpClient _httpClient; + + public VerifiedArtifactDownloader(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public async Task DownloadAsync( + ReleaseArtifact artifact, + string destinationPath, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(artifact); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + ReleaseManifestClient.RequireSecureOrLoopback(artifact.Url, "artifact"); + if (artifact.Size <= 0 + || artifact.Size > ReleaseManifestClient.MaximumArtifactBytes + || !ReleaseManifestClient.IsSha256(artifact.Sha256)) + { + throw new LauncherUpdateException("The requested artifact metadata is invalid."); + } + + string fullPath = Path.GetFullPath(destinationPath); + Directory.CreateDirectory( + Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException( + "The artifact staging path has no parent directory.")); + + bool ownsDestination = false; + try + { + using HttpResponseMessage response = await SendWithValidatedRedirectsAsync( + artifact.Url, + cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength is long contentLength + && contentLength != artifact.Size) + { + throw new LauncherUpdateException( + $"Artifact size header mismatch: expected {artifact.Size}, " + + $"received {contentLength}."); + } + + if (response.Content.Headers.ContentEncoding.Count != 0) + { + throw new LauncherUpdateException( + "Release artifact content encoding is not allowed."); + } + + await using Stream input = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + await using var output = new FileStream( + fullPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + BufferSize, + FileOptions.Asynchronous + | FileOptions.SequentialScan + | FileOptions.WriteThrough); + ownsDestination = true; + using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + byte[] buffer = ArrayPool.Shared.Rent(BufferSize); + long received = 0; + try + { + progress?.Report(new ArtifactDownloadProgress(0, artifact.Size)); + while (true) + { + int read = await input.ReadAsync( + buffer.AsMemory(0, BufferSize), + cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + received = checked(received + read); + if (received > artifact.Size) + { + throw new LauncherUpdateException( + $"Artifact exceeded its declared size of {artifact.Size} bytes."); + } + + hash.AppendData(buffer, 0, read); + await output.WriteAsync( + buffer.AsMemory(0, read), + cancellationToken) + .ConfigureAwait(false); + progress?.Report(new ArtifactDownloadProgress(received, artifact.Size)); + } + + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + } + + if (received != artifact.Size) + { + throw new LauncherUpdateException( + $"Artifact ended at {received} bytes; expected {artifact.Size}."); + } + + string actualSha256 = Convert.ToHexStringLower(hash.GetHashAndReset()); + if (!string.Equals( + actualSha256, + artifact.Sha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new LauncherUpdateException( + "Artifact SHA-256 does not match the release manifest."); + } + + return new VerifiedArtifactDownload(fullPath, received, actualSha256); + } + catch (OperationCanceledException) + { + if (ownsDestination) + { + TryDelete(fullPath); + } + + throw; + } + catch (LauncherUpdateException) + { + if (ownsDestination) + { + TryDelete(fullPath); + } + + throw; + } + catch (Exception ex) when (ex is HttpRequestException + or IOException + or UnauthorizedAccessException + or CryptographicException) + { + if (ownsDestination) + { + TryDelete(fullPath); + } + + throw new LauncherUpdateException( + $"The release artifact could not be downloaded: {ex.Message}", + ex); + } + } + + 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 + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // The exact random staging name is reclaimed by startup recovery. + } + } +} 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 2723ed1d..c42f0de0 100644 --- a/src/AcDream.Launcher/App.axaml.cs +++ b/src/AcDream.Launcher/App.axaml.cs @@ -1,7 +1,9 @@ +using System.Reflection; using AcDream.Launcher.Core.Installation; using AcDream.Launcher.Core.Launching; using AcDream.Launcher.Core.Orchestration; using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.Core.Updates; using AcDream.Launcher.ViewModels; using AcDream.Platform; using Avalonia; @@ -14,6 +16,7 @@ public sealed partial class App : Application { private LauncherOrchestrator? _orchestrator; private LauncherWindowViewModel? _viewModel; + private LauncherUpdateComposition? _updateComposition; public override void Initialize() => AvaloniaXamlLoader.Load(this); @@ -23,6 +26,7 @@ public sealed partial class App : Application { ApplicationPathSet paths = ApplicationPathSet.Resolve(); LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths); + string rid = LauncherRuntimeIdentity.DetectRid(); string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; var installer = new LauncherInstaller( paths, @@ -47,16 +51,27 @@ public sealed partial class App : Application $"Client content verification failed: {ex.Message}"); } + 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.FromDirectory(AppContext.BaseDirectory), + updates.Executables, verification.Record, - installationStatus: verification.Status); + installationStatus: verification.Status, + updateSessionBarrier: updates.Versions.Barrier); _viewModel = new LauncherWindowViewModel( _orchestrator, new AvaloniaUiDispatcher(), - installer); + installer, + updates.Updater); _viewModel.Initialize(); desktop.MainWindow = new MainWindow @@ -73,7 +88,23 @@ public sealed partial class App : Application { _viewModel?.Dispose(); _orchestrator?.Dispose(); + _updateComposition?.Dispose(); _viewModel = null; _orchestrator = null; + _updateComposition = null; + } + + private static LauncherVersion GetLauncherVersion() + { + string? informationalVersion = typeof(App).Assembly + .GetCustomAttribute()? + .InformationalVersion; + if (!LauncherVersion.TryParse(informationalVersion, out LauncherVersion? version)) + { + throw new InvalidOperationException( + $"Launcher informational version '{informationalVersion}' is not SemVer 2.0."); + } + + return version; } } 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/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml index 9af0408c..2f0a8515 100644 --- a/src/AcDream.Launcher/MainWindow.axaml +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -45,7 +45,7 @@