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 Launcher self-update staging lives at
`DataDirectory/launcher-update/transactions/<transactionId>/` and the sole `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 ```json
{ {
"schemaVersion": 2, "schemaVersion": 3,
"transactionId": "0123456789abcdef0123456789abcdef", "transactionId": "0123456789abcdef0123456789abcdef",
"state": "staged", "state": "staged",
"version": "1.2.3", "version": "1.2.3",
@ -608,18 +608,54 @@ owned metadata path, and obsolete paths from the previous ownership record:
```json ```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 Existing targets are replaced with one same-filesystem atomic replace whose
backup is also target-local. Previously absent noncanonical files use one backup is also target-local. Previously absent noncanonical files use one
same-filesystem rename; obsolete owned files use one rename into backup. The same-filesystem rename; obsolete owned files use one rename into backup. The
canonical launcher path therefore contains either the complete old file or 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 complete new file at every durable crash boundary. Rollback first performs a
operations atomically and is idempotent after a process/power loss. Linux mode zero-mutation preflight of the complete target-local transaction and every
bits come from the verified incoming file. A helper that cannot immediately 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 acquire the exclusive update lease defers the staged plan and exits without
restarting the old launcher, preventing restart loops. 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 launcher reclaims plan, data-transaction, and target-local residue. An
`applying` plan is rolled back before retry, and failure to start/confirm the `applying` plan is rolled back before retry, and failure to start/confirm the
new launcher restores every original (and removes every no-original target). 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 Reading `pending.json` never performs cleanup. Ordinary startup attempts the
exclusive lease without waiting and skips update cleanup entirely when another exclusive lease without waiting and skips update cleanup entirely when another
session/staging transaction owns it. All plan paths are re-derived/contained 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 The exact v1 manifest, extracted-version record, `current.json` activation
pointer and launcher ownership record, shared-session/exclusive-update OS 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 `docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater
contracts (v1, BINDING)**. That section is normative: implementations reject contracts (v1, BINDING)**. That section is normative: implementations reject
unknown/duplicate fields and unsupported versions, use strict SemVer 2.0 unknown/duplicate fields and unsupported versions, use strict SemVer 2.0

View file

@ -240,12 +240,10 @@ public static class LauncherSelfUpdateBootstrap
targetDirectory, targetDirectory,
lease); lease);
Process? replacement = null; Process? replacement = null;
bool appliedByThisHelper = false;
try try
{ {
plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken) plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
appliedByThisHelper = true;
replacement = Process.Start(startInfo) replacement = Process.Start(startInfo)
?? throw new LauncherUpdateException( ?? throw new LauncherUpdateException(
"The updated launcher could not be started."); "The updated launcher could not be started.");
@ -283,26 +281,33 @@ public static class LauncherSelfUpdateBootstrap
try try
{ {
if (appliedByThisHelper) SelfUpdatePlan? pending = await manager.LoadPendingAsync(
CancellationToken.None)
.ConfigureAwait(false);
SelfUpdatePlan? rollbackReceipt = pending?.State switch
{ {
SelfUpdatePlan? pending = await manager.LoadPendingAsync( SelfUpdatePlanState.Applying =>
CancellationToken.None) await manager.RecoverApplyingAsync(
.ConfigureAwait(false);
if (pending?.State == SelfUpdatePlanState.Applying)
{
_ = await manager.RecoverApplyingAsync(
targetDirectory, targetDirectory,
CancellationToken.None) CancellationToken.None)
.ConfigureAwait(false); .ConfigureAwait(false),
} SelfUpdatePlanState.AwaitingConfirmation =>
else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation) await manager.RollbackAwaitingConfirmationAsync(
{
_ = await manager.RollbackAwaitingConfirmationAsync(
targetDirectory, targetDirectory,
CancellationToken.None) 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 catch
{ {

View file

@ -9,6 +9,7 @@ public enum SelfUpdatePlanState
Staged, Staged,
Applying, Applying,
AwaitingConfirmation, AwaitingConfirmation,
RolledBack,
} }
public enum SelfUpdateApplyOperation public enum SelfUpdateApplyOperation
@ -20,7 +21,13 @@ public enum SelfUpdateApplyOperation
public sealed record SelfUpdateApplyEntry( public sealed record SelfUpdateApplyEntry(
string Path, string Path,
SelfUpdateApplyOperation Operation, SelfUpdateApplyOperation Operation,
bool HadOriginal); bool HadOriginal,
string? PriorSha256,
long? PriorSize,
int? PriorUnixMode,
string? ReplacementSha256,
long? ReplacementSize,
int? ReplacementUnixMode);
public sealed record SelfUpdatePlan( public sealed record SelfUpdatePlan(
int SchemaVersion, int SchemaVersion,
@ -34,7 +41,7 @@ public sealed record SelfUpdatePlan(
IReadOnlyList<InstalledFileRecord> Files, IReadOnlyList<InstalledFileRecord> Files,
IReadOnlyList<SelfUpdateApplyEntry>? Apply) IReadOnlyList<SelfUpdateApplyEntry>? Apply)
{ {
public const int CurrentSchemaVersion = 2; public const int CurrentSchemaVersion = 3;
} }
public sealed record LauncherBinaryInstallRecord( public sealed record LauncherBinaryInstallRecord(
@ -94,6 +101,14 @@ public sealed class LauncherSelfUpdateManager
private readonly SafeZipExtractor _extractor; private readonly SafeZipExtractor _extractor;
private readonly Action<SelfUpdateApplyObservation>? _applyObserver; 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( public LauncherSelfUpdateManager(
ApplicationPathSet paths, ApplicationPathSet paths,
HttpClient httpClient, HttpClient httpClient,
@ -310,17 +325,31 @@ public sealed class LauncherSelfUpdateManager
.ConfigureAwait(false); .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); await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false);
LauncherBinaryInstallRecord? previous = await ReadAndVerifyInstallRecordAsync( LauncherBinaryInstallRecord? previous = await ReadAndVerifyInstallRecordAsync(
expectedTarget, expectedTarget,
plan.Rid, plan.Rid,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
IReadOnlyList<SelfUpdateApplyEntry> apply = BuildApplyJournal( IReadOnlyList<SelfUpdateApplyEntry> apply = await BuildApplyJournalAsync(
plan, plan,
previous, previous,
expectedTarget); expectedTarget,
await PrepareTargetTransactionAsync(plan, apply, cancellationToken) cancellationToken)
.ConfigureAwait(false);
apply = await PrepareTargetTransactionAsync(plan, apply, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
plan = plan with plan = plan with
{ {
@ -334,7 +363,8 @@ public sealed class LauncherSelfUpdateManager
foreach (SelfUpdateApplyEntry entry in plan.Apply) foreach (SelfUpdateApplyEntry entry in plan.Apply)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
ApplyEntry(plan, entry); await ApplyEntryAsync(plan, entry, cancellationToken)
.ConfigureAwait(false);
_applyObserver?.Invoke(new SelfUpdateApplyObservation( _applyObserver?.Invoke(new SelfUpdateApplyObservation(
SelfUpdateApplyBoundary.AfterTargetMutation, SelfUpdateApplyBoundary.AfterTargetMutation,
entry.Path, entry.Path,
@ -367,6 +397,25 @@ public sealed class LauncherSelfUpdateManager
: plan; : 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( public async Task ConfirmAsync(
string transactionId, string transactionId,
string expectedTargetDirectory, string expectedTargetDirectory,
@ -497,10 +546,11 @@ public sealed class LauncherSelfUpdateManager
"acdream-launcher" "acdream-launcher"
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); + (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
private IReadOnlyList<SelfUpdateApplyEntry> BuildApplyJournal( private async Task<IReadOnlyList<SelfUpdateApplyEntry>> BuildApplyJournalAsync(
SelfUpdatePlan plan, SelfUpdatePlan plan,
LauncherBinaryInstallRecord? previous, LauncherBinaryInstallRecord? previous,
string targetDirectory) string targetDirectory,
CancellationToken cancellationToken)
{ {
var operations = new Dictionary<string, SelfUpdateApplyOperation>( var operations = new Dictionary<string, SelfUpdateApplyOperation>(
StringComparer.OrdinalIgnoreCase); StringComparer.OrdinalIgnoreCase);
@ -527,13 +577,12 @@ public sealed class LauncherSelfUpdateManager
{ {
string targetPath = ClientVersionStore.ResolveContained(targetDirectory, path); string targetPath = ClientVersionStore.ResolveContained(targetDirectory, path);
EnsureSafeParent(targetDirectory, targetPath); EnsureSafeParent(targetDirectory, targetPath);
if (Directory.Exists(targetPath)) JournalFileMetadata? prior = await CaptureOptionalFileMetadataAsync(
{ targetPath,
throw new LauncherUpdateException( $"Self-update target '{path}'",
$"Self-update target '{path}' is unexpectedly a directory."); cancellationToken)
} .ConfigureAwait(false);
bool hadOriginal = prior is not null;
bool hadOriginal = File.Exists(targetPath);
if (operation == SelfUpdateApplyOperation.Remove && !hadOriginal) if (operation == SelfUpdateApplyOperation.Remove && !hadOriginal)
{ {
throw new LauncherUpdateException( throw new LauncherUpdateException(
@ -550,13 +599,22 @@ public sealed class LauncherSelfUpdateManager
"The canonical launcher executable is missing before self-update."); "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; return result;
} }
private async Task PrepareTargetTransactionAsync( private async Task<IReadOnlyList<SelfUpdateApplyEntry>> PrepareTargetTransactionAsync(
SelfUpdatePlan plan, SelfUpdatePlan plan,
IReadOnlyList<SelfUpdateApplyEntry> apply, IReadOnlyList<SelfUpdateApplyEntry> apply,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@ -628,9 +686,36 @@ public sealed class LauncherSelfUpdateManager
throw new LauncherUpdateException( throw new LauncherUpdateException(
"The target-local self-update incoming tree is incomplete."); "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;
}
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 swap = GetTargetTransactionDirectory(plan);
string incoming = Path.Combine(swap, "incoming"); string incoming = Path.Combine(swap, "incoming");
@ -639,27 +724,33 @@ public sealed class LauncherSelfUpdateManager
plan.TargetDirectory, plan.TargetDirectory,
entry.Path); entry.Path);
string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path);
EnsureSafeParent(plan.TargetDirectory, targetPath); string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path);
Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); 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.Operation == SelfUpdateApplyOperation.Remove)
{ {
if (!entry.HadOriginal || !File.Exists(targetPath)) EnsureSafeParent(swap, backupPath);
{
throw new LauncherUpdateException(
$"Owned obsolete launcher file '{entry.Path}' vanished during apply.");
}
File.Move(targetPath, backupPath); File.Move(targetPath, backupPath);
return; return;
} }
string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path); await VerifyReplacementFileAsync(entry, incomingPath, cancellationToken)
if (!File.Exists(incomingPath)) .ConfigureAwait(false);
{ EnsureSafeParent(swap, backupPath);
throw new LauncherUpdateException(
$"Incoming launcher file '{entry.Path}' is missing.");
}
if (entry.HadOriginal) if (entry.HadOriginal)
{ {
@ -681,67 +772,470 @@ public sealed class LauncherSelfUpdateManager
} }
string swap = GetTargetTransactionDirectory(plan); 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 backup = Path.Combine(swap, "backup");
string discard = Path.Combine(swap, "rollback-discard"); 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(); cancellationToken.ThrowIfCancellationRequested();
string targetPath = ClientVersionStore.ResolveContained( string targetPath = ClientVersionStore.ResolveContained(
plan.TargetDirectory, plan.TargetDirectory,
entry.Path); entry.Path);
string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path);
string backupPath = ClientVersionStore.ResolveContained(backup, 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 (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)) RequirePriorMetadata(entry, backupFile, "rollback backup");
{ actions.Add(new RollbackAction(
throw new LauncherUpdateException( entry,
$"Obsolete launcher rollback target '{entry.Path}' was recreated."); targetPath,
} backupPath,
discardPath));
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); continue;
File.Move(backupPath, targetPath);
} }
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; continue;
} }
if (entry.HadOriginal && File.Exists(backupPath)) if (target is null && incomingFile is not null && discardedFile is null)
{ {
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); RequireReplacementMetadata(
if (File.Exists(targetPath)) entry,
{ incomingFile,
string discardPath = ClientVersionStore.ResolveContained( "unapplied rollback incoming file");
discard, continue;
entry.Path);
Directory.CreateDirectory(Path.GetDirectoryName(discardPath)!);
File.Replace(
backupPath,
targetPath,
discardPath,
ignoreMetadataErrors: true);
}
else
{
File.Move(backupPath, targetPath);
}
} }
else if (!entry.HadOriginal && File.Exists(targetPath))
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<string>(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); foreach (string path in Directory.EnumerateFileSystemEntries(
plan = plan with swap,
"*",
SearchOption.AllDirectories))
{ {
State = SelfUpdatePlanState.Staged, string relative = Path.GetRelativePath(swap, path).Replace('\\', '/');
Apply = null, if (!allowed.Contains(relative))
}; {
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); throw new LauncherUpdateException(
await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false); $"The rollback transaction contains unrecorded path '{relative}'.");
return plan; }
}
}
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( private async Task VerifyPayloadAsync(
@ -968,6 +1462,22 @@ public sealed class LauncherSelfUpdateManager
|| !Enum.IsDefined(entry.Operation) || !Enum.IsDefined(entry.Operation)
|| (entry.Operation == SelfUpdateApplyOperation.Remove || (entry.Operation == SelfUpdateApplyOperation.Remove
&& !entry.HadOriginal) && !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 || (prior is not null
&& string.Compare(prior, entry.Path, StringComparison.Ordinal) >= 0)) && 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."); throw new LauncherUpdateException("A self-update target has no parent.");
} }
EnsureExistingParentsSafe(root, filePath);
Directory.CreateDirectory(parent); 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); for (var directory = new DirectoryInfo(parent);
directory is not null && IsContained(root, directory.FullName); directory is not null && IsContained(root, directory.FullName);
directory = directory.Parent) 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( 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)) if (PathsEqual(directory.FullName, root))

View file

@ -1,4 +1,5 @@
using AcDream.Launcher.Core.Updates; using AcDream.Launcher.Core.Updates;
using System.Text.Json.Nodes;
namespace AcDream.Launcher.Core.Tests.Updates; namespace AcDream.Launcher.Core.Tests.Updates;
@ -103,8 +104,16 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
SelfUpdatePlan rolledBack = await harness.Manager SelfUpdatePlan rolledBack = await harness.Manager
.RollbackAwaitingConfirmationAsync(harness.Target); .RollbackAwaitingConfirmationAsync(harness.Target);
Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State); Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State);
Assert.Null(rolledBack.Apply); 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-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath)); Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target); SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target);
@ -150,7 +159,7 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
SelfUpdatePlan rolledBack = await harness.Manager SelfUpdatePlan rolledBack = await harness.Manager
.RollbackAwaitingConfirmationAsync(harness.Target); .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("launcher-v2", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("support-v2", await File.ReadAllTextAsync(harness.SupportPath)); Assert.Equal("support-v2", await File.ReadAllTextAsync(harness.SupportPath));
Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath)); Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath));
@ -170,7 +179,7 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
} }
[Fact] [Fact]
public async Task ApplyFailpointAfterCanonicalAtomicReplaceRollsBackToStagedState() public async Task ApplyFailpointAfterCanonicalAtomicReplaceLeavesVerifiedRollbackReceipt()
{ {
using var harness = new Harness(_root); using var harness = new Harness(_root);
_ = await harness.StageAsync(); _ = await harness.StageAsync();
@ -193,7 +202,8 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
await harness.Manager.LoadPendingAsync()); await harness.Manager.LoadPendingAsync());
Assert.Equal("failpoint", failure.Message); 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-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath)); Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine( Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine(
@ -201,6 +211,41 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
harness.LauncherName))); 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] [Fact]
public async Task CorruptPayloadWrongTargetAndUnknownPlanFieldFailClosed() 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] [Fact]
public async Task ConcurrentStartupCannotDeleteAVisibleSlowStageTransaction() public async Task ConcurrentStartupCannotDeleteAVisibleSlowStageTransaction()
{ {
@ -313,6 +423,109 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
UpdateTestData.Sha256(newCanonical)); 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( private static Process StartFixture(
IReadOnlyList<string> arguments, IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string>? environment = null) => IReadOnlyDictionary<string, string>? environment = null) =>
@ -450,4 +663,11 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
string CanonicalPath, string CanonicalPath,
byte[] NewArchive, byte[] NewArchive,
string NewCanonicalHash); string NewCanonicalHash);
private sealed record CrashedUpdate(
string Data,
string Target,
LauncherSelfUpdateManager Manager,
SelfUpdatePlan Plan,
PreparedLauncher Prepared);
} }