fix(launcher): verify self-update rollback sources

This commit is contained in:
Erik 2026-08-14 23:41:55 +02:00
parent 1955ca8ab5
commit 09d84387a8
6 changed files with 949 additions and 103 deletions

View file

@ -580,11 +580,11 @@ owns the lease and therefore releases after process death.
Launcher self-update staging lives at
`DataDirectory/launcher-update/transactions/<transactionId>/` 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

View file

@ -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

View file

@ -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.");
@ -282,28 +280,35 @@ public static class LauncherSelfUpdateBootstrap
}
try
{
if (appliedByThisHelper)
{
SelfUpdatePlan? pending = await manager.LoadPendingAsync(
CancellationToken.None)
.ConfigureAwait(false);
if (pending?.State == SelfUpdatePlanState.Applying)
SelfUpdatePlan? rollbackReceipt = pending?.State switch
{
_ = await manager.RecoverApplyingAsync(
SelfUpdatePlanState.Applying =>
await manager.RecoverApplyingAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false),
SelfUpdatePlanState.AwaitingConfirmation =>
await manager.RollbackAwaitingConfirmationAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false),
SelfUpdatePlanState.RolledBack => pending,
_ => null,
};
if (rollbackReceipt?.State != SelfUpdatePlanState.RolledBack)
{
return 75;
}
await manager.VerifyRestoredPriorAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
}
else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation)
{
_ = await manager.RollbackAwaitingConfirmationAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
}
}
}
catch
{
// An ambiguous state must not start either executable.

View file

@ -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<InstalledFileRecord> Files,
IReadOnlyList<SelfUpdateApplyEntry>? 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<SelfUpdateApplyObservation>? _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<SelfUpdateApplyEntry> apply = BuildApplyJournal(
IReadOnlyList<SelfUpdateApplyEntry> apply = await BuildApplyJournalAsync(
plan,
previous,
expectedTarget);
await PrepareTargetTransactionAsync(plan, apply, cancellationToken)
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<SelfUpdateApplyEntry> BuildApplyJournal(
private async Task<IReadOnlyList<SelfUpdateApplyEntry>> BuildApplyJournalAsync(
SelfUpdatePlan plan,
LauncherBinaryInstallRecord? previous,
string targetDirectory)
string targetDirectory,
CancellationToken cancellationToken)
{
var operations = new Dictionary<string, SelfUpdateApplyOperation>(
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<IReadOnlyList<SelfUpdateApplyEntry>> PrepareTargetTransactionAsync(
SelfUpdatePlan plan,
IReadOnlyList<SelfUpdateApplyEntry> 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<SelfUpdateApplyEntry>(apply.Count);
foreach (SelfUpdateApplyEntry entry in apply)
{
if (entry.Operation == SelfUpdateApplyOperation.Remove)
{
completed.Add(entry);
continue;
}
private void ApplyEntry(SelfUpdatePlan plan, SelfUpdateApplyEntry entry)
JournalFileMetadata replacement = await CaptureRequiredFileMetadataAsync(
ClientVersionStore.ResolveContained(incoming, entry.Path),
$"Target-local incoming launcher file '{entry.Path}'",
cancellationToken)
.ConfigureAwait(false);
completed.Add(entry with
{
ReplacementSha256 = replacement.Sha256,
ReplacementSize = replacement.Size,
ReplacementUnixMode = replacement.UnixMode,
});
}
return completed;
}
private async Task ApplyEntryAsync(
SelfUpdatePlan plan,
SelfUpdateApplyEntry entry,
CancellationToken cancellationToken)
{
string swap = GetTargetTransactionDirectory(plan);
string incoming = Path.Combine(swap, "incoming");
@ -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<RollbackAction> 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<IReadOnlyList<RollbackAction>> 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<RollbackAction>();
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 (entry.HadOriginal && File.Exists(backupPath))
if (backupFile is null && target is not null)
{
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
if (File.Exists(targetPath))
RequirePriorMetadata(entry, target, "restored rollback target");
continue;
}
throw AmbiguousRollback(entry.Path);
}
if (entry.HadOriginal)
{
string discardPath = ClientVersionStore.ResolveContained(
discard,
entry.Path);
Directory.CreateDirectory(Path.GetDirectoryName(discardPath)!);
File.Replace(
backupPath,
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,
discardPath,
ignoreMetadataErrors: true);
backupPath,
discardPath));
continue;
}
else
if (backupFile is null && target is not null)
{
File.Move(backupPath, targetPath);
}
}
else if (!entry.HadOriginal && File.Exists(targetPath))
RequirePriorMetadata(entry, target, "restored rollback target");
if (incomingFile is not null && discardedFile is null)
{
File.Delete(targetPath);
RequireReplacementMetadata(
entry,
incomingFile,
"unapplied rollback incoming file");
continue;
}
if (incomingFile is null && discardedFile is not null)
{
RequireReplacementMetadata(
entry,
discardedFile,
"completed rollback discard");
continue;
}
}
SafeZipExtractor.TryDeleteDirectory(swap);
plan = plan with
throw AmbiguousRollback(entry.Path);
}
RequireMissing(backupFile, entry.Path, "backup");
if (target is not null
&& incomingFile is null
&& discardedFile is null)
{
State = SelfUpdatePlanState.Staged,
Apply = null,
RequireReplacementMetadata(entry, target, "applied rollback target");
actions.Add(new RollbackAction(
entry,
targetPath,
backupPath,
discardPath));
continue;
}
if (target is null && incomingFile is not null && discardedFile is null)
{
RequireReplacementMetadata(
entry,
incomingFile,
"unapplied rollback incoming file");
continue;
}
if (target is null && incomingFile is null && discardedFile is not null)
{
RequireReplacementMetadata(
entry,
discardedFile,
"completed rollback discard");
continue;
}
throw AmbiguousRollback(entry.Path);
}
return actions;
}
private static void ValidateRollbackTree(SelfUpdatePlan plan, string swap)
{
RequireTransactionContainer(Path.Combine(swap, "incoming"), required: true);
RequireTransactionContainer(Path.Combine(swap, "backup"), required: false);
RequireTransactionContainer(
Path.Combine(swap, "rollback-discard"),
required: false);
var allowed = new HashSet<string>(StringComparer.Ordinal)
{
"incoming",
};
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false);
return plan;
foreach (SelfUpdateApplyEntry entry in plan.Apply!)
{
if (entry.Operation == SelfUpdateApplyOperation.Install)
{
AddAllowedTreePath(allowed, "incoming", entry.Path);
AddAllowedTreePath(allowed, "rollback-discard", entry.Path);
}
if (entry.HadOriginal)
{
AddAllowedTreePath(allowed, "backup", entry.Path);
}
}
foreach (string path in Directory.EnumerateFileSystemEntries(
swap,
"*",
SearchOption.AllDirectories))
{
string relative = Path.GetRelativePath(swap, path).Replace('\\', '/');
if (!allowed.Contains(relative))
{
throw new LauncherUpdateException(
$"The rollback transaction contains unrecorded path '{relative}'.");
}
}
}
private static void RequireTransactionContainer(string path, bool required)
{
try
{
FileAttributes attributes = File.GetAttributes(path);
if ((attributes & FileAttributes.Directory) == 0
|| (attributes & FileAttributes.ReparsePoint) != 0)
{
throw new LauncherUpdateException(
$"Rollback container '{Path.GetFileName(path)}' is not a safe directory.");
}
}
catch (FileNotFoundException) when (!required)
{
}
catch (DirectoryNotFoundException) when (!required)
{
}
catch (FileNotFoundException)
{
throw new LauncherUpdateException(
$"Required rollback container '{Path.GetFileName(path)}' is missing.");
}
catch (DirectoryNotFoundException)
{
throw new LauncherUpdateException(
$"Required rollback container '{Path.GetFileName(path)}' is missing.");
}
}
private static void AddAllowedTreePath(
HashSet<string> 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<JournalFileMetadata> CaptureRequiredFileMetadataAsync(
string path,
string description,
CancellationToken cancellationToken) =>
await CaptureOptionalFileMetadataAsync(path, description, cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException($"{description} is missing.");
private static async Task<JournalFileMetadata?> 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))

View file

@ -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<JsonObject>(JsonNode.Parse(
await File.ReadAllTextAsync(harness.Manager.PendingPlanPath)));
JsonArray apply = Assert.IsType<JsonArray>(document["apply"]);
JsonObject canonicalNode = Assert.IsType<JsonObject>(apply.Single(node =>
string.Equals(
node?["path"]?.GetValue<string>(),
harness.LauncherName,
StringComparison.Ordinal)));
canonicalNode["priorSha256"] = null;
await File.WriteAllTextAsync(
harness.Manager.PendingPlanPath,
document.ToJsonString());
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
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()
{

View file

@ -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<SelfUpdatePlan>(
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<LauncherUpdateException>(() =>
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<SelfUpdatePlan>(
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<CrashedUpdate> 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<SelfUpdatePlan>(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<SelfUpdatePlan>(await manager.LoadPendingAsync()).State);
return new CrashedUpdate(data, target, manager, plan, prepared);
}
private static Dictionary<string, string> 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<string> arguments,
IReadOnlyDictionary<string, string>? 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);
}