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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue