From 09d84387a8ee6f231b2a5b191d1928946a21062a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 23:41:55 +0200 Subject: [PATCH] fix(launcher): verify self-update rollback sources --- docs/plans/2026-08-14-launcher-campaign.md | 53 +- .../2026-08-14-launcher-campaign-design.md | 2 +- .../Updates/LauncherSelfUpdateBootstrap.cs | 37 +- .../Updates/LauncherSelfUpdateManager.cs | 685 ++++++++++++++++-- .../Updates/LauncherSelfUpdateManagerTests.cs | 55 +- .../Updates/LauncherSelfUpdateProcessTests.cs | 220 ++++++ 6 files changed, 949 insertions(+), 103 deletions(-) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 2ba8202d..180d320f 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -580,11 +580,11 @@ owns the lease and therefore releases after process death. Launcher self-update staging lives at `DataDirectory/launcher-update/transactions//` and the sole -durable authority is `DataDirectory/launcher-update/pending.json` (schema 2): +durable authority is `DataDirectory/launcher-update/pending.json` (schema 3): ```json { - "schemaVersion": 2, + "schemaVersion": 3, "transactionId": "0123456789abcdef0123456789abcdef", "state": "staged", "version": "1.2.3", @@ -608,18 +608,54 @@ owned metadata path, and obsolete paths from the previous ownership record: ```json [ - { "path": "acdream-launcher.exe", "operation": "install", "hadOriginal": true }, - { "path": "obsolete.dll", "operation": "remove", "hadOriginal": true } + { + "path": "acdream-launcher.exe", + "operation": "install", + "hadOriginal": true, + "priorSha256": "<64 hex characters>", + "priorSize": 123, + "priorUnixMode": 0, + "replacementSha256": "<64 hex characters>", + "replacementSize": 456, + "replacementUnixMode": 0 + }, + { + "path": "new-support.dat", + "operation": "install", + "hadOriginal": false, + "priorSha256": null, + "priorSize": null, + "priorUnixMode": null, + "replacementSha256": "<64 hex characters>", + "replacementSize": 456, + "replacementUnixMode": 0 + } ] ``` +Every `hadOriginal` entry persists the exact pre-mutation SHA-256, length, and +Linux mode bits; a no-original entry has all three prior fields null. Every +install entry likewise persists the verified replacement metadata, while a +remove entry has all three replacement fields null. The journal is invalid +unless those fields agree with `hadOriginal` and `operation`. + Existing targets are replaced with one same-filesystem atomic replace whose backup is also target-local. Previously absent noncanonical files use one same-filesystem rename; obsolete owned files use one rename into backup. The canonical launcher path therefore contains either the complete old file or the -complete new file at every durable crash boundary. Rollback reverses the same -operations atomically and is idempotent after a process/power loss. Linux mode -bits come from the verified incoming file. A helper that cannot immediately +complete new file at every durable crash boundary. Rollback first performs a +zero-mutation preflight of the complete target-local transaction and every +journal entry. It rejects reparse points, unsafe parents, unrecorded paths, +ambiguous file layouts, and any SHA-256/length/mode mismatch in a prior, +incoming, or discard file. Only a fully preflighted rollback may atomically +restore backups; newly created files move to target-local discard rather than +being deleted. The complete prior target set is then reverified before the +plan enters durable `rolledBack` state while retaining the journal. Retry is +allowed only after that prior set is reverified again and the plan returns to +`staged`. Thus rollback is atomic per file and idempotent after a process/power +loss. Any ambiguity preserves the applying plan and transaction evidence and +forbids launching the canonical path for manual recovery. Linux mode bits come +from the verified incoming file. A helper that cannot immediately acquire the exclusive update lease defers the staged plan and exits without restarting the old launcher, preventing restart loops. @@ -645,6 +681,9 @@ instruction, after which the helper releases its lease and the confirmed launcher reclaims plan, data-transaction, and target-local residue. An `applying` plan is rolled back before retry, and failure to start/confirm the new launcher restores every original (and removes every no-original target). +The helper restarts the restored canonical launcher only after a fresh complete +verification of the retained `rolledBack` journal; rollback corruption or an +unsafe backup/discard tree exits without starting either launcher. Reading `pending.json` never performs cleanup. Ordinary startup attempts the exclusive lease without waiting and skips update cleanup entirely when another session/staging transaction owns it. All plan paths are re-derived/contained 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 be403756..db477433 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -305,7 +305,7 @@ preview would be a deliberate divergence we are NOT taking. The exact v1 manifest, extracted-version record, `current.json` activation pointer and launcher ownership record, shared-session/exclusive-update OS -lease, and durable self-update plan schema 2 are pinned in +lease, and durable self-update plan schema 3 are pinned in `docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater contracts (v1, BINDING)**. That section is normative: implementations reject unknown/duplicate fields and unsupported versions, use strict SemVer 2.0 diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs index e72bea60..c37b51e8 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs @@ -240,12 +240,10 @@ public static class LauncherSelfUpdateBootstrap targetDirectory, lease); Process? replacement = null; - bool appliedByThisHelper = false; try { plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken) .ConfigureAwait(false); - appliedByThisHelper = true; replacement = Process.Start(startInfo) ?? throw new LauncherUpdateException( "The updated launcher could not be started."); @@ -283,26 +281,33 @@ public static class LauncherSelfUpdateBootstrap try { - if (appliedByThisHelper) + SelfUpdatePlan? pending = await manager.LoadPendingAsync( + CancellationToken.None) + .ConfigureAwait(false); + SelfUpdatePlan? rollbackReceipt = pending?.State switch { - SelfUpdatePlan? pending = await manager.LoadPendingAsync( - CancellationToken.None) - .ConfigureAwait(false); - if (pending?.State == SelfUpdatePlanState.Applying) - { - _ = await manager.RecoverApplyingAsync( + SelfUpdatePlanState.Applying => + await manager.RecoverApplyingAsync( targetDirectory, CancellationToken.None) - .ConfigureAwait(false); - } - else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation) - { - _ = await manager.RollbackAwaitingConfirmationAsync( + .ConfigureAwait(false), + SelfUpdatePlanState.AwaitingConfirmation => + await manager.RollbackAwaitingConfirmationAsync( targetDirectory, CancellationToken.None) - .ConfigureAwait(false); - } + .ConfigureAwait(false), + SelfUpdatePlanState.RolledBack => pending, + _ => null, + }; + if (rollbackReceipt?.State != SelfUpdatePlanState.RolledBack) + { + return 75; } + + await manager.VerifyRestoredPriorAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false); } catch { diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs index 71ef0064..4d36a2bb 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs @@ -9,6 +9,7 @@ public enum SelfUpdatePlanState Staged, Applying, AwaitingConfirmation, + RolledBack, } public enum SelfUpdateApplyOperation @@ -20,7 +21,13 @@ public enum SelfUpdateApplyOperation public sealed record SelfUpdateApplyEntry( string Path, SelfUpdateApplyOperation Operation, - bool HadOriginal); + bool HadOriginal, + string? PriorSha256, + long? PriorSize, + int? PriorUnixMode, + string? ReplacementSha256, + long? ReplacementSize, + int? ReplacementUnixMode); public sealed record SelfUpdatePlan( int SchemaVersion, @@ -34,7 +41,7 @@ public sealed record SelfUpdatePlan( IReadOnlyList Files, IReadOnlyList? Apply) { - public const int CurrentSchemaVersion = 2; + public const int CurrentSchemaVersion = 3; } public sealed record LauncherBinaryInstallRecord( @@ -94,6 +101,14 @@ public sealed class LauncherSelfUpdateManager private readonly SafeZipExtractor _extractor; private readonly Action? _applyObserver; + private sealed record JournalFileMetadata(string Sha256, long Size, int UnixMode); + + private sealed record RollbackAction( + SelfUpdateApplyEntry Entry, + string TargetPath, + string BackupPath, + string DiscardPath); + public LauncherSelfUpdateManager( ApplicationPathSet paths, HttpClient httpClient, @@ -310,17 +325,31 @@ public sealed class LauncherSelfUpdateManager .ConfigureAwait(false); } + if (plan.State == SelfUpdatePlanState.RolledBack) + { + await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + plan = plan with + { + State = SelfUpdatePlanState.Staged, + Apply = null, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + } + await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false); LauncherBinaryInstallRecord? previous = await ReadAndVerifyInstallRecordAsync( expectedTarget, plan.Rid, cancellationToken) .ConfigureAwait(false); - IReadOnlyList apply = BuildApplyJournal( - plan, - previous, - expectedTarget); - await PrepareTargetTransactionAsync(plan, apply, cancellationToken) + IReadOnlyList apply = await BuildApplyJournalAsync( + plan, + previous, + expectedTarget, + cancellationToken) + .ConfigureAwait(false); + apply = await PrepareTargetTransactionAsync(plan, apply, cancellationToken) .ConfigureAwait(false); plan = plan with { @@ -334,7 +363,8 @@ public sealed class LauncherSelfUpdateManager foreach (SelfUpdateApplyEntry entry in plan.Apply) { cancellationToken.ThrowIfCancellationRequested(); - ApplyEntry(plan, entry); + await ApplyEntryAsync(plan, entry, cancellationToken) + .ConfigureAwait(false); _applyObserver?.Invoke(new SelfUpdateApplyObservation( SelfUpdateApplyBoundary.AfterTargetMutation, entry.Path, @@ -367,6 +397,25 @@ public sealed class LauncherSelfUpdateManager : plan; } + internal async Task VerifyRestoredPriorAsync( + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no rolled-back self-update."); + ValidatePlan(plan, expectedTarget); + if (plan.State != SelfUpdatePlanState.RolledBack) + { + throw new LauncherUpdateException( + "The pending self-update has no verified rollback receipt."); + } + + await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + } + public async Task ConfirmAsync( string transactionId, string expectedTargetDirectory, @@ -497,10 +546,11 @@ public sealed class LauncherSelfUpdateManager "acdream-launcher" + (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); - private IReadOnlyList BuildApplyJournal( + private async Task> BuildApplyJournalAsync( SelfUpdatePlan plan, LauncherBinaryInstallRecord? previous, - string targetDirectory) + string targetDirectory, + CancellationToken cancellationToken) { var operations = new Dictionary( StringComparer.OrdinalIgnoreCase); @@ -527,13 +577,12 @@ public sealed class LauncherSelfUpdateManager { string targetPath = ClientVersionStore.ResolveContained(targetDirectory, path); EnsureSafeParent(targetDirectory, targetPath); - if (Directory.Exists(targetPath)) - { - throw new LauncherUpdateException( - $"Self-update target '{path}' is unexpectedly a directory."); - } - - bool hadOriginal = File.Exists(targetPath); + JournalFileMetadata? prior = await CaptureOptionalFileMetadataAsync( + targetPath, + $"Self-update target '{path}'", + cancellationToken) + .ConfigureAwait(false); + bool hadOriginal = prior is not null; if (operation == SelfUpdateApplyOperation.Remove && !hadOriginal) { throw new LauncherUpdateException( @@ -550,13 +599,22 @@ public sealed class LauncherSelfUpdateManager "The canonical launcher executable is missing before self-update."); } - result.Add(new SelfUpdateApplyEntry(path, operation, hadOriginal)); + result.Add(new SelfUpdateApplyEntry( + path, + operation, + hadOriginal, + prior?.Sha256, + prior?.Size, + prior?.UnixMode, + ReplacementSha256: null, + ReplacementSize: null, + ReplacementUnixMode: null)); } return result; } - private async Task PrepareTargetTransactionAsync( + private async Task> PrepareTargetTransactionAsync( SelfUpdatePlan plan, IReadOnlyList apply, CancellationToken cancellationToken) @@ -628,9 +686,36 @@ public sealed class LauncherSelfUpdateManager throw new LauncherUpdateException( "The target-local self-update incoming tree is incomplete."); } + + var completed = new List(apply.Count); + foreach (SelfUpdateApplyEntry entry in apply) + { + if (entry.Operation == SelfUpdateApplyOperation.Remove) + { + completed.Add(entry); + continue; + } + + JournalFileMetadata replacement = await CaptureRequiredFileMetadataAsync( + ClientVersionStore.ResolveContained(incoming, entry.Path), + $"Target-local incoming launcher file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + completed.Add(entry with + { + ReplacementSha256 = replacement.Sha256, + ReplacementSize = replacement.Size, + ReplacementUnixMode = replacement.UnixMode, + }); + } + + return completed; } - private void ApplyEntry(SelfUpdatePlan plan, SelfUpdateApplyEntry entry) + private async Task ApplyEntryAsync( + SelfUpdatePlan plan, + SelfUpdateApplyEntry entry, + CancellationToken cancellationToken) { string swap = GetTargetTransactionDirectory(plan); string incoming = Path.Combine(swap, "incoming"); @@ -639,27 +724,33 @@ public sealed class LauncherSelfUpdateManager plan.TargetDirectory, entry.Path); string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); - EnsureSafeParent(plan.TargetDirectory, targetPath); - Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path); + ClientVersionStore.RejectReparseTree(swap); + EnsureExistingParentsSafe(plan.TargetDirectory, targetPath); + EnsureExistingParentsSafe(swap, incomingPath); + EnsureExistingParentsSafe(swap, backupPath); + EnsurePathMissing(backupPath, $"Self-update backup '{entry.Path}'"); + + if (entry.HadOriginal) + { + await VerifyPriorFileAsync(entry, targetPath, cancellationToken) + .ConfigureAwait(false); + } + else + { + EnsurePathMissing(targetPath, $"Self-update target '{entry.Path}'"); + } if (entry.Operation == SelfUpdateApplyOperation.Remove) { - if (!entry.HadOriginal || !File.Exists(targetPath)) - { - throw new LauncherUpdateException( - $"Owned obsolete launcher file '{entry.Path}' vanished during apply."); - } - + EnsureSafeParent(swap, backupPath); File.Move(targetPath, backupPath); return; } - string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path); - if (!File.Exists(incomingPath)) - { - throw new LauncherUpdateException( - $"Incoming launcher file '{entry.Path}' is missing."); - } + await VerifyReplacementFileAsync(entry, incomingPath, cancellationToken) + .ConfigureAwait(false); + EnsureSafeParent(swap, backupPath); if (entry.HadOriginal) { @@ -681,67 +772,470 @@ public sealed class LauncherSelfUpdateManager } string swap = GetTargetTransactionDirectory(plan); + IReadOnlyList actions = await PreflightRollbackAsync( + plan, + cancellationToken) + .ConfigureAwait(false); + foreach (RollbackAction action in actions) + { + cancellationToken.ThrowIfCancellationRequested(); + ClientVersionStore.RejectReparseTree(swap); + EnsureExistingParentsSafe(plan.TargetDirectory, action.TargetPath); + EnsureExistingParentsSafe(swap, action.BackupPath); + EnsureExistingParentsSafe(swap, action.DiscardPath); + if (action.Entry.Operation == SelfUpdateApplyOperation.Remove) + { + EnsureSafeParent(plan.TargetDirectory, action.TargetPath); + File.Move(action.BackupPath, action.TargetPath); + continue; + } + + EnsureSafeParent(swap, action.DiscardPath); + if (action.Entry.HadOriginal) + { + File.Replace( + action.BackupPath, + action.TargetPath, + action.DiscardPath, + ignoreMetadataErrors: true); + } + else + { + File.Move(action.TargetPath, action.DiscardPath); + } + } + + await VerifyRestoredPriorAsync(plan, plan.TargetDirectory, cancellationToken) + .ConfigureAwait(false); + plan = plan with + { + State = SelfUpdatePlanState.RolledBack, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + SafeZipExtractor.TryDeleteDirectory(swap); + return plan; + } + + private async Task> PreflightRollbackAsync( + SelfUpdatePlan plan, + CancellationToken cancellationToken) + { + string swap = GetTargetTransactionDirectory(plan); + if (!Directory.Exists(swap)) + { + throw new LauncherUpdateException( + "The target-local self-update rollback transaction is missing."); + } + + ClientVersionStore.RejectReparseTree(swap); + ValidateRollbackTree(plan, swap); + string incoming = Path.Combine(swap, "incoming"); string backup = Path.Combine(swap, "backup"); string discard = Path.Combine(swap, "rollback-discard"); - foreach (SelfUpdateApplyEntry entry in plan.Apply.Reverse()) + var actions = new List(); + foreach (SelfUpdateApplyEntry entry in plan.Apply!.Reverse()) { cancellationToken.ThrowIfCancellationRequested(); string targetPath = ClientVersionStore.ResolveContained( plan.TargetDirectory, entry.Path); + string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path); string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); + string discardPath = ClientVersionStore.ResolveContained(discard, entry.Path); + EnsureExistingParentsSafe(plan.TargetDirectory, targetPath); + EnsureExistingParentsSafe(swap, incomingPath); + EnsureExistingParentsSafe(swap, backupPath); + EnsureExistingParentsSafe(swap, discardPath); + + JournalFileMetadata? target = await CaptureOptionalFileMetadataAsync( + targetPath, + $"Rollback target '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + JournalFileMetadata? incomingFile = await CaptureOptionalFileMetadataAsync( + incomingPath, + $"Rollback incoming file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + JournalFileMetadata? backupFile = await CaptureOptionalFileMetadataAsync( + backupPath, + $"Rollback backup file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + JournalFileMetadata? discardedFile = await CaptureOptionalFileMetadataAsync( + discardPath, + $"Rollback discard file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + if (entry.Operation == SelfUpdateApplyOperation.Remove) { - if (File.Exists(backupPath)) + RequireMissing(incomingFile, entry.Path, "incoming"); + RequireMissing(discardedFile, entry.Path, "discard"); + if (backupFile is not null && target is null) { - if (File.Exists(targetPath) || Directory.Exists(targetPath)) - { - throw new LauncherUpdateException( - $"Obsolete launcher rollback target '{entry.Path}' was recreated."); - } - - Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); - File.Move(backupPath, targetPath); + RequirePriorMetadata(entry, backupFile, "rollback backup"); + actions.Add(new RollbackAction( + entry, + targetPath, + backupPath, + discardPath)); + continue; } + if (backupFile is null && target is not null) + { + RequirePriorMetadata(entry, target, "restored rollback target"); + continue; + } + + throw AmbiguousRollback(entry.Path); + } + + if (entry.HadOriginal) + { + if (backupFile is not null + && target is not null + && incomingFile is null + && discardedFile is null) + { + RequirePriorMetadata(entry, backupFile, "rollback backup"); + RequireReplacementMetadata(entry, target, "applied rollback target"); + actions.Add(new RollbackAction( + entry, + targetPath, + backupPath, + discardPath)); + continue; + } + + if (backupFile is null && target is not null) + { + RequirePriorMetadata(entry, target, "restored rollback target"); + if (incomingFile is not null && discardedFile is null) + { + RequireReplacementMetadata( + entry, + incomingFile, + "unapplied rollback incoming file"); + continue; + } + + if (incomingFile is null && discardedFile is not null) + { + RequireReplacementMetadata( + entry, + discardedFile, + "completed rollback discard"); + continue; + } + } + + throw AmbiguousRollback(entry.Path); + } + + RequireMissing(backupFile, entry.Path, "backup"); + if (target is not null + && incomingFile is null + && discardedFile is null) + { + RequireReplacementMetadata(entry, target, "applied rollback target"); + actions.Add(new RollbackAction( + entry, + targetPath, + backupPath, + discardPath)); continue; } - if (entry.HadOriginal && File.Exists(backupPath)) + if (target is null && incomingFile is not null && discardedFile is null) { - Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); - if (File.Exists(targetPath)) - { - string discardPath = ClientVersionStore.ResolveContained( - discard, - entry.Path); - Directory.CreateDirectory(Path.GetDirectoryName(discardPath)!); - File.Replace( - backupPath, - targetPath, - discardPath, - ignoreMetadataErrors: true); - } - else - { - File.Move(backupPath, targetPath); - } + RequireReplacementMetadata( + entry, + incomingFile, + "unapplied rollback incoming file"); + continue; } - else if (!entry.HadOriginal && File.Exists(targetPath)) + + if (target is null && incomingFile is null && discardedFile is not null) { - File.Delete(targetPath); + RequireReplacementMetadata( + entry, + discardedFile, + "completed rollback discard"); + continue; + } + + throw AmbiguousRollback(entry.Path); + } + + return actions; + } + + private static void ValidateRollbackTree(SelfUpdatePlan plan, string swap) + { + RequireTransactionContainer(Path.Combine(swap, "incoming"), required: true); + RequireTransactionContainer(Path.Combine(swap, "backup"), required: false); + RequireTransactionContainer( + Path.Combine(swap, "rollback-discard"), + required: false); + var allowed = new HashSet(StringComparer.Ordinal) + { + "incoming", + }; + foreach (SelfUpdateApplyEntry entry in plan.Apply!) + { + if (entry.Operation == SelfUpdateApplyOperation.Install) + { + AddAllowedTreePath(allowed, "incoming", entry.Path); + AddAllowedTreePath(allowed, "rollback-discard", entry.Path); + } + + if (entry.HadOriginal) + { + AddAllowedTreePath(allowed, "backup", entry.Path); } } - SafeZipExtractor.TryDeleteDirectory(swap); - plan = plan with + foreach (string path in Directory.EnumerateFileSystemEntries( + swap, + "*", + SearchOption.AllDirectories)) { - State = SelfUpdatePlanState.Staged, - Apply = null, - }; - await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); - await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false); - return plan; + string relative = Path.GetRelativePath(swap, path).Replace('\\', '/'); + if (!allowed.Contains(relative)) + { + throw new LauncherUpdateException( + $"The rollback transaction contains unrecorded path '{relative}'."); + } + } + } + + private static void RequireTransactionContainer(string path, bool required) + { + try + { + FileAttributes attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.Directory) == 0 + || (attributes & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"Rollback container '{Path.GetFileName(path)}' is not a safe directory."); + } + } + catch (FileNotFoundException) when (!required) + { + } + catch (DirectoryNotFoundException) when (!required) + { + } + catch (FileNotFoundException) + { + throw new LauncherUpdateException( + $"Required rollback container '{Path.GetFileName(path)}' is missing."); + } + catch (DirectoryNotFoundException) + { + throw new LauncherUpdateException( + $"Required rollback container '{Path.GetFileName(path)}' is missing."); + } + } + + private static void AddAllowedTreePath( + HashSet allowed, + string container, + string relativePath) + { + allowed.Add(container); + string current = container; + foreach (string segment in relativePath.Split('/')) + { + current += "/" + segment; + allowed.Add(current); + } + } + + private static LauncherUpdateException AmbiguousRollback(string path) => new( + $"Rollback state for '{path}' is corrupt or ambiguous; transaction evidence was preserved."); + + private static void RequireMissing( + JournalFileMetadata? metadata, + string path, + string location) + { + if (metadata is not null) + { + throw new LauncherUpdateException( + $"Rollback {location} for '{path}' is unexpected; transaction evidence was preserved."); + } + } + + private static async Task VerifyRestoredPriorAsync( + SelfUpdatePlan plan, + string targetDirectory, + CancellationToken cancellationToken) + { + if (plan.Apply is null) + { + throw new LauncherUpdateException("The rollback receipt is missing its apply journal."); + } + + foreach (SelfUpdateApplyEntry entry in plan.Apply) + { + cancellationToken.ThrowIfCancellationRequested(); + string targetPath = ClientVersionStore.ResolveContained(targetDirectory, entry.Path); + EnsureExistingParentsSafe(targetDirectory, targetPath); + if (entry.HadOriginal) + { + await VerifyPriorFileAsync(entry, targetPath, cancellationToken) + .ConfigureAwait(false); + } + else + { + EnsurePathMissing(targetPath, $"Restored rollback target '{entry.Path}'"); + } + } + } + + private static async Task VerifyPriorFileAsync( + SelfUpdateApplyEntry entry, + string path, + CancellationToken cancellationToken) + { + JournalFileMetadata actual = await CaptureRequiredFileMetadataAsync( + path, + $"Prior launcher file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + RequirePriorMetadata(entry, actual, "prior launcher file"); + } + + private static async Task VerifyReplacementFileAsync( + SelfUpdateApplyEntry entry, + string path, + CancellationToken cancellationToken) + { + JournalFileMetadata actual = await CaptureRequiredFileMetadataAsync( + path, + $"Replacement launcher file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + RequireReplacementMetadata(entry, actual, "replacement launcher file"); + } + + private static void RequirePriorMetadata( + SelfUpdateApplyEntry entry, + JournalFileMetadata actual, + string description) => + RequireMetadata( + entry.Path, + description, + actual, + entry.PriorSha256, + entry.PriorSize, + entry.PriorUnixMode); + + private static void RequireReplacementMetadata( + SelfUpdateApplyEntry entry, + JournalFileMetadata actual, + string description) => + RequireMetadata( + entry.Path, + description, + actual, + entry.ReplacementSha256, + entry.ReplacementSize, + entry.ReplacementUnixMode); + + private static void RequireMetadata( + string path, + string description, + JournalFileMetadata actual, + string? expectedSha256, + long? expectedSize, + int? expectedUnixMode) + { + if (!string.Equals(actual.Sha256, expectedSha256, StringComparison.OrdinalIgnoreCase) + || actual.Size != expectedSize + || actual.UnixMode != expectedUnixMode) + { + throw new LauncherUpdateException( + $"The {description} '{path}' failed its rollback integrity check; " + + "transaction evidence was preserved."); + } + } + + private static async Task CaptureRequiredFileMetadataAsync( + string path, + string description, + CancellationToken cancellationToken) => + await CaptureOptionalFileMetadataAsync(path, description, cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException($"{description} is missing."); + + private static async Task CaptureOptionalFileMetadataAsync( + string path, + string description, + CancellationToken cancellationToken) + { + FileAttributes attributes; + try + { + attributes = File.GetAttributes(path); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + { + throw new LauncherUpdateException($"{description} is a directory or reparse point."); + } + + var before = new FileInfo(path); + long size = before.Length; + int unixMode = OperatingSystem.IsLinux() + ? (int)File.GetUnixFileMode(path) & 0x1FF + : 0; + string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( + path, + cancellationToken) + .ConfigureAwait(false); + var after = new FileInfo(path); + after.Refresh(); + if (!after.Exists + || (after.Attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 + || after.Length != size + || (OperatingSystem.IsLinux() + && ((int)File.GetUnixFileMode(path) & 0x1FF) != unixMode)) + { + throw new LauncherUpdateException($"{description} changed while it was measured."); + } + + return new JournalFileMetadata(sha256, size, unixMode); + } + + private static void EnsurePathMissing(string path, string description) + { + try + { + _ = File.GetAttributes(path); + } + catch (FileNotFoundException) + { + return; + } + catch (DirectoryNotFoundException) + { + return; + } + + throw new LauncherUpdateException($"{description} already exists."); } private async Task VerifyPayloadAsync( @@ -968,6 +1462,22 @@ public sealed class LauncherSelfUpdateManager || !Enum.IsDefined(entry.Operation) || (entry.Operation == SelfUpdateApplyOperation.Remove && !entry.HadOriginal) + || entry.HadOriginal != ( + ReleaseManifestClient.IsSha256(entry.PriorSha256) + && entry.PriorSize is >= 0 + && entry.PriorUnixMode is >= 0 and <= 0x1FF) + || entry.HadOriginal == ( + entry.PriorSha256 is null + && entry.PriorSize is null + && entry.PriorUnixMode is null) + || (entry.Operation == SelfUpdateApplyOperation.Install) != ( + ReleaseManifestClient.IsSha256(entry.ReplacementSha256) + && entry.ReplacementSize is >= 0 + && entry.ReplacementUnixMode is >= 0 and <= 0x1FF) + || (entry.Operation == SelfUpdateApplyOperation.Install) == ( + entry.ReplacementSha256 is null + && entry.ReplacementSize is null + && entry.ReplacementUnixMode is null) || (prior is not null && string.Compare(prior, entry.Path, StringComparison.Ordinal) >= 0)) { @@ -1118,15 +1628,42 @@ public sealed class LauncherSelfUpdateManager throw new LauncherUpdateException("A self-update target has no parent."); } + EnsureExistingParentsSafe(root, filePath); Directory.CreateDirectory(parent); + EnsureExistingParentsSafe(root, filePath); + } + + private static void EnsureExistingParentsSafe(string root, string filePath) + { + string? parent = Path.GetDirectoryName(filePath); + if (parent is null) + { + throw new LauncherUpdateException("A self-update target has no parent."); + } + for (var directory = new DirectoryInfo(parent); directory is not null && IsContained(root, directory.FullName); directory = directory.Parent) { - if ((directory.Attributes & FileAttributes.ReparsePoint) != 0) + FileAttributes attributes; + try + { + attributes = File.GetAttributes(directory.FullName); + } + catch (FileNotFoundException) + { + continue; + } + catch (DirectoryNotFoundException) + { + continue; + } + + if ((attributes & FileAttributes.Directory) == 0 + || (attributes & FileAttributes.ReparsePoint) != 0) { throw new LauncherUpdateException( - $"Self-update target parent '{directory.FullName}' is a reparse point."); + $"Self-update target parent '{directory.FullName}' is not a safe directory."); } if (PathsEqual(directory.FullName, root)) diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs index fe4f0621..e2c9b4b3 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs @@ -1,4 +1,5 @@ using AcDream.Launcher.Core.Updates; +using System.Text.Json.Nodes; namespace AcDream.Launcher.Core.Tests.Updates; @@ -103,8 +104,16 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable SelfUpdatePlan rolledBack = await harness.Manager .RollbackAwaitingConfirmationAsync(harness.Target); - Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State); - Assert.Null(rolledBack.Apply); + Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State); + Assert.All(rolledBack.Apply!, entry => + { + if (entry.HadOriginal) + { + Assert.Matches("^[0-9a-f]{64}$", entry.PriorSha256!); + Assert.NotNull(entry.PriorSize); + Assert.NotNull(entry.PriorUnixMode); + } + }); Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath)); SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target); @@ -150,7 +159,7 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable SelfUpdatePlan rolledBack = await harness.Manager .RollbackAwaitingConfirmationAsync(harness.Target); - Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State); + Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State); Assert.Equal("launcher-v2", await File.ReadAllTextAsync(harness.LauncherPath)); Assert.Equal("support-v2", await File.ReadAllTextAsync(harness.SupportPath)); Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath)); @@ -170,7 +179,7 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable } [Fact] - public async Task ApplyFailpointAfterCanonicalAtomicReplaceRollsBackToStagedState() + public async Task ApplyFailpointAfterCanonicalAtomicReplaceLeavesVerifiedRollbackReceipt() { using var harness = new Harness(_root); _ = await harness.StageAsync(); @@ -193,7 +202,8 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable await harness.Manager.LoadPendingAsync()); Assert.Equal("failpoint", failure.Message); - Assert.Equal(SelfUpdatePlanState.Staged, recovered.State); + Assert.Equal(SelfUpdatePlanState.RolledBack, recovered.State); + await harness.Manager.VerifyRestoredPriorAsync(harness.Target); Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath)); Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine( @@ -201,6 +211,41 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable harness.LauncherName))); } + [Fact] + public async Task ConditionalPriorIntegrityFieldsAreStrictAndFailClosed() + { + using var harness = new Harness(_root); + _ = await harness.StageAsync(); + SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target); + SelfUpdateApplyEntry canonical = Assert.Single( + applied.Apply!, + entry => entry.Path == harness.LauncherName); + Assert.True(canonical.HadOriginal); + Assert.Matches("^[0-9a-f]{64}$", canonical.PriorSha256!); + Assert.NotNull(canonical.PriorSize); + Assert.NotNull(canonical.PriorUnixMode); + Assert.Matches("^[0-9a-f]{64}$", canonical.ReplacementSha256!); + + JsonObject document = Assert.IsType(JsonNode.Parse( + await File.ReadAllTextAsync(harness.Manager.PendingPlanPath))); + JsonArray apply = Assert.IsType(document["apply"]); + JsonObject canonicalNode = Assert.IsType(apply.Single(node => + string.Equals( + node?["path"]?.GetValue(), + harness.LauncherName, + StringComparison.Ordinal))); + canonicalNode["priorSha256"] = null; + await File.WriteAllTextAsync( + harness.Manager.PendingPlanPath, + document.ToJsonString()); + + await Assert.ThrowsAsync(() => + harness.Manager.LoadPendingAsync()); + Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); + Assert.True(Directory.Exists( + harness.Manager.GetTargetTransactionDirectory(applied))); + } + [Fact] public async Task CorruptPayloadWrongTargetAndUnknownPlanFieldFailClosed() { diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs index 14731094..1c67efc3 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs @@ -125,6 +125,116 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable } } + [Fact] + public async Task CorruptBackupAfterCanonicalCrashNeverLaunchesAndPreservesEvidence() + { + CrashedUpdate crashed = await PrepareKilledAfterCanonicalReplaceAsync(); + string backupPath = Path.Combine( + crashed.Manager.GetTargetTransactionDirectory(crashed.Plan), + "backup", + crashed.Prepared.CanonicalName); + Assert.True(File.Exists(backupPath)); + await File.WriteAllTextAsync(backupPath, "tampered rollback backup"); + string tamperedHash = await FileIntegrity.ComputeSha256HexAsync(backupPath); + string launched = Path.Combine(_root, "corrupt-backup-launched"); + string helperPidPath = Path.Combine(_root, "corrupt-backup-helper.pid"); + + using Process canonical = StartProcess( + crashed.Prepared.CanonicalPath, + ["canonical-probe", launched], + BootstrapEnvironment(crashed, helperPidPath)); + await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); + Assert.Equal(0, canonical.ExitCode); + await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20)); + await WaitForProcessExitAsync( + int.Parse( + await File.ReadAllTextAsync(helperPidPath), + System.Globalization.CultureInfo.InvariantCulture), + TimeSpan.FromSeconds(20)); + + Assert.False(File.Exists(launched)); + SelfUpdatePlan preserved = Assert.IsType( + await crashed.Manager.LoadPendingAsync()); + Assert.Equal(SelfUpdatePlanState.Applying, preserved.State); + Assert.Equal(crashed.Plan.TransactionId, preserved.TransactionId); + Assert.True(Directory.Exists( + crashed.Manager.GetTargetTransactionDirectory(crashed.Plan))); + Assert.Equal(tamperedHash, await FileIntegrity.ComputeSha256HexAsync(backupPath)); + Assert.Equal( + crashed.Prepared.NewCanonicalHash, + await FileIntegrity.ComputeSha256HexAsync(crashed.Prepared.CanonicalPath)); + await Assert.ThrowsAsync(() => + crashed.Manager.VerifyRestoredPriorAsync(crashed.Target)); + } + + [Fact] + public async Task BackupJunctionOrSymlinkAfterCanonicalCrashCannotMutateOutsideOrLaunch() + { + CrashedUpdate crashed = await PrepareKilledAfterCanonicalReplaceAsync(); + string swap = crashed.Manager.GetTargetTransactionDirectory(crashed.Plan); + string backup = Path.Combine(swap, "backup"); + string preservedBackup = Path.Combine(_root, "preserved-backup"); + string outside = Path.Combine(_root, "outside-backup"); + Directory.Move(backup, preservedBackup); + CopyDirectory(preservedBackup, outside); + string outsideCanonical = Path.Combine( + outside, + crashed.Prepared.CanonicalName); + string outsideHash = await FileIntegrity.ComputeSha256HexAsync(outsideCanonical); + CreateDirectoryLink(backup, outside); + string launched = Path.Combine(_root, "reparse-backup-launched"); + string helperPidPath = Path.Combine(_root, "reparse-backup-helper.pid"); + + try + { + using Process canonical = StartProcess( + crashed.Prepared.CanonicalPath, + ["canonical-probe", launched], + BootstrapEnvironment(crashed, helperPidPath)); + await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); + Assert.Equal(0, canonical.ExitCode); + await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20)); + await WaitForProcessExitAsync( + int.Parse( + await File.ReadAllTextAsync(helperPidPath), + System.Globalization.CultureInfo.InvariantCulture), + TimeSpan.FromSeconds(20)); + + Assert.False(File.Exists(launched)); + Assert.True(File.Exists(outsideCanonical)); + Assert.Equal( + outsideHash, + await FileIntegrity.ComputeSha256HexAsync(outsideCanonical)); + Assert.True( + (File.GetAttributes(backup) & FileAttributes.ReparsePoint) != 0); + SelfUpdatePlan preserved = Assert.IsType( + await crashed.Manager.LoadPendingAsync()); + Assert.Equal(SelfUpdatePlanState.Applying, preserved.State); + Assert.Equal( + crashed.Prepared.NewCanonicalHash, + await FileIntegrity.ComputeSha256HexAsync( + crashed.Prepared.CanonicalPath)); + } + finally + { + try + { + if ((File.GetAttributes(backup) & FileAttributes.ReparsePoint) != 0) + { + Directory.Delete(backup); + } + } + catch (FileNotFoundException) + { + // The assertion above reports an unexpected missing link. + } + catch (DirectoryNotFoundException) + { + // The assertion above reports an unexpected missing link. + } + } + } + [Fact] public async Task ConcurrentStartupCannotDeleteAVisibleSlowStageTransaction() { @@ -313,6 +423,109 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable UpdateTestData.Sha256(newCanonical)); } + private async Task PrepareKilledAfterCanonicalReplaceAsync() + { + string data = Path.Combine(_root, "data"); + string target = Path.Combine(_root, "launcher"); + string ready = Path.Combine(_root, "crash.ready"); + Directory.CreateDirectory(_root); + string rid = LauncherRuntimeIdentity.DetectRid(); + PreparedLauncher prepared = PrepareLauncherClosure(target, rid); + using var server = new LocalHttpFixture(); + server.Add("launcher.zip", prepared.NewArchive); + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http); + _ = await manager.StageAsync( + LauncherVersion.Parse("2.0.0"), + rid, + new ReleaseArtifact( + server.UriFor("launcher.zip"), + UpdateTestData.Sha256(prepared.NewArchive), + prepared.NewArchive.LongLength), + target, + progress: null, + CancellationToken.None); + SelfUpdatePlan plan = Assert.IsType(await manager.LoadPendingAsync()); + using Process crash = StartFixture( + ["crash-self-update", data, target, ready, prepared.CanonicalName]); + await WaitForFileAsync(ready, crash, TimeSpan.FromSeconds(20)); + crash.Kill(entireProcessTree: true); + await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(SelfUpdatePlanState.Applying, + Assert.IsType(await manager.LoadPendingAsync()).State); + return new CrashedUpdate(data, target, manager, plan, prepared); + } + + private static Dictionary BootstrapEnvironment( + CrashedUpdate crashed, + string helperPidPath) => new() + { + [DataEnvironment] = crashed.Data, + [TargetEnvironment] = crashed.Target, + [HelperPidEnvironment] = helperPidPath, + }; + + private static void CopyDirectory(string source, string destination) + { + Directory.CreateDirectory(destination); + foreach (string directory in Directory.EnumerateDirectories( + source, + "*", + SearchOption.AllDirectories)) + { + Directory.CreateDirectory(Path.Combine( + destination, + Path.GetRelativePath(source, directory))); + } + + foreach (string file in Directory.EnumerateFiles( + source, + "*", + SearchOption.AllDirectories)) + { + string target = Path.Combine(destination, Path.GetRelativePath(source, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(target, File.GetUnixFileMode(file)); + } + } + } + + private static void CreateDirectoryLink(string link, string target) + { + if (!OperatingSystem.IsWindows()) + { + Directory.CreateSymbolicLink(link, target); + return; + } + + var start = new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + start.ArgumentList.Add("/d"); + start.ArgumentList.Add("/c"); + start.ArgumentList.Add("mklink"); + start.ArgumentList.Add("/J"); + start.ArgumentList.Add(link); + start.ArgumentList.Add(target); + using Process process = Process.Start(start) + ?? throw new InvalidOperationException("Could not create the test junction."); + process.WaitForExit(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + "Could not create the test junction: " + + process.StandardError.ReadToEnd() + + process.StandardOutput.ReadToEnd()); + } + } + private static Process StartFixture( IReadOnlyList arguments, IReadOnlyDictionary? environment = null) => @@ -450,4 +663,11 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable string CanonicalPath, byte[] NewArchive, string NewCanonicalHash); + + private sealed record CrashedUpdate( + string Data, + string Target, + LauncherSelfUpdateManager Manager, + SelfUpdatePlan Plan, + PreparedLauncher Prepared); }