fix(launcher): verify self-update rollback sources
This commit is contained in:
parent
1955ca8ab5
commit
09d84387a8
6 changed files with 949 additions and 103 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
plan,
|
||||
previous,
|
||||
expectedTarget);
|
||||
await PrepareTargetTransactionAsync(plan, apply, cancellationToken)
|
||||
IReadOnlyList<SelfUpdateApplyEntry> 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<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;
|
||||
}
|
||||
|
||||
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<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 (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<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);
|
||||
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<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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue