feat(launcher): implement verified atomic updates
This commit is contained in:
parent
2198a0cc8e
commit
2d2a5b5046
34 changed files with 6755 additions and 61 deletions
786
src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs
Normal file
786
src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs
Normal file
|
|
@ -0,0 +1,786 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
public enum SelfUpdatePlanState
|
||||
{
|
||||
Staged,
|
||||
Applying,
|
||||
AwaitingConfirmation,
|
||||
}
|
||||
|
||||
public sealed record SelfUpdateApplyEntry(string Path, bool HadOriginal);
|
||||
|
||||
public sealed record SelfUpdatePlan(
|
||||
int SchemaVersion,
|
||||
string TransactionId,
|
||||
SelfUpdatePlanState State,
|
||||
string Version,
|
||||
string Rid,
|
||||
string TargetDirectory,
|
||||
string ArchiveSha256,
|
||||
long ArchiveSize,
|
||||
IReadOnlyList<InstalledFileRecord> Files,
|
||||
IReadOnlyList<SelfUpdateApplyEntry>? Apply)
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
}
|
||||
|
||||
public sealed record SelfUpdateStageResult(
|
||||
LauncherVersion Version,
|
||||
string PendingPlanPath,
|
||||
string Status);
|
||||
|
||||
/// <summary>
|
||||
/// Durable self-update transaction owner. It stages a verified launcher ZIP;
|
||||
/// a separately copied helper performs move-only replacement after the parent
|
||||
/// exits and can replay rollback after a crash at any file boundary.
|
||||
/// </summary>
|
||||
public sealed class LauncherSelfUpdateManager
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
WriteIndented = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||
MaxDepth = 32,
|
||||
Converters = { new JsonStringEnumConverter<SelfUpdatePlanState>(
|
||||
JsonNamingPolicy.CamelCase,
|
||||
allowIntegerValues: false) },
|
||||
};
|
||||
|
||||
private readonly VerifiedArtifactDownloader _downloader;
|
||||
private readonly SafeZipExtractor _extractor;
|
||||
|
||||
public LauncherSelfUpdateManager(
|
||||
ApplicationPathSet paths,
|
||||
HttpClient httpClient,
|
||||
SafeZipExtractor? extractor = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
RootDirectory = Path.Combine(
|
||||
Path.GetFullPath(paths.DataDirectory),
|
||||
"launcher-update");
|
||||
TransactionsDirectory = Path.Combine(RootDirectory, "transactions");
|
||||
PendingPlanPath = Path.Combine(RootDirectory, "pending.json");
|
||||
Barrier = new UpdateSessionBarrier(paths.DataDirectory);
|
||||
_downloader = new VerifiedArtifactDownloader(
|
||||
httpClient ?? throw new ArgumentNullException(nameof(httpClient)));
|
||||
_extractor = extractor ?? new SafeZipExtractor();
|
||||
}
|
||||
|
||||
public string RootDirectory { get; }
|
||||
|
||||
public string TransactionsDirectory { get; }
|
||||
|
||||
public string PendingPlanPath { get; }
|
||||
|
||||
public UpdateSessionBarrier Barrier { get; }
|
||||
|
||||
public async Task<SelfUpdateStageResult> StageAsync(
|
||||
ReleaseManifest manifest,
|
||||
string rid,
|
||||
string targetDirectory,
|
||||
IProgress<ArtifactDownloadProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ReleaseArtifact artifact = manifest.RequireLauncher(rid);
|
||||
return await StageAsync(
|
||||
manifest.Version,
|
||||
rid,
|
||||
artifact,
|
||||
targetDirectory,
|
||||
progress,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async Task<SelfUpdateStageResult> StageAsync(
|
||||
LauncherVersion version,
|
||||
string rid,
|
||||
ReleaseArtifact artifact,
|
||||
string targetDirectory,
|
||||
IProgress<ArtifactDownloadProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(version);
|
||||
ArgumentNullException.ThrowIfNull(artifact);
|
||||
if (!LauncherRuntimeIdentity.IsValidRid(rid))
|
||||
{
|
||||
throw new ArgumentException("RID is invalid.", nameof(rid));
|
||||
}
|
||||
|
||||
string target = NormalizeTargetDirectory(targetDirectory);
|
||||
Directory.CreateDirectory(RootDirectory);
|
||||
Directory.CreateDirectory(TransactionsDirectory);
|
||||
SelfUpdatePlan? existing = await LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Launcher self-update {existing.Version} is already {existing.State}. "
|
||||
+ "Restart the launcher to finish it before staging another.");
|
||||
}
|
||||
|
||||
string transactionId = Guid.NewGuid().ToString("N");
|
||||
string transactionDirectory = GetTransactionDirectory(transactionId);
|
||||
string payloadDirectory = GetPayloadDirectory(transactionId);
|
||||
string archivePath = Path.Combine(transactionDirectory, "launcher.zip");
|
||||
Directory.CreateDirectory(transactionDirectory);
|
||||
try
|
||||
{
|
||||
_ = await _downloader.DownloadAsync(
|
||||
artifact,
|
||||
archivePath,
|
||||
progress,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
IReadOnlyList<ExtractedFileRecord> extracted = await _extractor.ExtractAsync(
|
||||
archivePath,
|
||||
payloadDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
ClientVersionStore.ValidateRequiredExecutables(
|
||||
extracted,
|
||||
rid,
|
||||
launcherPayload: true);
|
||||
|
||||
var plan = new SelfUpdatePlan(
|
||||
SelfUpdatePlan.CurrentSchemaVersion,
|
||||
transactionId,
|
||||
SelfUpdatePlanState.Staged,
|
||||
version.Value,
|
||||
rid,
|
||||
target,
|
||||
artifact.Sha256.ToLowerInvariant(),
|
||||
artifact.Size,
|
||||
extracted.Select(file => new InstalledFileRecord(
|
||||
file.Path,
|
||||
file.Sha256,
|
||||
file.Size,
|
||||
file.UnixMode))
|
||||
.OrderBy(file => file.Path, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
null);
|
||||
ValidatePlan(plan, target);
|
||||
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
VerifiedArtifactDownloader.TryDelete(archivePath);
|
||||
return new SelfUpdateStageResult(
|
||||
version,
|
||||
PendingPlanPath,
|
||||
$"Launcher {version} is staged and will be applied on next start.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!File.Exists(PendingPlanPath))
|
||||
{
|
||||
SafeZipExtractor.TryDeleteDirectory(transactionDirectory);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SelfUpdatePlan?> LoadPendingAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(PendingPlanPath))
|
||||
{
|
||||
CleanupOwnedResidue(keepTransactionId: null);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] bytes = await File.ReadAllBytesAsync(PendingPlanPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
SelfUpdatePlan? plan = ClientVersionStore.ParseStrict<SelfUpdatePlan>(
|
||||
bytes,
|
||||
SerializerOptions);
|
||||
if (plan is null)
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update plan is empty.");
|
||||
}
|
||||
|
||||
ValidatePlan(plan, plan.TargetDirectory);
|
||||
CleanupOwnedResidue(plan.TransactionId);
|
||||
return plan;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (LauncherUpdateException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or JsonException
|
||||
or FormatException
|
||||
or NotSupportedException)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The pending launcher self-update is invalid: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SelfUpdatePlan> ApplyPendingAsync(
|
||||
string expectedTargetDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
|
||||
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException("There is no staged launcher self-update.");
|
||||
ValidatePlan(plan, expectedTarget);
|
||||
|
||||
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
|
||||
{
|
||||
return plan;
|
||||
}
|
||||
|
||||
if (plan.State == SelfUpdatePlanState.Applying)
|
||||
{
|
||||
plan = await RollbackApplyingAsync(plan, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
var apply = new List<SelfUpdateApplyEntry>(plan.Files.Count);
|
||||
foreach (InstalledFileRecord file in plan.Files)
|
||||
{
|
||||
string targetPath = ClientVersionStore.ResolveContained(
|
||||
expectedTarget,
|
||||
file.Path);
|
||||
if (Directory.Exists(targetPath))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Self-update target '{file.Path}' is unexpectedly a directory.");
|
||||
}
|
||||
|
||||
apply.Add(new SelfUpdateApplyEntry(file.Path, File.Exists(targetPath)));
|
||||
}
|
||||
|
||||
plan = plan with
|
||||
{
|
||||
State = SelfUpdatePlanState.Applying,
|
||||
Apply = apply,
|
||||
};
|
||||
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string payload = GetPayloadDirectory(plan.TransactionId);
|
||||
string backup = GetBackupDirectory(plan.TransactionId);
|
||||
try
|
||||
{
|
||||
foreach (SelfUpdateApplyEntry entry in plan.Apply)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path);
|
||||
string targetPath = ClientVersionStore.ResolveContained(expectedTarget, entry.Path);
|
||||
string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path);
|
||||
EnsureSafeParent(expectedTarget, targetPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!);
|
||||
if (entry.HadOriginal)
|
||||
{
|
||||
File.Move(targetPath, backupPath);
|
||||
}
|
||||
|
||||
File.Move(stagedPath, targetPath);
|
||||
InstalledFileRecord file = plan.Files.Single(candidate =>
|
||||
string.Equals(candidate.Path, entry.Path, StringComparison.Ordinal));
|
||||
if (OperatingSystem.IsLinux() && file.UnixMode != 0)
|
||||
{
|
||||
File.SetUnixFileMode(targetPath, (UnixFileMode)file.UnixMode);
|
||||
}
|
||||
}
|
||||
|
||||
plan = plan with { State = SelfUpdatePlanState.AwaitingConfirmation };
|
||||
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
return plan;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await RollbackApplyingAsync(plan, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SelfUpdatePlan> RecoverApplyingAsync(
|
||||
string expectedTargetDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
|
||||
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException("There is no pending self-update.");
|
||||
ValidatePlan(plan, expectedTarget);
|
||||
return plan.State == SelfUpdatePlanState.Applying
|
||||
? await RollbackApplyingAsync(plan, cancellationToken).ConfigureAwait(false)
|
||||
: plan;
|
||||
}
|
||||
|
||||
public async Task ConfirmAsync(
|
||||
string transactionId,
|
||||
string expectedTargetDirectory,
|
||||
string currentExecutablePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException("There is no self-update to confirm.");
|
||||
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
|
||||
ValidatePlan(plan, expectedTarget);
|
||||
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)
|
||||
|| plan.State != SelfUpdatePlanState.AwaitingConfirmation)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The running launcher does not match the pending confirmation plan.");
|
||||
}
|
||||
|
||||
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
|
||||
? ".exe"
|
||||
: string.Empty;
|
||||
string expectedExecutable = ClientVersionStore.ResolveContained(
|
||||
expectedTarget,
|
||||
"acdream-launcher" + suffix);
|
||||
if (!PathsEqual(expectedExecutable, currentExecutablePath))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Only the newly installed launcher executable may confirm self-update.");
|
||||
}
|
||||
|
||||
await VerifyAppliedTargetsAsync(plan, expectedTarget, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
string confirmationPath = GetConfirmationPath(transactionId);
|
||||
await AtomicJsonFile.WriteBytesAsync(
|
||||
confirmationPath,
|
||||
"confirmed"u8.ToArray(),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public bool IsConfirmed(string transactionId) =>
|
||||
File.Exists(GetConfirmationPath(transactionId));
|
||||
|
||||
public async Task CompleteConfirmedAsync(
|
||||
string transactionId,
|
||||
string expectedTargetDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException("There is no self-update to complete.");
|
||||
ValidatePlan(plan, NormalizeTargetDirectory(expectedTargetDirectory));
|
||||
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)
|
||||
|| plan.State != SelfUpdatePlanState.AwaitingConfirmation
|
||||
|| !IsConfirmed(transactionId))
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update is not confirmed.");
|
||||
}
|
||||
|
||||
File.Delete(PendingPlanPath);
|
||||
SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId));
|
||||
}
|
||||
|
||||
public async Task<SelfUpdatePlan> RollbackAwaitingConfirmationAsync(
|
||||
string expectedTargetDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
|
||||
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException("There is no self-update to roll back.");
|
||||
ValidatePlan(plan, expectedTarget);
|
||||
if (plan.State != SelfUpdatePlanState.AwaitingConfirmation)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The pending self-update is not awaiting confirmation.");
|
||||
}
|
||||
|
||||
plan = plan with { State = SelfUpdatePlanState.Applying };
|
||||
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
return await RollbackApplyingAsync(plan, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public string GetTransactionDirectory(string transactionId)
|
||||
{
|
||||
RequireTransactionId(transactionId);
|
||||
return Path.Combine(TransactionsDirectory, transactionId);
|
||||
}
|
||||
|
||||
public string GetPayloadDirectory(string transactionId) =>
|
||||
Path.Combine(GetTransactionDirectory(transactionId), "payload");
|
||||
|
||||
public string GetBackupDirectory(string transactionId) =>
|
||||
Path.Combine(GetTransactionDirectory(transactionId), "backup");
|
||||
|
||||
public string GetConfirmationPath(string transactionId) =>
|
||||
Path.Combine(GetTransactionDirectory(transactionId), "confirmed");
|
||||
|
||||
public string GetHelperPath(string transactionId)
|
||||
{
|
||||
string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||
return Path.Combine(
|
||||
GetTransactionDirectory(transactionId),
|
||||
"acdream-self-update-helper" + suffix);
|
||||
}
|
||||
|
||||
private async Task<SelfUpdatePlan> RollbackApplyingAsync(
|
||||
SelfUpdatePlan plan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (plan.State != SelfUpdatePlanState.Applying || plan.Apply is null)
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update rollback journal is missing.");
|
||||
}
|
||||
|
||||
string payload = GetPayloadDirectory(plan.TransactionId);
|
||||
string backup = GetBackupDirectory(plan.TransactionId);
|
||||
foreach (SelfUpdateApplyEntry entry in plan.Apply.Reverse())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path);
|
||||
string targetPath = ClientVersionStore.ResolveContained(
|
||||
plan.TargetDirectory,
|
||||
entry.Path);
|
||||
string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path);
|
||||
if (!File.Exists(stagedPath) && File.Exists(targetPath))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!);
|
||||
File.Move(targetPath, stagedPath);
|
||||
}
|
||||
|
||||
if (entry.HadOriginal && File.Exists(backupPath))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||
File.Move(backupPath, targetPath);
|
||||
}
|
||||
else if (!entry.HadOriginal && File.Exists(targetPath))
|
||||
{
|
||||
File.Delete(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
SafeZipExtractor.TryDeleteDirectory(backup);
|
||||
plan = plan with
|
||||
{
|
||||
State = SelfUpdatePlanState.Staged,
|
||||
Apply = null,
|
||||
};
|
||||
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
return plan;
|
||||
}
|
||||
|
||||
private async Task VerifyPayloadAsync(
|
||||
SelfUpdatePlan plan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string payload = GetPayloadDirectory(plan.TransactionId);
|
||||
if (!Directory.Exists(payload))
|
||||
{
|
||||
throw new LauncherUpdateException("The staged launcher payload is missing.");
|
||||
}
|
||||
|
||||
ClientVersionStore.RejectReparseTree(payload);
|
||||
string[] actual = Directory.EnumerateFiles(
|
||||
payload,
|
||||
"*",
|
||||
SearchOption.AllDirectories)
|
||||
.Select(path => Path.GetRelativePath(payload, path).Replace('\\', '/'))
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] expected = plan.Files
|
||||
.Select(file => file.Path)
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (!actual.SequenceEqual(expected, StringComparer.Ordinal))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The staged launcher contains missing or unrecorded files.");
|
||||
}
|
||||
|
||||
foreach (InstalledFileRecord file in plan.Files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string path = ClientVersionStore.ResolveContained(payload, file.Path);
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists || info.Length != file.Size)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Staged launcher file '{file.Path}' size is corrupt.");
|
||||
}
|
||||
|
||||
string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync(
|
||||
path,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Staged launcher file '{file.Path}' SHA-256 is corrupt.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task VerifyAppliedTargetsAsync(
|
||||
SelfUpdatePlan plan,
|
||||
string targetDirectory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (InstalledFileRecord file in plan.Files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string path = ClientVersionStore.ResolveContained(targetDirectory, file.Path);
|
||||
EnsureSafeParent(targetDirectory, path);
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists
|
||||
|| (info.Attributes & FileAttributes.ReparsePoint) != 0
|
||||
|| info.Length != file.Size)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Applied launcher file '{file.Path}' is missing, linked, or corrupt.");
|
||||
}
|
||||
|
||||
string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync(
|
||||
path,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Applied launcher file '{file.Path}' SHA-256 is corrupt.");
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux()
|
||||
&& ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Applied launcher file '{file.Path}' mode is corrupt.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WritePlanAsync(
|
||||
SelfUpdatePlan plan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidatePlan(plan, plan.TargetDirectory);
|
||||
await AtomicJsonFile.WriteAsync(
|
||||
PendingPlanPath,
|
||||
plan,
|
||||
SerializerOptions,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void ValidatePlan(SelfUpdatePlan plan, string expectedTargetDirectory)
|
||||
{
|
||||
if (plan.SchemaVersion != SelfUpdatePlan.CurrentSchemaVersion)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Self-update schema version {plan.SchemaVersion} is not supported.");
|
||||
}
|
||||
|
||||
RequireTransactionId(plan.TransactionId);
|
||||
if (!LauncherVersion.TryParse(plan.Version, out _)
|
||||
|| !LauncherRuntimeIdentity.IsValidRid(plan.Rid)
|
||||
|| !ReleaseManifestClient.IsSha256(plan.ArchiveSha256)
|
||||
|| plan.ArchiveSize <= 0
|
||||
|| plan.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes)
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update plan metadata is invalid.");
|
||||
}
|
||||
|
||||
string target = NormalizeTargetDirectory(plan.TargetDirectory);
|
||||
if (!PathsEqual(target, expectedTargetDirectory))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update target does not match the running launcher directory.");
|
||||
}
|
||||
|
||||
if (plan.Files is null || plan.Files.Count == 0)
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update file list is empty.");
|
||||
}
|
||||
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
string? prior = null;
|
||||
foreach (InstalledFileRecord file in plan.Files)
|
||||
{
|
||||
if (!ClientVersionStore.IsNormalizedRelative(file.Path)
|
||||
|| !paths.Add(file.Path)
|
||||
|| !ReleaseManifestClient.IsSha256(file.Sha256)
|
||||
|| file.Size < 0
|
||||
|| file.UnixMode is < 0 or > 0x1FF
|
||||
|| (prior is not null
|
||||
&& string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update file list is invalid, duplicated, or unsorted.");
|
||||
}
|
||||
|
||||
prior = file.Path;
|
||||
}
|
||||
|
||||
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
|
||||
? ".exe"
|
||||
: string.Empty;
|
||||
if (!paths.Contains("acdream-launcher" + suffix))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update plan lacks the launcher root executable.");
|
||||
}
|
||||
|
||||
if (plan.State == SelfUpdatePlanState.Staged && plan.Apply is not null
|
||||
|| plan.State != SelfUpdatePlanState.Staged && plan.Apply is null)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update apply journal does not match its state.");
|
||||
}
|
||||
|
||||
if (plan.Apply is not null)
|
||||
{
|
||||
if (plan.Apply.Count != plan.Files.Count
|
||||
|| !plan.Apply.Select(entry => entry.Path)
|
||||
.SequenceEqual(plan.Files.Select(file => file.Path), StringComparer.Ordinal))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update apply journal does not match the file list.");
|
||||
}
|
||||
}
|
||||
|
||||
string transactionDirectory = GetTransactionDirectory(plan.TransactionId);
|
||||
if (!IsContained(TransactionsDirectory, transactionDirectory))
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update transaction path escaped.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeTargetDirectory(string targetDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetDirectory);
|
||||
if (!Path.IsPathFullyQualified(targetDirectory))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update target directory must be absolute.");
|
||||
}
|
||||
|
||||
string target = Path.TrimEndingDirectorySeparator(Path.GetFullPath(targetDirectory));
|
||||
if (!Directory.Exists(target)
|
||||
|| (File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update target directory is missing or is a reparse point.");
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
private static void EnsureSafeParent(string root, string filePath)
|
||||
{
|
||||
string? parent = Path.GetDirectoryName(filePath);
|
||||
if (parent is null)
|
||||
{
|
||||
throw new LauncherUpdateException("A self-update target has no parent.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(parent);
|
||||
for (var directory = new DirectoryInfo(parent);
|
||||
directory is not null && IsContained(root, directory.FullName);
|
||||
directory = directory.Parent)
|
||||
{
|
||||
if ((directory.Attributes & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Self-update target parent '{directory.FullName}' is a reparse point.");
|
||||
}
|
||||
|
||||
if (PathsEqual(directory.FullName, root))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsContained(string root, string path)
|
||||
{
|
||||
string fullRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
return PathsEqual(fullRoot, fullPath)
|
||||
|| fullPath.StartsWith(
|
||||
fullRoot + Path.DirectorySeparatorChar,
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool PathsEqual(string left, string right) =>
|
||||
string.Equals(
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal);
|
||||
|
||||
private static void RequireTransactionId(string transactionId)
|
||||
{
|
||||
if (transactionId.Length != 32
|
||||
|| !Guid.TryParseExact(transactionId, "N", out Guid parsed)
|
||||
|| !string.Equals(parsed.ToString("N"), transactionId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update transaction id is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupOwnedResidue(string? keepTransactionId)
|
||||
{
|
||||
if (Directory.Exists(TransactionsDirectory))
|
||||
{
|
||||
foreach (string directory in Directory.EnumerateDirectories(
|
||||
TransactionsDirectory,
|
||||
"*",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
string name = Path.GetFileName(directory);
|
||||
if (name.Length == 32
|
||||
&& Guid.TryParseExact(name, "N", out Guid transaction)
|
||||
&& string.Equals(
|
||||
transaction.ToString("N"),
|
||||
name,
|
||||
StringComparison.Ordinal)
|
||||
&& !string.Equals(name, keepTransactionId, StringComparison.Ordinal))
|
||||
{
|
||||
SafeZipExtractor.TryDeleteDirectory(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Directory.Exists(RootDirectory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string temporary in Directory.EnumerateFiles(
|
||||
RootDirectory,
|
||||
".pending.json.*.tmp",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
string name = Path.GetFileName(temporary);
|
||||
string prefix = ".pending.json.";
|
||||
string transaction = name[prefix.Length..^".tmp".Length];
|
||||
if (Guid.TryParseExact(transaction, "N", out _))
|
||||
{
|
||||
VerifiedArtifactDownloader.TryDelete(temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue