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