fix(launcher): verify self-update rollback sources

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

View file

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

View file

@ -9,6 +9,7 @@ public enum SelfUpdatePlanState
Staged,
Applying,
AwaitingConfirmation,
RolledBack,
}
public enum SelfUpdateApplyOperation
@ -20,7 +21,13 @@ public enum SelfUpdateApplyOperation
public sealed record SelfUpdateApplyEntry(
string Path,
SelfUpdateApplyOperation Operation,
bool HadOriginal);
bool HadOriginal,
string? PriorSha256,
long? PriorSize,
int? PriorUnixMode,
string? ReplacementSha256,
long? ReplacementSize,
int? ReplacementUnixMode);
public sealed record SelfUpdatePlan(
int SchemaVersion,
@ -34,7 +41,7 @@ public sealed record SelfUpdatePlan(
IReadOnlyList<InstalledFileRecord> Files,
IReadOnlyList<SelfUpdateApplyEntry>? Apply)
{
public const int CurrentSchemaVersion = 2;
public const int CurrentSchemaVersion = 3;
}
public sealed record LauncherBinaryInstallRecord(
@ -94,6 +101,14 @@ public sealed class LauncherSelfUpdateManager
private readonly SafeZipExtractor _extractor;
private readonly Action<SelfUpdateApplyObservation>? _applyObserver;
private sealed record JournalFileMetadata(string Sha256, long Size, int UnixMode);
private sealed record RollbackAction(
SelfUpdateApplyEntry Entry,
string TargetPath,
string BackupPath,
string DiscardPath);
public LauncherSelfUpdateManager(
ApplicationPathSet paths,
HttpClient httpClient,
@ -310,17 +325,31 @@ public sealed class LauncherSelfUpdateManager
.ConfigureAwait(false);
}
if (plan.State == SelfUpdatePlanState.RolledBack)
{
await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken)
.ConfigureAwait(false);
plan = plan with
{
State = SelfUpdatePlanState.Staged,
Apply = null,
};
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
}
await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false);
LauncherBinaryInstallRecord? previous = await ReadAndVerifyInstallRecordAsync(
expectedTarget,
plan.Rid,
cancellationToken)
.ConfigureAwait(false);
IReadOnlyList<SelfUpdateApplyEntry> apply = BuildApplyJournal(
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))