From 208a70ac83f4393be5c081d795ca06606f30afb6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 21:02:27 +0200 Subject: [PATCH] fix(launcher): guard orphan bake publication --- docs/architecture/acdream-architecture.md | 10 +- src/AcDream.Bake/AcDream.Bake.csproj | 2 + src/AcDream.Bake/BakeOutputTransaction.cs | 23 ++ src/AcDream.Bake/BakePublicationGuard.cs | 80 +++++++ .../AcDream.Launcher.Core.csproj | 1 + .../Installation/BakeOutputStagingContract.cs | 18 +- .../Installation/BakeProcessRunner.cs | 54 +++-- .../BakePublicationGuardContract.cs | 120 ++++++++++ .../Installation/LauncherInstaller.cs | 110 ++++++++-- .../BakePublicationGuardPaths.cs | 35 +++ ...e.Tests.Fixtures.InstallLeaseHolder.csproj | 5 + .../Program.cs | 206 ++++++++++++++++-- .../Installation/BakeProcessRunnerTests.cs | 46 ++++ .../Installation/LauncherInstallerTests.cs | 174 +++++++++++++++ 14 files changed, 829 insertions(+), 55 deletions(-) create mode 100644 src/AcDream.Bake/BakePublicationGuard.cs create mode 100644 src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs create mode 100644 src/AcDream.Platform/BakePublicationGuardPaths.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Installation/BakeProcessRunnerTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 34c9d23b..aab28285 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -278,6 +278,9 @@ src/ AcDream.Platform/ BCL-only portable path contract (Campaign LA LA0) ApplicationPathSet.cs -> shared XDG/Windows config, data, cache, plugin, screenshot, and diagnostic paths + BakePublicationGuardPaths.cs + -> shared launcher/Bake environment nonce and + adjacent publication lock/token naming contract -> zero project/package references (guarded by tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs); Runtime and App reference it directly; Headless reaches it @@ -295,7 +298,12 @@ src/ orchestration, and atomic SHA/size/tool-version install-record verification and recovery; one OS-handle lease serializes recovery/install per - DataDirectory, and only exact adjacent + DataDirectory; a second OS-held publication + lock plus durable per-transaction nonce makes + late orphan Bake children irrevocably stale + before recovery, while already-authorized + promotion completes before recovery; only exact + adjacent `..acdream-bake..tmp` files are transaction-owned crash residue -> references Platform only; no Avalonia or game-host dependency diff --git a/src/AcDream.Bake/AcDream.Bake.csproj b/src/AcDream.Bake/AcDream.Bake.csproj index 9b04f741..3557add4 100644 --- a/src/AcDream.Bake/AcDream.Bake.csproj +++ b/src/AcDream.Bake/AcDream.Bake.csproj @@ -12,6 +12,7 @@ + @@ -24,6 +25,7 @@ + diff --git a/src/AcDream.Bake/BakeOutputTransaction.cs b/src/AcDream.Bake/BakeOutputTransaction.cs index 54416ab5..34f5cf16 100644 --- a/src/AcDream.Bake/BakeOutputTransaction.cs +++ b/src/AcDream.Bake/BakeOutputTransaction.cs @@ -16,6 +16,21 @@ public static class BakeOutputTransaction Func writeTemporary, Action validateTemporary, CancellationToken cancellationToken = default) + => WriteValidateAndPublish( + destinationPath, + writeTemporary, + validateTemporary, + beforePublicationLock: null, + beforePromotion: null, + cancellationToken); + + internal static TResult WriteValidateAndPublish( + string destinationPath, + Func writeTemporary, + Action validateTemporary, + Action? beforePublicationLock, + Action? beforePromotion, + CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); ArgumentNullException.ThrowIfNull(writeTemporary); @@ -36,6 +51,14 @@ public static class BakeOutputTransaction cancellationToken.ThrowIfCancellationRequested(); validateTemporary(temporaryPath, result); cancellationToken.ThrowIfCancellationRequested(); + beforePublicationLock?.Invoke(); + using IDisposable? publication = + BakePublicationGuard.AcquireIfRequested( + fullDestination, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + beforePromotion?.Invoke(); + cancellationToken.ThrowIfCancellationRequested(); // Same-volume MoveFileEx/rename is the publication primitive. // File.Replace additionally performs destination metadata/backup diff --git a/src/AcDream.Bake/BakePublicationGuard.cs b/src/AcDream.Bake/BakePublicationGuard.cs new file mode 100644 index 00000000..0b35a811 --- /dev/null +++ b/src/AcDream.Bake/BakePublicationGuard.cs @@ -0,0 +1,80 @@ +using AcDream.Platform; + +namespace AcDream.Bake; + +/// +/// Optional launcher authorization checked immediately before atomic +/// publication. Standalone Bake runs have no nonce environment variable and +/// retain the original unguarded behavior. +/// +internal static class BakePublicationGuard +{ + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50); + + internal static IDisposable? AcquireIfRequested( + string outputPath, + CancellationToken cancellationToken) + { + string? nonce = Environment.GetEnvironmentVariable( + BakePublicationGuardPaths.NonceEnvironmentVariable); + if (nonce is null) + { + return null; + } + + if (!BakePublicationGuardPaths.IsValidNonce(nonce)) + { + throw new InvalidOperationException( + "The launcher bake publication nonce is invalid."); + } + + string lockPath = BakePublicationGuardPaths.GetPublishLockPath( + outputPath); + Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException( + "The bake publication lock has no parent directory.")); + + FileStream? lease = null; + while (lease is null) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + lease = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + options: FileOptions.None); + } + catch (IOException) + { + cancellationToken.WaitHandle.WaitOne(RetryDelay); + } + } + + try + { + string authorizationPath = + BakePublicationGuardPaths.GetAuthorizationPath(outputPath); + string authorized = File.Exists(authorizationPath) + ? File.ReadAllText(authorizationPath) + : string.Empty; + if (!string.Equals(authorized, nonce, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "This bake process is no longer authorized to publish its output."); + } + + return lease; + } + catch + { + lease.Dispose(); + throw; + } + } + +} diff --git a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj index 85dc49f0..df9c9ad1 100644 --- a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj +++ b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj @@ -14,6 +14,7 @@ + diff --git a/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs index a6ae15b4..cefa567e 100644 --- a/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs +++ b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs @@ -51,14 +51,22 @@ internal static class BakeOutputStagingContract } string destinationFileName = Path.GetFileName(fullDestination); - foreach (string candidate in Directory.EnumerateFiles(directory)) + try { - if (IsOwnedStagingFileName( - Path.GetFileName(candidate), - destinationFileName)) + foreach (string candidate in Directory.EnumerateFiles(directory)) { - LauncherInstallRecordStore.TryDelete(candidate); + if (IsOwnedStagingFileName( + Path.GetFileName(candidate), + destinationFileName)) + { + LauncherInstallRecordStore.TryDelete(candidate); + } } } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best effort: these files are never launchable. A later startup + // retries exact-name cleanup under the publication lock. + } } } diff --git a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs index 7c11ea15..24fbc48c 100644 --- a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs +++ b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Globalization; using System.Text; +using AcDream.Platform; namespace AcDream.Launcher.Core.Installation; @@ -9,7 +10,8 @@ public sealed record BakeProcessRequest( string ExecutablePath, string DatDirectory, string OutputPath, - int Threads) + int Threads, + string? PublicationNonce = null) { public IReadOnlyList Arguments => [ @@ -59,19 +61,7 @@ public sealed class SystemBakeProcessRunner : IBakeProcessRunner cancellationToken.ThrowIfCancellationRequested(); - var startInfo = new ProcessStartInfo - { - FileName = request.ExecutablePath, - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - foreach (string argument in request.Arguments) - { - startInfo.ArgumentList.Add(argument); - } + ProcessStartInfo startInfo = CreateStartInfo(request); using var process = new Process { StartInfo = startInfo }; if (!process.Start()) @@ -140,6 +130,42 @@ public sealed class SystemBakeProcessRunner : IBakeProcessRunner } } + internal static ProcessStartInfo CreateStartInfo(BakeProcessRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var startInfo = new ProcessStartInfo + { + FileName = request.ExecutablePath, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (string argument in request.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + startInfo.Environment.Remove( + BakePublicationGuardPaths.NonceEnvironmentVariable); + if (request.PublicationNonce is not null) + { + if (!BakePublicationGuardPaths.IsValidNonce( + request.PublicationNonce)) + { + throw new ArgumentException( + "The bake publication nonce is invalid.", + nameof(request)); + } + + startInfo.Environment[ + BakePublicationGuardPaths.NonceEnvironmentVariable] = + request.PublicationNonce; + } + + return startInfo; + } + private static async Task PumpAsync( TextReader reader, Action sink, diff --git a/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs b/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs new file mode 100644 index 00000000..a3f409c6 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs @@ -0,0 +1,120 @@ +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Installation; + +/// +/// Launcher half of the environment-only Bake publication guard. Paths are +/// derived from the canonical output path, while a durable GUID nonce grants +/// one child permission to promote its already-validated adjacent staging +/// file. Every token mutation happens while the stable publication lock is +/// held. +/// +internal static class BakePublicationGuardContract +{ + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50); + + internal static async ValueTask AcquireAsync( + string outputPath, + CancellationToken cancellationToken = default) + { + string lockPath = BakePublicationGuardPaths.GetPublishLockPath( + outputPath); + Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException( + "The bake publication lock has no parent directory.")); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + return new PublicationLease(new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + options: FileOptions.None)); + } + catch (IOException) + { + await Task.Delay(RetryDelay, cancellationToken) + .ConfigureAwait(false); + } + } + } + + internal static void Authorize( + string outputPath, + string nonce, + PublicationLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (!BakePublicationGuardPaths.IsValidNonce(nonce)) + { + throw new ArgumentException( + "The bake publication nonce must be a lowercase GUID in N format.", + nameof(nonce)); + } + + string authorizationPath = + BakePublicationGuardPaths.GetAuthorizationPath(outputPath); + using var stream = new FileStream( + authorizationPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + options: FileOptions.WriteThrough); + using var writer = new StreamWriter(stream, leaveOpen: true); + writer.Write(nonce); + writer.Flush(); + stream.Flush(flushToDisk: true); + } + + internal static void Invalidate( + string outputPath, + PublicationLease lease, + string? onlyIfNonceMatches = null) + { + ArgumentNullException.ThrowIfNull(lease); + string authorizationPath = + BakePublicationGuardPaths.GetAuthorizationPath(outputPath); + if (!File.Exists(authorizationPath)) + { + return; + } + + if (onlyIfNonceMatches is not null) + { + string current = File.ReadAllText(authorizationPath); + + if (!string.Equals( + current, + onlyIfNonceMatches, + StringComparison.Ordinal)) + { + return; + } + } + + File.Delete(authorizationPath); + } + + internal sealed class PublicationLease : IAsyncDisposable + { + private readonly FileStream _stream; + + internal PublicationLease(FileStream stream) + { + _stream = stream; + } + + public ValueTask DisposeAsync() + { + _stream.Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs index 488f03f5..f8de24a7 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs @@ -120,10 +120,9 @@ public sealed class LauncherInstaller : ILauncherInstaller _recordStore.DataDirectory, cancellationToken) .ConfigureAwait(false); - BakeOutputStagingContract.DeleteOwnedStagingFiles( - _recordStore.PreparedAssetPath); - InstallRecordVerification verification = await _recordStore - .LoadAndVerifyUnderLeaseAsync(cancellationToken) + InstallRecordVerification verification = + await RecoverExistingUnderPublicationGuardAsync( + cancellationToken) .ConfigureAwait(false); _verifiedRecord = verification.Record; return verification; @@ -197,9 +196,8 @@ public sealed class LauncherInstaller : ILauncherInstaller string outputPath = _recordStore.PreparedAssetPath; string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath); - BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); - InstallRecordVerification existing = await _recordStore - .LoadAndVerifyUnderLeaseAsync(cancellationToken) + InstallRecordVerification existing = + await RecoverExistingUnderPublicationGuardAsync(cancellationToken) .ConfigureAwait(false); _verifiedRecord = existing.Record; @@ -220,6 +218,7 @@ public sealed class LauncherInstaller : ILauncherInstaller var parser = new BakeProgressJsonlParser(); var protocol = new BakeProgressProtocol(); + string? publicationNonce = null; void Observe(BakeProgressEvent progressEvent) { @@ -265,11 +264,26 @@ public sealed class LauncherInstaller : ILauncherInstaller try { cancellationToken.ThrowIfCancellationRequested(); + publicationNonce = BakePublicationGuardPaths.CreateNonce(); + await using ( + BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + cancellationToken) + .ConfigureAwait(false)) + { + BakePublicationGuardContract.Authorize( + outputPath, + publicationNonce, + publication); + } + var request = new BakeProcessRequest( _bakeExecutablePath, validation.Directory, outputPath, - threads); + threads, + publicationNonce); BakeProcessResult processResult = await _processRunner.RunAsync( request, chunk => @@ -370,7 +384,11 @@ public sealed class LauncherInstaller : ILauncherInstaller .ConfigureAwait(false); _verifiedRecord = record; - LauncherInstallRecordStore.TryDelete(backupPath); + await FinalizeSuccessfulPublicationAsync( + outputPath, + backupPath, + publicationNonce) + .ConfigureAwait(false); Report( progress, LauncherInstallPhase.Completed, @@ -381,7 +399,12 @@ public sealed class LauncherInstaller : ILauncherInstaller } catch (OperationCanceledException) { - RestorePreviousPackage(outputPath, backupPath, previousPreserved); + await FinalizeFailedPublicationAsync( + outputPath, + backupPath, + previousPreserved, + publicationNonce) + .ConfigureAwait(false); Report( progress, LauncherInstallPhase.Cancelled, @@ -390,7 +413,12 @@ public sealed class LauncherInstaller : ILauncherInstaller } catch (Exception ex) { - RestorePreviousPackage(outputPath, backupPath, previousPreserved); + await FinalizeFailedPublicationAsync( + outputPath, + backupPath, + previousPreserved, + publicationNonce) + .ConfigureAwait(false); Report( progress, LauncherInstallPhase.Failed, @@ -402,10 +430,62 @@ public sealed class LauncherInstaller : ILauncherInstaller throw new LauncherInstallException("Installation failed.", ex); } - finally - { - BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); - } + } + + private async Task + RecoverExistingUnderPublicationGuardAsync( + CancellationToken cancellationToken) + { + string outputPath = _recordStore.PreparedAssetPath; + await using BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + cancellationToken) + .ConfigureAwait(false); + // Any child whose parent died before it acquired this lock is now + // irrevocably stale. A child already holding the lock must finish its + // promotion before recovery reaches this invalidation point. + BakePublicationGuardContract.Invalidate(outputPath, publication); + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + return await _recordStore.LoadAndVerifyUnderLeaseAsync(cancellationToken) + .ConfigureAwait(false); + } + + private static async Task FinalizeSuccessfulPublicationAsync( + string outputPath, + string backupPath, + string publicationNonce) + { + await using BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + CancellationToken.None) + .ConfigureAwait(false); + BakePublicationGuardContract.Invalidate( + outputPath, + publication, + publicationNonce); + LauncherInstallRecordStore.TryDelete(backupPath); + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + } + + private static async Task FinalizeFailedPublicationAsync( + string outputPath, + string backupPath, + bool previousPreserved, + string? publicationNonce) + { + await using BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + CancellationToken.None) + .ConfigureAwait(false); + BakePublicationGuardContract.Invalidate( + outputPath, + publication, + publicationNonce); + RestorePreviousPackage(outputPath, backupPath, previousPreserved); + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); } private bool PreservePreviousPackage(string outputPath, string backupPath) diff --git a/src/AcDream.Platform/BakePublicationGuardPaths.cs b/src/AcDream.Platform/BakePublicationGuardPaths.cs new file mode 100644 index 00000000..75f46ae3 --- /dev/null +++ b/src/AcDream.Platform/BakePublicationGuardPaths.cs @@ -0,0 +1,35 @@ +namespace AcDream.Platform; + +/// +/// Portable, versioned naming contract shared by the launcher parent and the +/// independently published bake child. The durable token grants one child +/// permission to publish while the adjacent OS-held lock serializes its final +/// promotion with launcher recovery. +/// +public static class BakePublicationGuardPaths +{ + public const string NonceEnvironmentVariable = + "ACDREAM_BAKE_PUBLISH_NONCE_V1"; + public const string PublishLockSuffix = ".publish.lock"; + public const string AuthorizationSuffix = ".publish-token"; + + public static string CreateNonce() => Guid.NewGuid().ToString("N"); + + public static bool IsValidNonce(string? nonce) => + nonce is not null + && nonce.Length == 32 + && Guid.TryParseExact(nonce, "N", out Guid parsed) + && string.Equals(parsed.ToString("N"), nonce, StringComparison.Ordinal); + + public static string GetPublishLockPath(string outputPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + return Path.GetFullPath(outputPath) + PublishLockSuffix; + } + + public static string GetAuthorizationPath(string outputPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + return Path.GetFullPath(outputPath) + AuthorizationSuffix; + } +} diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj index bc7176f0..b64e0eaa 100644 --- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj @@ -8,4 +8,9 @@ false true + + + + + diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs index 73207808..c56f94cd 100644 --- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs @@ -1,24 +1,190 @@ -if (args.Length != 3) +using System.Diagnostics; +using System.Reflection; +using AcDream.Bake; +using AcDream.Launcher.Core.Installation; +using AcDream.Platform; + +return args.FirstOrDefault() switch { - return 2; + "hold-install-lease" => await HoldInstallLeaseAsync(args[1..]), + "orphan-parent" => await RunOrphanParentAsync(args[1..]), + "orphan-child" => RunOrphanChild(args[1..]), + _ => 2, +}; + +static async Task HoldInstallLeaseAsync(string[] arguments) +{ + if (arguments.Length != 3) + { + return 2; + } + + string lockPath = Path.GetFullPath(arguments[0]); + string stagingPath = Path.GetFullPath(arguments[1]); + string readyPath = Path.GetFullPath(arguments[2]); + Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException("lock path has no parent")); + Directory.CreateDirectory( + Path.GetDirectoryName(stagingPath) + ?? throw new InvalidOperationException("staging path has no parent")); + + using var lease = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None); + File.WriteAllText(stagingPath, "abandoned bake staging"); + File.WriteAllText(readyPath, "ready"); + await Task.Delay(Timeout.InfiniteTimeSpan); + return 0; } -string lockPath = Path.GetFullPath(args[0]); -string stagingPath = Path.GetFullPath(args[1]); -string readyPath = Path.GetFullPath(args[2]); -Directory.CreateDirectory( - Path.GetDirectoryName(lockPath) - ?? throw new InvalidOperationException("lock path has no parent")); -Directory.CreateDirectory( - Path.GetDirectoryName(stagingPath) - ?? throw new InvalidOperationException("staging path has no parent")); +static async Task RunOrphanParentAsync(string[] arguments) +{ + if (arguments.Length != 8) + { + return 2; + } -using var lease = new FileStream( - lockPath, - FileMode.OpenOrCreate, - FileAccess.ReadWrite, - FileShare.None); -File.WriteAllText(stagingPath, "abandoned bake staging"); -File.WriteAllText(readyPath, "ready"); -await Task.Delay(Timeout.InfiniteTimeSpan); -return 0; + string dataDirectory = Path.GetFullPath(arguments[0]); + string datDirectory = Path.GetFullPath(arguments[1]); + string bakeMarker = Path.GetFullPath(arguments[2]); + string schedule = arguments[3]; + string childReadyPath = Path.GetFullPath(arguments[4]); + string childReleasePath = Path.GetFullPath(arguments[5]); + string childPidPath = Path.GetFullPath(arguments[6]); + string childExitPath = Path.GetFullPath(arguments[7]); + var paths = new ApplicationPathSet( + Path.Combine(dataDirectory, "fixture-config"), + dataDirectory, + Path.Combine(dataDirectory, "fixture-cache"), + null); + var runner = new OrphanBakeProcessRunner( + schedule, + childReadyPath, + childReleasePath, + childPidPath, + childExitPath); + var installer = new LauncherInstaller( + paths, + bakeMarker, + processRunner: runner); + + try + { + await installer.InstallAsync(datDirectory, 1); + return 0; + } + catch + { + return 9; + } +} + +static int RunOrphanChild(string[] arguments) +{ + if (arguments.Length != 5) + { + return 2; + } + + string outputPath = Path.GetFullPath(arguments[0]); + string schedule = arguments[1]; + string readyPath = Path.GetFullPath(arguments[2]); + string releasePath = Path.GetFullPath(arguments[3]); + string exitPath = Path.GetFullPath(arguments[4]); + Action barrier = () => + { + File.WriteAllText(readyPath, schedule); + while (!File.Exists(releasePath)) + { + Thread.Sleep(10); + } + }; + + int exitCode; + try + { + BakeOutputTransaction.WriteValidateAndPublish( + outputPath, + temporaryPath => + { + File.WriteAllText(temporaryPath, "orphan replacement"); + return 1; + }, + (temporaryPath, _) => + { + if (File.ReadAllText(temporaryPath) != "orphan replacement") + { + throw new InvalidDataException("staging content changed"); + } + }, + beforePublicationLock: schedule == "late" ? barrier : null, + beforePromotion: schedule == "holds" ? barrier : null, + CancellationToken.None); + exitCode = 0; + } + catch (Exception ex) + { + File.WriteAllText(exitPath + ".error", ex.Message); + exitCode = 17; + } + + File.WriteAllText(exitPath, exitCode.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + return exitCode; +} + +file sealed class OrphanBakeProcessRunner( + string schedule, + string childReadyPath, + string childReleasePath, + string childPidPath, + string childExitPath) : IBakeProcessRunner +{ + public async Task RunAsync( + BakeProcessRequest request, + Action onStandardOutput, + CancellationToken cancellationToken = default) + { + string dotnetHost = Environment.ProcessPath + ?? throw new InvalidOperationException("dotnet host path is unavailable"); + string fixtureDll = Assembly.GetExecutingAssembly().Location; + var startInfo = new ProcessStartInfo(dotnetHost) + { + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add(fixtureDll); + startInfo.ArgumentList.Add("orphan-child"); + startInfo.ArgumentList.Add(request.OutputPath); + startInfo.ArgumentList.Add(schedule); + startInfo.ArgumentList.Add(childReadyPath); + startInfo.ArgumentList.Add(childReleasePath); + startInfo.ArgumentList.Add(childExitPath); + startInfo.Environment.Remove( + BakePublicationGuardPaths.NonceEnvironmentVariable); + startInfo.Environment[ + BakePublicationGuardPaths.NonceEnvironmentVariable] = + request.PublicationNonce + ?? throw new InvalidOperationException("publication nonce is missing"); + + using Process child = Process.Start(startInfo) + ?? throw new InvalidOperationException("orphan child did not start"); + File.WriteAllText( + childPidPath, + child.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)); + await child.WaitForExitAsync(cancellationToken); + if (child.ExitCode == 0) + { + long bytes = new FileInfo(request.OutputPath).Length; + onStandardOutput("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n"); + onStandardOutput($"{{\"v\":1,\"e\":\"completed\"," + + $"\"bakeToolVersion\":4,\"outputBytes\":{bytes}," + + "\"failures\":0}\n"); + } + + return new BakeProcessResult(child.ExitCode, "orphan fixture child"); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/BakeProcessRunnerTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProcessRunnerTests.cs new file mode 100644 index 00000000..35dee17e --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProcessRunnerTests.cs @@ -0,0 +1,46 @@ +using AcDream.Launcher.Core.Installation; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Tests.Installation; + +public sealed class BakeProcessRunnerTests +{ + [Fact] + public void PublicationNonceIsEnvironmentOnlyAndVisibleArgumentsStayPinned() + { + string nonce = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef") + .ToString("N"); + var request = new BakeProcessRequest( + "acdream-bake", + "retail-dats", + "data/pak/acdream.pak", + 7, + nonce); + + System.Diagnostics.ProcessStartInfo startInfo = + SystemBakeProcessRunner.CreateStartInfo(request); + + Assert.Equal(request.Arguments, startInfo.ArgumentList); + Assert.DoesNotContain(nonce, startInfo.ArgumentList); + Assert.Equal( + nonce, + startInfo.Environment[ + BakePublicationGuardPaths.NonceEnvironmentVariable]); + } + + [Fact] + public void UnguardedRequestExplicitlyRemovesInheritedAuthorization() + { + var request = new BakeProcessRequest( + "acdream-bake", + "retail-dats", + "data/pak/acdream.pak", + 1); + + System.Diagnostics.ProcessStartInfo startInfo = + SystemBakeProcessRunner.CreateStartInfo(request); + + Assert.False(startInfo.Environment.ContainsKey( + BakePublicationGuardPaths.NonceEnvironmentVariable)); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs index 0ee05773..4e48b069 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs @@ -85,6 +85,11 @@ public sealed class LauncherInstallerTests : IDisposable "--progress-json", ], observedRequest.Arguments); + Assert.True(BakePublicationGuardPaths.IsValidNonce( + observedRequest.PublicationNonce)); + Assert.DoesNotContain( + observedRequest.PublicationNonce!, + observedRequest.Arguments); Assert.Equal(LauncherInstallRecordStore.CurrentBakeToolVersion, result.Record.BakeToolVersion); Assert.Equal(new FileInfo(result.Record.PreparedAssetPath).Length, @@ -94,6 +99,9 @@ public sealed class LauncherInstallerTests : IDisposable result.Record.PreparedAssetSha256); Assert.Contains(progress, value => value.Phase == LauncherInstallPhase.BakingMeshes); Assert.Equal(LauncherInstallPhase.Completed, progress[^1].Phase); + Assert.False(File.Exists( + BakePublicationGuardPaths.GetAuthorizationPath( + result.Record.PreparedAssetPath))); var store = new LauncherInstallRecordStore(_paths); InstallRecordVerification verification = await store.LoadAndVerifyAsync(); @@ -434,6 +442,7 @@ public sealed class LauncherInstallerTests : IDisposable UseShellExecute = false, }; startInfo.ArgumentList.Add(fixtureDll); + startInfo.ArgumentList.Add("hold-install-lease"); startInfo.ArgumentList.Add( InstallerTransactionLease.GetLockPath(store.DataDirectory)); startInfo.ArgumentList.Add(staging); @@ -479,6 +488,144 @@ public sealed class LauncherInstallerTests : IDisposable await File.ReadAllTextAsync(store.PreparedAssetPath)); } + [Theory] + [InlineData("holds", 0)] + [InlineData("late", 17)] + public async Task OrphanBakeCanNeverPublishAfterRestartRecovery( + string schedule, + int expectedChildExitCode) + { + var store = new LauncherInstallRecordStore(_paths); + LauncherInstallRecord old = await CreatePriorRecordAsync(store); + string recordBefore = await File.ReadAllTextAsync(store.RecordPath); + string control = Path.Combine(_root, "orphan-" + schedule); + Directory.CreateDirectory(control); + string ready = Path.Combine(control, "child-ready"); + string release = Path.Combine(control, "child-release"); + string childPid = Path.Combine(control, "child-pid"); + string childExit = Path.Combine(control, "child-exit"); + string fixtureDll = GetInstallLeaseFixturePath(); + var startInfo = new ProcessStartInfo("dotnet") + { + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + foreach (string argument in new[] + { + fixtureDll, + "orphan-parent", + store.DataDirectory, + _dats, + _bakeExecutable, + schedule, + ready, + release, + childPid, + childExit, + }) + { + startInfo.ArgumentList.Add(argument); + } + + using Process parent = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start orphan parent."); + int orphanPid = 0; + try + { + await WaitForFileAsync(ready, parent, TimeSpan.FromSeconds(15)); + orphanPid = int.Parse( + await File.ReadAllTextAsync(childPid), + System.Globalization.CultureInfo.InvariantCulture); + Assert.True(File.Exists( + LauncherInstallRecordStore.GetBackupPath( + store.PreparedAssetPath))); + Assert.True(File.Exists( + BakePublicationGuardPaths.GetAuthorizationPath( + store.PreparedAssetPath))); + + parent.Kill(entireProcessTree: false); + await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + + var restarted = new LauncherInstaller( + _paths, + _bakeExecutable, + recordStore: new LauncherInstallRecordStore(_paths)); + Task recovery = + restarted.LoadExistingAsync(); + InstallRecordVerification recovered; + if (schedule == "holds") + { + await Task.Delay(200); + Assert.False(recovery.IsCompleted); + File.WriteAllText(release, "release"); + recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15)); + } + else + { + recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15)); + Assert.False(File.Exists( + BakePublicationGuardPaths.GetAuthorizationPath( + store.PreparedAssetPath))); + File.WriteAllText(release, "release"); + } + + Assert.True(recovered.IsVerified); + Assert.Equal(old, recovered.Record); + string canonicalAfterRecovery = + await File.ReadAllTextAsync(store.PreparedAssetPath); + string recordAfterRecovery = + await File.ReadAllTextAsync(store.RecordPath); + bool backupAfterRecovery = File.Exists( + LauncherInstallRecordStore.GetBackupPath( + store.PreparedAssetPath)); + + await WaitForFileAsync(childExit, TimeSpan.FromSeconds(15)); + Assert.Equal( + expectedChildExitCode, + int.Parse( + await File.ReadAllTextAsync(childExit), + System.Globalization.CultureInfo.InvariantCulture)); + if (schedule == "late") + { + Assert.Contains( + "no longer authorized", + await File.ReadAllTextAsync(childExit + ".error"), + StringComparison.OrdinalIgnoreCase); + } + await Task.Delay(200); + + Assert.Equal( + canonicalAfterRecovery, + await File.ReadAllTextAsync(store.PreparedAssetPath)); + Assert.Equal("previous verified package", canonicalAfterRecovery); + Assert.Equal(recordBefore, recordAfterRecovery); + Assert.Equal(recordAfterRecovery, await File.ReadAllTextAsync(store.RecordPath)); + Assert.Equal( + backupAfterRecovery, + File.Exists(LauncherInstallRecordStore.GetBackupPath( + store.PreparedAssetPath))); + Assert.False(backupAfterRecovery); + Assert.False(File.Exists( + BakePublicationGuardPaths.GetAuthorizationPath( + store.PreparedAssetPath))); + } + finally + { + File.WriteAllText(release, "release"); + if (!parent.HasExited) + { + parent.Kill(entireProcessTree: false); + await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + + if (orphanPid != 0 && !File.Exists(childExit)) + { + TryKill(orphanPid); + } + } + } + private async Task<( LauncherInstaller Installer, LauncherInstallRecordStore Store, @@ -548,6 +695,33 @@ public sealed class LauncherInstallerTests : IDisposable } } + private static async Task WaitForFileAsync(string path, TimeSpan timeout) + { + using var cancellation = new CancellationTokenSource(timeout); + while (!File.Exists(path)) + { + await Task.Delay(25, cancellation.Token); + } + } + + private static void TryKill(int processId) + { + try + { + using Process process = Process.GetProcessById(processId); + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(5_000); + } + } + catch + { + // The orphan normally exits by itself; cleanup tolerates the + // expected race with Process.GetProcessById. + } + } + private static string GetInstallLeaseFixturePath() { string root = FindRepositoryRoot();