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 Files, IReadOnlyList? Apply) { public const int CurrentSchemaVersion = 1; } public sealed record SelfUpdateStageResult( LauncherVersion Version, string PendingPlanPath, string Status); /// /// 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. /// 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( 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 StageAsync( ReleaseManifest manifest, string rid, string targetDirectory, IProgress? 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 StageAsync( LauncherVersion version, string rid, ReleaseArtifact artifact, string targetDirectory, IProgress? 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 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 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( 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 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(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 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 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 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(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); } } } }