From 2d2a5b5046fb22c192b178c041e4db5ad9664fdd Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 22:09:34 +0200 Subject: [PATCH 1/3] feat(launcher): implement verified atomic updates --- docs/architecture/acdream-architecture.md | 12 +- docs/plans/2026-08-14-launcher-campaign.md | 125 +++ .../2026-08-14-launcher-campaign-design.md | 12 + .../Orchestration/LauncherExecutableSet.cs | 106 +- .../Orchestration/LauncherOrchestrator.cs | 35 +- .../Updates/AtomicJsonFile.cs | 84 ++ .../Updates/ClientVersionStore.cs | 973 ++++++++++++++++++ .../Updates/LauncherRuntimeIdentity.cs | 33 + .../Updates/LauncherSelfUpdateBootstrap.cs | 299 ++++++ .../Updates/LauncherSelfUpdateManager.cs | 786 ++++++++++++++ .../Updates/LauncherUpdater.cs | 457 ++++++++ .../Updates/LauncherVersion.cs | 199 ++++ .../Updates/ReleaseManifest.cs | 37 + .../Updates/ReleaseManifestClient.cs | 300 ++++++ .../Updates/SafeZipExtractor.cs | 482 +++++++++ .../Updates/UpdateSessionBarrier.cs | 85 ++ .../Updates/VerifiedArtifactDownloader.cs | 200 ++++ src/AcDream.Launcher/App.axaml.cs | 51 +- src/AcDream.Launcher/MainWindow.axaml | 77 +- src/AcDream.Launcher/MainWindow.axaml.cs | 2 +- src/AcDream.Launcher/Program.cs | 31 +- .../ViewModels/LauncherUpdateViewModel.cs | 520 ++++++++++ .../ViewModels/LauncherWindowViewModel.cs | 43 +- .../Program.cs | 25 + .../LauncherOrchestratorTests.cs | 27 +- .../Updates/ClientVersionStoreTests.cs | 207 ++++ .../Updates/LauncherSelfUpdateManagerTests.cs | 279 +++++ .../LauncherUpdaterIntegrationTests.cs | 211 ++++ .../Updates/ReleaseTransportTests.cs | 219 ++++ .../Updates/SafeZipExtractorTests.cs | 186 ++++ .../Updates/UpdateSessionBarrierTests.cs | 147 +++ .../Updates/UpdateTestSupport.cs | 270 +++++ .../LauncherUpdateViewModelTests.cs | 292 ++++++ .../LauncherWindowViewModelTests.cs | 4 +- 34 files changed, 6755 insertions(+), 61 deletions(-) create mode 100644 src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs create mode 100644 src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs create mode 100644 src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs create mode 100644 src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs create mode 100644 src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs create mode 100644 src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs create mode 100644 src/AcDream.Launcher.Core/Updates/LauncherVersion.cs create mode 100644 src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs create mode 100644 src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs create mode 100644 src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs create mode 100644 src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs create mode 100644 src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs create mode 100644 src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/ClientVersionStoreTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/LauncherUpdaterIntegrationTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/SafeZipExtractorTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/UpdateSessionBarrierTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/UpdateTestSupport.cs create mode 100644 tests/AcDream.Launcher.Tests/LauncherUpdateViewModelTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 2fcef60c..1d1f44de 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -329,11 +329,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 853e69fc..151d7c78 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -478,6 +478,131 @@ gate (user): clean-profile first-run against real DATs. 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 may inject a loopback HTTP URI, but production artifacts and redirects +must use HTTPS. `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 and pointer +temporaries are transaction-owned by exact names and are removed under the +update lease. A corrupt installed version is never silently selected; the +explicit one-step rollback swaps the two verified pointer versions. + +`DataDirectory/app/.update-session.lock` is the cross-process barrier. Each +supervised launcher activity holds a shared OS handle from before executable +resolution until terminal process observation; an update/rollback holds the +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`: + +```json +{ + "schemaVersion": 1, + "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 a next-start helper copied outside the target directory +atomically advances the plan to `applying` and fills `apply` with each path's +`hadOriginal` bit. It waits for the initiating launcher PID without invoking a +shell, moves originals into the transaction backup tree, then moves verified +staged files into place. It never opens a target with truncate/overwrite. On +success the plan becomes `awaitingConfirmation`; the new launcher confirms at +its first managed instruction, after which backup and plan cleanup is safe. An +`applying` plan is rolled back before retry, and failure to start/confirm the +new launcher restores every original (and removes every no-original target). +All plan paths are re-derived/contained under the pinned data root except the +target directory, which must equal the actual launcher base directory. + ## 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..6c4d8d07 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -303,6 +303,18 @@ preview would be a deliberate divergence we are NOT taking. - **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips are release assets; the launcher pins the repo/owner in its config. +The exact v1 manifest, extracted-version record, `current.json` activation +pointer, shared-session/exclusive-update OS lease, and durable self-update +plan are pinned in +`docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater +contracts (v1, BINDING)**. That section is normative: implementations reject +unknown/duplicate fields and unsupported versions, use strict SemVer 2.0 +precedence, verify bounded streamed downloads before safe ZIP extraction, and +derive all mutable staging/backup paths from the application data root. The +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/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs index ef4635c5..5fc2a858 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,28 @@ 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); + }); + } + + private ExecutablePaths RequireAvailable(LaunchMode mode) { LauncherCapability capability = GetAvailability(mode); if (!capability.IsAvailable) @@ -110,6 +157,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 +193,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..d430eaad 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 @@ -629,10 +634,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 +687,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 +742,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator finally { request.Password = null; + if (!hostStarted) + { + ReleaseUpdateSessionLease(request.Activity); + } } } @@ -735,6 +753,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator ManagedActivity activity, LauncherSessionState processState) { + UpdateSessionBarrier.SessionLease? sessionLease = null; try { lock (_gate) @@ -774,10 +793,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator { activity.Status = activity.HostTerminalStatus; } + sessionLease = activity.UpdateSessionLease; + activity.UpdateSessionLease = null; break; } } + sessionLease?.Dispose(); RaiseStateChanged(); } catch @@ -1192,6 +1214,15 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator activity.Supervisor.Dispose(); activity.Supervisor = null; } + + ReleaseUpdateSessionLease(activity); + } + + private static void ReleaseUpdateSessionLease(ManagedActivity activity) + { + UpdateSessionBarrier.SessionLease? lease = + Interlocked.Exchange(ref activity.UpdateSessionLease, null); + lease?.Dispose(); } private void ThrowIfDisposed() @@ -1252,6 +1283,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator public CancellationTokenSource? StartCancellation { get; set; } + public UpdateSessionBarrier.SessionLease? UpdateSessionLease; + 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..e14163b0 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs @@ -0,0 +1,973 @@ +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", + StringComparison.OrdinalIgnoreCase)) + .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)) + { + string suffix = Path.GetFileName(path)[".client-staging-".Length..]; + if (Guid.TryParseExact(suffix, "N", out _)) + { + SafeZipExtractor.TryDeleteDirectory(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 _)) + { + VerifiedArtifactDownloader.TryDelete(path); + } + } + } + + private void RequireOwnedStagingPath(string path) + { + string parent = Path.GetDirectoryName(path) ?? string.Empty; + string fileName = Path.GetFileName(path); + if (!PathsEqual(parent, AppDirectory) + || !fileName.StartsWith(".client-staging-", StringComparison.Ordinal) + || !Guid.TryParseExact(fileName[".client-staging-".Length..], "N", out _)) + { + 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 '*') + && !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 IsWindowsDeviceName(string segment) + { + string stem = segment.Split('.')[0]; + return stem.Equals("CON", StringComparison.OrdinalIgnoreCase) + || stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) + || stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) + || stem.Equals("NUL", StringComparison.OrdinalIgnoreCase) + || (stem.Length == 4 + && (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase) + || stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) + && stem[3] is >= '1' and <= '9'); + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + 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..6470244a --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs @@ -0,0 +1,299 @@ +using System.Diagnostics; + +namespace AcDream.Launcher.Core.Updates; + +public sealed record SelfUpdateStartupResult( + bool ShouldExit, + int ExitCode, + string[] RemainingArguments); + +/// +/// Process-level rename dance for launcher self-update. Every child argument +/// is passed through with +/// UseShellExecute=false; no path or PID is ever interpolated into a +/// shell command. +/// +public static class LauncherSelfUpdateBootstrap +{ + public const string HelperArgument = "--acdream-self-update-helper-v1"; + public const string ConfirmArgument = "--acdream-self-update-confirm-v1"; + private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30); + + 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], 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], + 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); + return new SelfUpdateStartupResult(false, 0, []); + } + + SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + if (plan is null) + { + return new SelfUpdateStartupResult(false, 0, args); + } + + if (!PathsEqual(plan.TargetDirectory, baseDirectory)) + { + throw new LauncherUpdateException( + "The pending self-update targets a different launcher directory."); + } + + if (plan.State == SelfUpdatePlanState.AwaitingConfirmation) + { + if (!manager.IsConfirmed(plan.TransactionId)) + { + await manager.ConfirmAsync( + plan.TransactionId, + baseDirectory, + executable, + cancellationToken) + .ConfigureAwait(false); + } + + await manager.CompleteConfirmedAsync( + plan.TransactionId, + baseDirectory, + cancellationToken) + .ConfigureAwait(false); + return new SelfUpdateStartupResult(false, 0, args); + } + + string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) + ? ".exe" + : string.Empty; + string expectedExecutable = ClientVersionStore.ResolveContained( + baseDirectory, + "acdream-launcher" + suffix); + if (!PathsEqual(executable, expectedExecutable)) + { + throw new LauncherUpdateException( + "Self-update can start only from the published acdream-launcher executable."); + } + + string helperPath = manager.GetHelperPath(plan.TransactionId); + Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); + VerifiedArtifactDownloader.TryDelete(helperPath); + File.Copy(executable, helperPath, overwrite: false); + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode( + helperPath, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute); + } + + var startInfo = new ProcessStartInfo(helperPath) + { + UseShellExecute = false, + WorkingDirectory = manager.GetTransactionDirectory(plan.TransactionId), + }; + startInfo.ArgumentList.Add(HelperArgument); + startInfo.ArgumentList.Add( + Environment.ProcessId.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add(baseDirectory); + startInfo.ArgumentList.Add(plan.TransactionId); + _ = Process.Start(startInfo) + ?? throw new LauncherUpdateException( + "The launcher self-update helper could not be started."); + return new SelfUpdateStartupResult(true, 0, []); + } + + private static async Task RunHelperAsync( + LauncherSelfUpdateManager manager, + int parentPid, + string targetDirectory, + string transactionId, + 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."); + } + + string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) + ? ".exe" + : string.Empty; + string launcherPath = ClientVersionStore.ResolveContained( + targetDirectory, + "acdream-launcher" + suffix); + var startInfo = new ProcessStartInfo(launcherPath) + { + UseShellExecute = false, + WorkingDirectory = Path.GetFullPath(targetDirectory), + }; + startInfo.ArgumentList.Add(ConfirmArgument); + startInfo.ArgumentList.Add(transactionId); + + Process? replacement = null; + UpdateSessionBarrier.ExclusiveLease? updateLease = null; + bool appliedByThisHelper = false; + try + { + await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false); + updateLease = manager.Barrier.AcquireExclusive(); + plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken) + .ConfigureAwait(false); + appliedByThisHelper = true; + replacement = Process.Start(startInfo) + ?? throw new LauncherUpdateException( + "The updated launcher could not be started."); + DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout; + while (!manager.IsConfirmed(transactionId)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline) + { + throw new LauncherUpdateException( + replacement.HasExited + ? $"The updated launcher exited with code {replacement.ExitCode} " + + "before confirming startup." + : "The updated launcher did not confirm startup in time."); + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + await manager.CompleteConfirmedAsync( + transactionId, + targetDirectory, + cancellationToken) + .ConfigureAwait(false); + return 0; + } + catch + { + if (replacement is { HasExited: false }) + { + replacement.Kill(entireProcessTree: true); + await replacement.WaitForExitAsync(CancellationToken.None) + .ConfigureAwait(false); + } + + try + { + if (appliedByThisHelper) + { + SelfUpdatePlan? pending = await manager.LoadPendingAsync( + CancellationToken.None) + .ConfigureAwait(false); + if (pending?.State == SelfUpdatePlanState.Applying) + { + _ = await manager.RecoverApplyingAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false); + } + else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation) + { + _ = await manager.RollbackAwaitingConfirmationAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false); + } + } + } + catch + { + // Do not start an executable from an ambiguous half-applied + // state. A subsequent startup replays the durable journal. + return 75; + } + + var restored = new ProcessStartInfo(launcherPath) + { + UseShellExecute = false, + WorkingDirectory = Path.GetFullPath(targetDirectory), + }; + _ = Process.Start(restored); + return 74; + } + finally + { + replacement?.Dispose(); + updateLease?.Dispose(); + } + } + + 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..1dddc952 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs @@ -0,0 +1,786 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Updates; + +public enum SelfUpdatePlanState +{ + Staged, + Applying, + AwaitingConfirmation, +} + +public sealed record SelfUpdateApplyEntry(string Path, bool HadOriginal); + +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 = 1; +} + +public sealed record SelfUpdateStageResult( + LauncherVersion Version, + string PendingPlanPath, + string Status); + +/// +/// Durable self-update transaction owner. It stages a verified launcher ZIP; +/// a separately copied helper performs move-only replacement after the parent +/// exits and can replay rollback after a crash at any file boundary. +/// +public sealed class LauncherSelfUpdateManager +{ + 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) }, + }; + + private readonly VerifiedArtifactDownloader _downloader; + private readonly SafeZipExtractor _extractor; + + public LauncherSelfUpdateManager( + ApplicationPathSet paths, + HttpClient httpClient, + SafeZipExtractor? extractor = null) + { + 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(); + } + + public string RootDirectory { get; } + + public string TransactionsDirectory { get; } + + public string PendingPlanPath { get; } + + public UpdateSessionBarrier Barrier { get; } + + public async Task StageAsync( + ReleaseManifest manifest, + string rid, + string targetDirectory, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + ReleaseArtifact artifact = manifest.RequireLauncher(rid); + return await StageAsync( + manifest.Version, + rid, + artifact, + targetDirectory, + progress, + cancellationToken) + .ConfigureAwait(false); + } + + 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)); + } + + string target = NormalizeTargetDirectory(targetDirectory); + Directory.CreateDirectory(RootDirectory); + Directory.CreateDirectory(TransactionsDirectory); + SelfUpdatePlan? existing = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + 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); + + 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; + } + } + + public async Task LoadPendingAsync( + CancellationToken cancellationToken = default) + { + if (!File.Exists(PendingPlanPath)) + { + CleanupOwnedResidue(keepTransactionId: null); + 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); + CleanupOwnedResidue(plan.TransactionId); + 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); + } + + await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false); + var apply = new List(plan.Files.Count); + foreach (InstalledFileRecord file in plan.Files) + { + string targetPath = ClientVersionStore.ResolveContained( + expectedTarget, + file.Path); + if (Directory.Exists(targetPath)) + { + throw new LauncherUpdateException( + $"Self-update target '{file.Path}' is unexpectedly a directory."); + } + + apply.Add(new SelfUpdateApplyEntry(file.Path, File.Exists(targetPath))); + } + + plan = plan with + { + State = SelfUpdatePlanState.Applying, + Apply = apply, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + + string payload = GetPayloadDirectory(plan.TransactionId); + string backup = GetBackupDirectory(plan.TransactionId); + try + { + foreach (SelfUpdateApplyEntry entry in plan.Apply) + { + cancellationToken.ThrowIfCancellationRequested(); + string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path); + string targetPath = ClientVersionStore.ResolveContained(expectedTarget, entry.Path); + string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); + EnsureSafeParent(expectedTarget, targetPath); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + if (entry.HadOriginal) + { + File.Move(targetPath, backupPath); + } + + File.Move(stagedPath, targetPath); + InstalledFileRecord file = plan.Files.Single(candidate => + string.Equals(candidate.Path, entry.Path, StringComparison.Ordinal)); + if (OperatingSystem.IsLinux() && file.UnixMode != 0) + { + File.SetUnixFileMode(targetPath, (UnixFileMode)file.UnixMode); + } + } + + 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; + } + + 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 suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) + ? ".exe" + : string.Empty; + string expectedExecutable = ClientVersionStore.ResolveContained( + expectedTarget, + "acdream-launcher" + suffix); + if (!PathsEqual(expectedExecutable, currentExecutablePath)) + { + throw new LauncherUpdateException( + "Only the newly installed launcher executable may confirm self-update."); + } + + await VerifyAppliedTargetsAsync(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."); + ValidatePlan(plan, NormalizeTargetDirectory(expectedTargetDirectory)); + 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(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 GetBackupDirectory(string transactionId) => + Path.Combine(GetTransactionDirectory(transactionId), "backup"); + + public string GetConfirmationPath(string transactionId) => + Path.Combine(GetTransactionDirectory(transactionId), "confirmed"); + + public string GetHelperPath(string transactionId) + { + string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + return Path.Combine( + GetTransactionDirectory(transactionId), + "acdream-self-update-helper" + suffix); + } + + 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 payload = GetPayloadDirectory(plan.TransactionId); + string backup = GetBackupDirectory(plan.TransactionId); + foreach (SelfUpdateApplyEntry entry in plan.Apply.Reverse()) + { + cancellationToken.ThrowIfCancellationRequested(); + string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path); + string targetPath = ClientVersionStore.ResolveContained( + plan.TargetDirectory, + entry.Path); + string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); + if (!File.Exists(stagedPath) && File.Exists(targetPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!); + File.Move(targetPath, stagedPath); + } + + if (entry.HadOriginal && File.Exists(backupPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.Move(backupPath, targetPath); + } + else if (!entry.HadOriginal && File.Exists(targetPath)) + { + File.Delete(targetPath); + } + } + + SafeZipExtractor.TryDeleteDirectory(backup); + plan = plan with + { + State = SelfUpdatePlanState.Staged, + Apply = null, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false); + return plan; + } + + 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) + { + cancellationToken.ThrowIfCancellationRequested(); + string path = ClientVersionStore.ResolveContained(payload, file.Path); + var info = new FileInfo(path); + if (!info.Exists || info.Length != file.Size) + { + throw new LauncherUpdateException( + $"Staged launcher file '{file.Path}' size is corrupt."); + } + + string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( + path, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new LauncherUpdateException( + $"Staged launcher file '{file.Path}' SHA-256 is corrupt."); + } + } + } + + private static async Task VerifyAppliedTargetsAsync( + SelfUpdatePlan plan, + string targetDirectory, + CancellationToken cancellationToken) + { + foreach (InstalledFileRecord file in plan.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + string path = ClientVersionStore.ResolveContained(targetDirectory, file.Path); + EnsureSafeParent(targetDirectory, path); + var info = new FileInfo(path); + if (!info.Exists + || (info.Attributes & FileAttributes.ReparsePoint) != 0 + || info.Length != file.Size) + { + throw new LauncherUpdateException( + $"Applied launcher file '{file.Path}' is missing, linked, or corrupt."); + } + + string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( + path, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new LauncherUpdateException( + $"Applied launcher file '{file.Path}' SHA-256 is corrupt."); + } + + if (OperatingSystem.IsLinux() + && ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode) + { + throw new LauncherUpdateException( + $"Applied launcher file '{file.Path}' mode is corrupt."); + } + } + } + + 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."); + } + + if (plan.Files is null || plan.Files.Count == 0) + { + throw new LauncherUpdateException("The self-update file list is empty."); + } + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + string? prior = null; + foreach (InstalledFileRecord file in plan.Files) + { + if (!ClientVersionStore.IsNormalizedRelative(file.Path) + || !paths.Add(file.Path) + || !ReleaseManifestClient.IsSha256(file.Sha256) + || file.Size < 0 + || file.UnixMode is < 0 or > 0x1FF + || (prior is not null + && string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0)) + { + throw new LauncherUpdateException( + "The self-update file list is invalid, duplicated, or unsorted."); + } + + prior = file.Path; + } + + string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) + ? ".exe" + : string.Empty; + if (!paths.Contains("acdream-launcher" + suffix)) + { + throw new LauncherUpdateException( + "The self-update plan lacks the launcher root 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) + { + if (plan.Apply.Count != plan.Files.Count + || !plan.Apply.Select(entry => entry.Path) + .SequenceEqual(plan.Files.Select(file => file.Path), StringComparer.Ordinal)) + { + throw new LauncherUpdateException( + "The self-update apply journal does not match the file list."); + } + } + + string transactionDirectory = GetTransactionDirectory(plan.TransactionId); + if (!IsContained(TransactionsDirectory, transactionDirectory)) + { + throw new LauncherUpdateException("The self-update transaction path escaped."); + } + } + + 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."); + } + + Directory.CreateDirectory(parent); + for (var directory = new DirectoryInfo(parent); + directory is not null && IsContained(root, directory.FullName); + directory = directory.Parent) + { + if ((directory.Attributes & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"Self-update target parent '{directory.FullName}' is a reparse point."); + } + + 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 CleanupOwnedResidue(string? keepTransactionId) + { + if (Directory.Exists(TransactionsDirectory)) + { + foreach (string directory in Directory.EnumerateDirectories( + TransactionsDirectory, + "*", + SearchOption.TopDirectoryOnly)) + { + string name = Path.GetFileName(directory); + if (name.Length == 32 + && Guid.TryParseExact(name, "N", out Guid transaction) + && string.Equals( + transaction.ToString("N"), + name, + StringComparison.Ordinal) + && !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); + string prefix = ".pending.json."; + string transaction = name[prefix.Length..^".tmp".Length]; + if (Guid.TryParseExact(transaction, "N", out _)) + { + VerifiedArtifactDownloader.TryDelete(temporary); + } + } + } +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs b/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs new file mode 100644 index 00000000..810ac57c --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs @@ -0,0 +1,457 @@ +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(); + using UpdateSessionBarrier.ExclusiveLease lease = + _versions.Barrier.AcquireExclusive(); + 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/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..6718ddcb --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs @@ -0,0 +1,300 @@ +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. HTTP is +/// accepted only for a loopback fixture; production and artifact URLs are +/// HTTPS-only. +/// +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 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 bool _ownsHttpClient; + private readonly Uri _manifestUri; + + public ReleaseManifestClient(HttpClient? httpClient = null, Uri? manifestUri = null) + { + _httpClient = httpClient ?? new HttpClient(); + _ownsHttpClient = httpClient is null; + _manifestUri = manifestUri ?? ProductionManifestUri; + RequireSecureOrLoopback(_manifestUri, "manifest"); + if (_ownsHttpClient) + { + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); + } + } + + public async Task FetchAsync( + CancellationToken cancellationToken = default) + { + try + { + using HttpResponseMessage response = await _httpClient.GetAsync( + _manifestUri, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + Uri finalUri = response.RequestMessage?.RequestUri ?? _manifestUri; + RequireSecureOrLoopback(finalUri, "manifest redirect"); + if (response.Content.Headers.ContentLength is long contentLength + && contentLength > MaximumManifestBytes) + { + 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()); + } + 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) + { + 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); + } + 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() + { + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + + internal static void RequireSecureOrLoopback(Uri uri, string description) + { + if (!uri.IsAbsoluteUri + || (uri.Scheme != Uri.UriSchemeHttps + && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback))) + { + throw new LauncherUpdateException( + $"The {description} URI must use HTTPS (loopback HTTP is test-only)."); + } + } + + private static ReleaseManifest Validate(ManifestDocument? document) + { + 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"); + IReadOnlyDictionary launchers = ValidateArtifacts( + document.Launchers, + "launchers"); + return new ReleaseManifest(version, minimum, clients, launchers); + } + + private static IReadOnlyDictionary ValidateArtifacts( + Dictionary? artifacts, + string field) + { + 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."); + } + + RequireSecureOrLoopback(uri, $"{field}.{rid} artifact"); + 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 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..6d2c77ee --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs @@ -0,0 +1,482 @@ +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 '*') + || 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."); + } + } + + private static bool IsWindowsDeviceName(string segment) + { + string stem = segment.Split('.')[0]; + return stem.Equals("CON", StringComparison.OrdinalIgnoreCase) + || stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) + || stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) + || stem.Equals("NUL", StringComparison.OrdinalIgnoreCase) + || (stem.Length == 4 + && (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase) + || stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) + && stem[3] is >= '1' and <= '9'); + } + + internal static void TryDeleteDirectory(string path) + { + try + { + 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..6c73ff35 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs @@ -0,0 +1,85 @@ +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(stream); + } + + 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 FileStream? _stream; + + internal ExclusiveLease(FileStream stream) => _stream = stream; + + 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..608207ff --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs @@ -0,0 +1,200 @@ +using System.Buffers; +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 _httpClient.GetAsync( + artifact.Url, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + ReleaseManifestClient.RequireSecureOrLoopback( + response.RequestMessage?.RequestUri ?? artifact.Url, + "artifact redirect"); + if (response.Content.Headers.ContentLength is long contentLength + && contentLength != artifact.Size) + { + 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); + } + } + + 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/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs index 2723ed1d..dae76b75 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,8 @@ public sealed partial class App : Application { private LauncherOrchestrator? _orchestrator; private LauncherWindowViewModel? _viewModel; + private HttpClient? _updateHttpClient; + private ReleaseManifestClient? _manifestClient; public override void Initialize() => AvaloniaXamlLoader.Load(this); @@ -23,6 +27,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 +52,38 @@ public sealed partial class App : Application $"Client content verification failed: {ex.Message}"); } + var clientVersions = new ClientVersionStore(paths); + _ = clientVersions.LoadAndRecoverAsync(rid) + .GetAwaiter() + .GetResult(); + _orchestrator = new LauncherOrchestrator( profiles, paths, - LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory), + LauncherExecutableSet.FromCurrentVersionStore(clientVersions), verification.Record, - installationStatus: verification.Status); + installationStatus: verification.Status, + updateSessionBarrier: clientVersions.Barrier); + _updateHttpClient = new HttpClient(); + _updateHttpClient.Timeout = TimeSpan.FromSeconds(15); + _updateHttpClient.DefaultRequestHeaders.UserAgent.ParseAdd( + "acdream-launcher/1"); + _manifestClient = new ReleaseManifestClient(_updateHttpClient); + var selfUpdates = new LauncherSelfUpdateManager(paths, _updateHttpClient); + var updater = new LauncherUpdater( + _manifestClient, + _updateHttpClient, + clientVersions, + selfUpdates, + GetLauncherVersion(), + rid, + AppContext.BaseDirectory, + () => _orchestrator.GetSnapshot().Sessions.Any(session => session.IsActive)); _viewModel = new LauncherWindowViewModel( _orchestrator, new AvaloniaUiDispatcher(), - installer); + installer, + updater); _viewModel.Initialize(); desktop.MainWindow = new MainWindow @@ -73,7 +100,25 @@ public sealed partial class App : Application { _viewModel?.Dispose(); _orchestrator?.Dispose(); + _manifestClient?.Dispose(); + _updateHttpClient?.Dispose(); _viewModel = null; _orchestrator = null; + _manifestClient = null; + _updateHttpClient = 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/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 @@