From accd01a00878643ad1b59be55aca161abf3ce817 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 01:08:41 +0200 Subject: [PATCH] fix(launcher): harden Campaign LA11 gate evidence --- .../2026-08-14-campaign-la-test-script.md | 80 ++++- .../Updates/LauncherSelfUpdateBootstrap.cs | 221 ++++++++----- .../Updates/LauncherSelfUpdateManager.cs | 33 ++ .../Updates/UpdateSessionBarrier.cs | 37 +++ .../LauncherStartupOptions.cs | 15 - .../Program.cs | 34 +- .../Updates/LauncherSelfUpdateManagerTests.cs | 225 ++++++++++++- .../Updates/LauncherSelfUpdateProcessTests.cs | 189 +++++++++-- .../LauncherStartupOptionsTests.cs | 17 +- tools/CampaignLaProcessCorrelation.ps1 | 93 ++++++ tools/capture-campaign-la-session-process.ps1 | 109 +++++++ tools/new-campaign-la-update-fixture.ps1 | 167 ++++++++-- tools/run-campaign-la-preflight.ps1 | 112 ++++++- tools/test-campaign-la-gate-helpers.ps1 | 296 ++++++++++++++++++ tools/test-campaign-la-script-safety.ps1 | 254 +++++++++++++++ tools/test-campaign-la-session-status.ps1 | 148 +++++++-- 16 files changed, 1820 insertions(+), 210 deletions(-) create mode 100644 tools/CampaignLaProcessCorrelation.ps1 create mode 100644 tools/capture-campaign-la-session-process.ps1 create mode 100644 tools/test-campaign-la-gate-helpers.ps1 create mode 100644 tools/test-campaign-la-script-safety.ps1 diff --git a/docs/research/2026-08-14-campaign-la-test-script.md b/docs/research/2026-08-14-campaign-la-test-script.md index 25109d73..0377c261 100644 --- a/docs/research/2026-08-14-campaign-la-test-script.md +++ b/docs/research/2026-08-14-campaign-la-test-script.md @@ -53,6 +53,7 @@ New-Item -ItemType Directory -Path $Gate | Out-Null pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') ` -Repository $Repo ` + -AllowedOutputRoot $Gate ` -OutputDirectory $Preflight $Report = Get-Content -LiteralPath (Join-Path $Preflight 'report.json') -Raw | @@ -79,8 +80,13 @@ The expected matrix is: `report.json` records the tested HEAD/dirty state, OS/RID, exact commands, durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight -plans 30 rows. It never launches App or Headless in connected mode and -never reads a credential. Every child starts with all inherited `ACDREAM_*` +plans 32 rows, including the connection-free PID/status/redaction and script- +safety contract suites. `-AllowedOutputRoot` must be a fresh, explicit +`campaign-la-*` gate root (or the repository `logs` root), and output must be a +fresh, empty, non-reparse strict descendant; repository, home, source, payload, +nonempty, and arbitrary existing directories are rejected. The helper never +launches App or Headless in connected mode and never reads a credential. Every +child starts with all inherited `ACDREAM_*` variables removed, so a developer shell cannot accidentally enable live, installed-DAT, fixture-regeneration, or diagnostic gates. Only the optional row below adds the two named DAT variables back for its three exact tests. @@ -93,6 +99,7 @@ the DAT directory may be read by tests: ```powershell pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') ` -Repository $Repo ` + -AllowedOutputRoot $Gate ` -OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') ` -IncludeInstalledDat ` -InstalledDatDirectory '' @@ -103,7 +110,7 @@ The mandatory installed-DAT result is `ACDREAM_DAT_DIR` set inside the child environment. The helper reads the TRX and fails if the test skipped or did anything other than pass. The action-map and portal-asset probes are additional coverage, never substitutes. Expected -matrix size: 34 rows. +matrix size: 36 rows. On Ubuntu/WSL, invoke the same script with native `pwsh`, a Linux repository path, and a Linux output path. Do not treat a Windows-hosted run over @@ -170,8 +177,11 @@ pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1 ``` The helper rejects nonempty output, invalid or non-monotonic versions, missing -root executables (including the co-deployed Bake CLI), and nonabsolute inputs. -It writes fixed-timestamp sorted ZIPs, +root executables (including the co-deployed Bake CLI), nonabsolute inputs, +output/source overlap in either direction, and any reparse point in source or +output ancestry. It enumerates normalized relative paths with ordinal ordering, +never its own output, and normalizes ZIP host metadata so Windows/Linux hashes +are identical under multiple cultures. It writes fixed-timestamp sorted ZIPs, the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only server (with optional bounded `-MaximumRequests` smoke mode), and an atomic A/B selector. Both generated helpers reject a `-Root` other than their own fixture @@ -221,24 +231,56 @@ must preserve the same validated suffix through helper and confirmation restarts. The launcher, profiles, installer, current-version store, updater, session composer, and orchestrator must all use this one exact path set. -For every play/probe row, copy the session id shown in the launcher's Sessions -list into ``, then run: +For every play/probe row, start this gate-only PID watcher immediately before +clicking Refresh/Play. It correlates only the unique isolated session-config +path, records neither command line nor config contents, and must finish while +the child is still live: ```powershell -$Status = Join-Path $WinCache 'launcher/sessions//status.jsonl' +$CapturePath = Join-Path $Evidence '-process.capture.json' +$CaptureStart = [DateTimeOffset]::UtcNow +$CaptureInfo = [Diagnostics.ProcessStartInfo]::new() +$CaptureInfo.FileName = (Get-Command pwsh).Source +$CaptureInfo.UseShellExecute = $false +$CaptureInfo.CreateNoWindow = $true +foreach ($Value in @( + '-NoProfile', '-File', (Join-Path $Repo 'tools/capture-campaign-la-session-process.ps1'), + '-SessionsDirectory', (Join-Path $WinCache 'launcher/sessions'), + '-CreatedAfterUtc', $CaptureStart.ToString('O'), + '-ReportPath', $CapturePath, '-WaitSeconds', '60')) { + $CaptureInfo.ArgumentList.Add($Value) +} +$CaptureProcess = [Diagnostics.Process]::Start($CaptureInfo) +# Click exactly one Refresh/Play action now, then wait for capture. +$CaptureProcess.WaitForExit() +if ($CaptureProcess.ExitCode) { throw 'Stop: live child PID capture failed.' } +$Capture = Get-Content -LiteralPath $CapturePath -Raw | ConvertFrom-Json +$SessionConfig = Join-Path $WinCache "launcher/sessions/$($Capture.sessionId)/session.json" +$Status = Join-Path $WinCache "launcher/sessions/$($Capture.sessionId)/status.jsonl" + +# After Stop and terminal status: pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` -StatusFile $Status ` -Mode '' ` - -ExpectedSessionId '' ` + -ExpectedProcessId $Capture.processId ` + -SessionConfigPath $SessionConfig ` + -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` + -ExpectedSessionId $Capture.sessionId ` -ReportPath (Join-Path $Evidence '-status.validation.json') ``` Add `-ExpectedPlugin acdream.smoke` to rows D–F. The validator enforces exact v1 fields **and property order**, one session id, UTC monotonic timestamps, mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login -command failure, credential redaction, and no surviving App/Headless process. -Its report contains event names and a hash, not account, character, command, or -error payloads. Keep raw `session.json`/`status.jsonl` local; never upload them. +command failure, exact terminal `disconnected.reason == stopped`, credential +redaction, and that exact captured PID is gone. Optional config-path correlation +uses Windows CIM or Linux `/proc/*/cmdline`; it never globally scans a process +name, so unrelated same-name processes and Linux's 15-character names do not +affect the result. The validator verifies owner-only profile access, reads only +password/secret fields in memory, recursively checks every allowed status +string (including command/error text), and reports only the forbidden-value +count and status hash—never credential content or a credential hash. Keep the +profile and raw `session.json`/`status.jsonl` local; never upload them. ## 5. Serial Windows user rows A–H @@ -374,8 +416,10 @@ called. This connected row proves the actual ACE graceful-logout half. Issue 2. Confirm ordinary selection enables Enter/Delete and disables Restore. Click Delete, inspect the retail confirmation dialog, cancel once, and confirm no state change. -3. Delete again and confirm. Verify the wait dialog, greyed roster row/countdown, - disabled Enter/Delete, and enabled Restore. Save `G-deleted.png`. +3. Delete again and confirm. Verify the wait dialog, greyed/pending-delete + roster state, constant boolean-ish nonzero `secondsGreyedOut`, disabled + Enter/Delete, and enabled Restore. The UI must display no countdown. Save + `G-deleted.png`. 4. Click Restore and confirm the same GUID returns to ordinary state with Enter/Delete enabled and Restore disabled. Save `G-restored.png`. 5. Close through launcher **Stop**, confirm graceful terminal status and ACE @@ -385,6 +429,9 @@ called. This connected row proves the actual ACE graceful-logout half. Issue pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` -StatusFile (Join-Path $WinCache 'launcher/sessions//status.jsonl') ` -Mode guiSelect ` + -ExpectedProcessId '' ` + -SessionConfigPath (Join-Path $WinCache 'launcher/sessions//session.json') ` + -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` -ExpectNoEnteredWorld ` -ExpectedSessionId '' ` -ExpectedPlugin acdream.smoke ` @@ -489,7 +536,9 @@ Complete this exact serial matrix: `stat -c '%a' "$LinuxConfig/launcher-profiles.json"`; the exact result must be `600`. 3. **Probe twice:** run Refresh twice, validate both status streams in `probe` - mode with native `pwsh`, and confirm ACE clears the account after each. + mode with native `pwsh`, using the same pre-action watcher and exact PID, + Linux session-config path, and `$LinuxConfig/launcher-profiles.json`; confirm + ACE clears the account after each. 4. **Platform posture:** confirm GUI and GUI-select client buttons are disabled and show the explicit Modern Runtime Slice-L message. Do not bypass this disablement and do not claim a Linux graphical-client gate. @@ -521,6 +570,7 @@ logs/campaign-la-user-gate-/ evidence/A-install-hashes.json evidence/B-*.png evidence/C-probe-{1,2}-status.validation.json + evidence/*-process.capture.json evidence/D-*.png + D-status.validation.json evidence/E-*.png + E-status.validation.json evidence/F-*.png + F-status.validation.json diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs index c37b51e8..6decb4b9 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs @@ -15,10 +15,9 @@ public static class LauncherSelfUpdateBootstrap { public const string HelperArgument = "--acdream-self-update-helper-v1"; public const string ConfirmArgument = "--acdream-self-update-confirm-v1"; - internal const string DeferredArgument = "--acdream-self-update-deferred-v1"; - internal const int DeferredLeaseExitCode = 73; + internal const int UpdateLeaseBusyExitCode = 73; + private const string InternalArgumentPrefix = "--acdream-self-update-"; private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30); - private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5); public static async Task HandleAsync( string[] args, @@ -33,12 +32,6 @@ public static class LauncherSelfUpdateBootstrap Path.GetFullPath(launcherBaseDirectory)); string executable = Path.GetFullPath(currentExecutablePath); - if (args.Length > 0 - && string.Equals(args[0], DeferredArgument, StringComparison.Ordinal)) - { - return new SelfUpdateStartupResult(false, 0, args[1..]); - } - if (args.Length > 0 && string.Equals(args[0], HelperArgument, StringComparison.Ordinal)) { @@ -55,6 +48,8 @@ public static class LauncherSelfUpdateBootstrap int exitCode = await RunHelperAsync( manager, + baseDirectory, + executable, parentPid, args[2], args[3], @@ -72,26 +67,72 @@ public static class LauncherSelfUpdateBootstrap return new SelfUpdateStartupResult(true, 64, []); } + if (manager.Barrier.TryAcquireSession( + out UpdateSessionBarrier.SessionLease? unexpectedSharedLease)) + { + unexpectedSharedLease?.Dispose(); + throw new LauncherUpdateException( + "Self-update confirmation is trusted only while its helper owns " + + "the exclusive update lease."); + } + await manager.ConfirmAsync( args[1], baseDirectory, executable, cancellationToken) .ConfigureAwait(false); - await FinishConfirmedCleanupAsync( - manager, - baseDirectory, - cancellationToken) - .ConfigureAwait(false); + // The helper that owns the exclusive lease observes this durable + // receipt and performs authoritative completion. A later ordinary + // startup also completes it if that helper crashes after receipt. return new SelfUpdateStartupResult(false, 0, args[2..]); } + if (args.Length > 0 + && args[0].StartsWith(InternalArgumentPrefix, StringComparison.Ordinal)) + { + // Internal modes are an exact vocabulary. In particular, an old + // deferred-restart marker must never become an authorization to + // skip a pending recovery state. + return new SelfUpdateStartupResult(true, 64, []); + } + + // Load first: an invalid/ambiguous journal must fail closed even when + // another process currently owns the update barrier. + _ = await manager.LoadPendingAsync(cancellationToken).ConfigureAwait(false); + if (!manager.Barrier.TryAcquireExclusive( out UpdateSessionBarrier.ExclusiveLease? startupLease)) { - // A running session or another launcher is staging. Reading the - // plan is safe, but cleanup or starting a competing helper is not. - return new SelfUpdateStartupResult(false, 0, args); + if (!manager.Barrier.TryAcquireSession( + out UpdateSessionBarrier.SessionLease? sharedLease)) + { + throw new LauncherUpdateException( + "Launcher startup is blocked by an active update or recovery transaction."); + } + + using (sharedLease + ?? throw new InvalidOperationException("Shared startup lease is missing.")) + { + SelfUpdatePlan? blockedPlan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + if (blockedPlan is null) + { + return new SelfUpdateStartupResult(false, 0, args); + } + + ValidateCanonicalStartup(blockedPlan, baseDirectory, executable); + if (blockedPlan.State != SelfUpdatePlanState.Staged) + { + throw new LauncherUpdateException( + $"Self-update state '{blockedPlan.State}' requires exclusive recovery."); + } + + // A verified staged update may wait while an already-running + // session holds the shared lease. No helper is spawned, so a + // late session lease cannot create a restart loop. + return new SelfUpdateStartupResult(false, 0, args); + } } using (UpdateSessionBarrier.ExclusiveLease lease = startupLease @@ -108,11 +149,7 @@ public static class LauncherSelfUpdateBootstrap return new SelfUpdateStartupResult(false, 0, args); } - if (!PathsEqual(plan.TargetDirectory, baseDirectory)) - { - throw new LauncherUpdateException( - "The pending self-update targets a different launcher directory."); - } + ValidateCanonicalStartup(plan, baseDirectory, executable); if (plan.State == SelfUpdatePlanState.AwaitingConfirmation) { @@ -138,13 +175,40 @@ public static class LauncherSelfUpdateBootstrap return new SelfUpdateStartupResult(false, 0, args); } - string expectedExecutable = ClientVersionStore.ResolveContained( - baseDirectory, - GetLauncherFileName(plan.Rid)); - if (!PathsEqual(executable, expectedExecutable)) + if (plan.State is SelfUpdatePlanState.Applying + or SelfUpdatePlanState.RolledBack) + { + if (plan.State == SelfUpdatePlanState.Applying) + { + plan = await manager.RecoverApplyingAsync( + baseDirectory, + cancellationToken) + .ConfigureAwait(false); + } + + if (plan.State != SelfUpdatePlanState.RolledBack) + { + throw new LauncherUpdateException( + "The interrupted self-update did not produce a rollback receipt."); + } + + await manager.CompleteRolledBackAsync( + plan.TransactionId, + baseDirectory, + lease, + cancellationToken) + .ConfigureAwait(false); + _ = manager.CleanupOwnedResidueUnderLease( + pending: null, + baseDirectory, + lease); + return new SelfUpdateStartupResult(false, 0, args); + } + + if (plan.State != SelfUpdatePlanState.Staged) { throw new LauncherUpdateException( - "Self-update can start only from the published acdream-launcher executable."); + $"Self-update state '{plan.State}' cannot start a helper."); } string helperPath = manager.GetStagedLauncherPath(plan); @@ -173,6 +237,8 @@ public static class LauncherSelfUpdateBootstrap private static async Task RunHelperAsync( LauncherSelfUpdateManager manager, + string helperBaseDirectory, + string currentExecutablePath, int parentPid, string targetDirectory, string transactionId, @@ -182,10 +248,11 @@ public static class LauncherSelfUpdateBootstrap SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken) .ConfigureAwait(false) ?? throw new LauncherUpdateException("The helper found no pending self-update."); - if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)) + if (plan.State != SelfUpdatePlanState.Staged + || !string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)) { throw new LauncherUpdateException( - "The helper transaction does not match the pending self-update."); + "The helper mode does not match a staged self-update transaction."); } if (!PathsEqual(plan.TargetDirectory, targetDirectory)) @@ -194,6 +261,15 @@ public static class LauncherSelfUpdateBootstrap "The helper target does not match the pending self-update."); } + string expectedHelperDirectory = manager.GetPayloadDirectory(plan.TransactionId); + string expectedHelperPath = manager.GetStagedLauncherPath(plan); + if (!PathsEqual(helperBaseDirectory, expectedHelperDirectory) + || !PathsEqual(currentExecutablePath, expectedHelperPath)) + { + throw new LauncherUpdateException( + "Self-update helper mode is trusted only from the staged launcher payload."); + } + string launcherPath = ClientVersionStore.ResolveContained( targetDirectory, GetLauncherFileName(plan.Rid)); @@ -215,9 +291,10 @@ public static class LauncherSelfUpdateBootstrap { // Do not restart the canonical launcher: it would immediately see // the same staged plan and create an unbounded helper loop. - return DeferredLeaseExitCode; + return UpdateLeaseBusyExitCode; } + ProcessStartInfo? restoredStart = null; using (UpdateSessionBarrier.ExclusiveLease lease = updateLease ?? throw new InvalidOperationException("Exclusive update lease is missing.")) { @@ -225,7 +302,8 @@ public static class LauncherSelfUpdateBootstrap .ConfigureAwait(false) ?? throw new LauncherUpdateException( "The helper found no pending self-update after acquiring the lease."); - if (!string.Equals( + if (plan.State != SelfUpdatePlanState.Staged + || !string.Equals( plan.TransactionId, transactionId, StringComparison.Ordinal) @@ -315,78 +393,59 @@ public static class LauncherSelfUpdateBootstrap return 75; } - var restored = new ProcessStartInfo(launcherPath) + restoredStart = new ProcessStartInfo(launcherPath) { UseShellExecute = false, WorkingDirectory = Path.GetFullPath(targetDirectory), }; - restored.ArgumentList.Add(DeferredArgument); foreach (string argument in publicArguments) { - restored.ArgumentList.Add(argument); + restoredStart.ArgumentList.Add(argument); } - - _ = Process.Start(restored); - return 74; } finally { replacement?.Dispose(); } } - } - private static async Task FinishConfirmedCleanupAsync( - LauncherSelfUpdateManager manager, - string targetDirectory, - CancellationToken cancellationToken) - { - DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout; - do + // Release the helper's exclusive barrier before restarting the + // restored canonical launcher. It will observe the durable RolledBack + // receipt through the ordinary startup path, re-verify it, finalize + // recovery, and continue with no privileged bypass argument. + if (restoredStart is null || Process.Start(restoredStart) is null) { - cancellationToken.ThrowIfCancellationRequested(); - if (manager.Barrier.TryAcquireExclusive( - out UpdateSessionBarrier.ExclusiveLease? lease)) - { - using (UpdateSessionBarrier.ExclusiveLease acquiredLease = lease - ?? throw new InvalidOperationException( - "Exclusive cleanup lease is missing.")) - { - SelfUpdatePlan? pending = await manager.LoadPendingAsync(cancellationToken) - .ConfigureAwait(false); - if (pending is - { - State: SelfUpdatePlanState.AwaitingConfirmation, - } - && manager.IsConfirmed(pending.TransactionId)) - { - await manager.CompleteConfirmedAsync( - pending.TransactionId, - targetDirectory, - cancellationToken) - .ConfigureAwait(false); - pending = null; - } - - if (manager.CleanupOwnedResidueUnderLease( - pending, - targetDirectory, - acquiredLease)) - { - return; - } - } - } - - await Task.Delay(50, cancellationToken).ConfigureAwait(false); + return 75; } - while (DateTimeOffset.UtcNow < deadline); + + return 74; } private static string GetLauncherFileName(string rid) => "acdream-launcher" + (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); + private static void ValidateCanonicalStartup( + SelfUpdatePlan plan, + string baseDirectory, + string executable) + { + if (!PathsEqual(plan.TargetDirectory, baseDirectory)) + { + throw new LauncherUpdateException( + "The pending self-update targets a different launcher directory."); + } + + string expectedExecutable = ClientVersionStore.ResolveContained( + baseDirectory, + GetLauncherFileName(plan.Rid)); + if (!PathsEqual(executable, expectedExecutable)) + { + throw new LauncherUpdateException( + "Self-update can run only from the published acdream-launcher executable."); + } + } + private static async Task WaitForParentExitAsync( int parentPid, CancellationToken cancellationToken) diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs index 4d36a2bb..e5f1d467 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs @@ -483,6 +483,39 @@ public sealed class LauncherSelfUpdateManager SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId)); } + /// + /// Finalizes a durable rollback only after the prior owned launcher set + /// has been freshly re-verified while the caller holds the update + /// barrier. A failed self-update is abandoned rather than silently + /// re-staged, so an ordinary restart cannot enter an automatic retry + /// loop. + /// + internal async Task CompleteRolledBackAsync( + string transactionId, + string expectedTargetDirectory, + UpdateSessionBarrier.ExclusiveLease lease, + CancellationToken cancellationToken = default) + { + Barrier.RequireOwned(lease); + 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 (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal) + || plan.State != SelfUpdatePlanState.RolledBack) + { + throw new LauncherUpdateException( + "The self-update does not have the expected rollback receipt."); + } + + await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + File.Delete(PendingPlanPath); + SafeZipExtractor.TryDeleteDirectory(GetTargetTransactionDirectory(plan)); + SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId)); + } + public async Task RollbackAwaitingConfirmationAsync( string expectedTargetDirectory, CancellationToken cancellationToken = default) diff --git a/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs index 1ea640fb..fbb21197 100644 --- a/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs +++ b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs @@ -28,6 +28,43 @@ public sealed class UpdateSessionBarrier return new SessionLease(stream); } + /// + /// Non-blocking shared-lease probe used only by launcher startup after an + /// exclusive probe observed contention. Success proves that no updater + /// owns the exclusive lease at that instant; permission and path failures + /// remain hard errors. + /// + public bool TryAcquireSession(out SessionLease? lease) + { + Directory.CreateDirectory( + Path.GetDirectoryName(_lockPath) + ?? throw new InvalidOperationException( + "The update/session lock path has no parent directory.")); + try + { + lease = new SessionLease( + new FileStream( + _lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.ReadWrite, + bufferSize: 1, + FileOptions.None)); + return true; + } + catch (IOException) + { + lease = null; + return false; + } + catch (UnauthorizedAccessException ex) + { + throw new LauncherUpdateException( + $"The update/session lease could not be opened: {ex.Message}", + ex); + } + } + public ExclusiveLease AcquireExclusive() { FileStream stream = Open( diff --git a/src/AcDream.Launcher/LauncherStartupOptions.cs b/src/AcDream.Launcher/LauncherStartupOptions.cs index 43b3429e..b3364cbc 100644 --- a/src/AcDream.Launcher/LauncherStartupOptions.cs +++ b/src/AcDream.Launcher/LauncherStartupOptions.cs @@ -9,7 +9,6 @@ internal enum LauncherStartupMode VerifyPublish, SelfUpdateHelper, SelfUpdateConfirmation, - SelfUpdateDeferred, } /// @@ -19,12 +18,6 @@ internal enum LauncherStartupMode /// internal sealed class LauncherStartupOptions { - // This prefix is consumed only after LauncherSelfUpdateBootstrap has - // already decided to continue after a recovered rollback. Keep it local - // so the process-level bootstrap can remain internal to Launcher.Core. - private const string DeferredSelfUpdateArgument = - "--acdream-self-update-deferred-v1"; - private readonly IReadOnlyList _publicArguments; private LauncherStartupOptions( @@ -213,14 +206,6 @@ internal sealed class LauncherStartupOptions arguments.Count >= 2 ? 2 : arguments.Count); } - if (string.Equals( - arguments[0], - DeferredSelfUpdateArgument, - StringComparison.Ordinal)) - { - return (LauncherStartupMode.SelfUpdateDeferred, 1); - } - return (LauncherStartupMode.Desktop, 0); } diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs index 18a98d1e..f847916b 100644 --- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs @@ -31,7 +31,7 @@ if (!string.IsNullOrWhiteSpace(selfUpdateData) SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync( effectiveArgs, manager, - Path.GetFullPath(selfUpdateTarget), + Path.GetFullPath(AppContext.BaseDirectory), Path.GetFullPath( Environment.ProcessPath ?? throw new InvalidOperationException("Process path is unavailable."))); @@ -53,6 +53,8 @@ return effectiveArgs.FirstOrDefault() switch "stage-self-update" => await StageSelfUpdateAsync(effectiveArgs[1..]), "bootstrap-probe" => await BootstrapProbeAsync(effectiveArgs[1..]), "canonical-probe" => CanonicalProbe(effectiveArgs[1..]), + "hold-campaign-la-process" => + await HoldCampaignLaProcessAsync(effectiveArgs[1..]), _ => 2, }; @@ -60,7 +62,7 @@ static bool IsBootstrapInvocation(string[] arguments) => arguments.Length > 0 && arguments[0] is LauncherSelfUpdateBootstrap.HelperArgument or LauncherSelfUpdateBootstrap.ConfirmArgument - or LauncherSelfUpdateBootstrap.DeferredArgument + or "--acdream-self-update-deferred-v1" or "canonical-probe"; static ApplicationPathSet Paths(string dataDirectory) @@ -180,6 +182,34 @@ static int CanonicalProbe(string[] arguments) return 0; } +static async Task HoldCampaignLaProcessAsync(string[] arguments) +{ + if (arguments.Length != 4 + || arguments[0] is not ("--config" or "--session-config")) + { + return 2; + } + + string configPath = Path.GetFullPath(arguments[1]); + string readyPath = Path.GetFullPath(arguments[2]); + string releasePath = Path.GetFullPath(arguments[3]); + if (!File.Exists(configPath)) + { + return 3; + } + + File.WriteAllText( + readyPath, + Environment.ProcessId.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + while (!File.Exists(releasePath)) + { + await Task.Delay(10); + } + + return 0; +} + static async Task HoldUpdateLeaseAsync(string[] arguments) { if (arguments.Length != 4 diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs index 40fe89f3..4463dd1a 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs @@ -295,29 +295,228 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable Assert.Equal(publicArguments, ordinary.RemainingArguments); SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync( - [LauncherSelfUpdateBootstrap.DeferredArgument, .. publicArguments], + ["--acdream-self-update-deferred-v1", .. publicArguments], harness.Manager, harness.Target, harness.LauncherPath); - Assert.False(deferred.ShouldExit); - Assert.Equal(publicArguments, deferred.RemainingArguments); + Assert.True(deferred.ShouldExit); + Assert.Equal(64, deferred.ExitCode); + Assert.Empty(deferred.RemainingArguments); _ = await harness.StageAsync(); SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target); - SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync( - [ - LauncherSelfUpdateBootstrap.ConfirmArgument, - applied.TransactionId, - .. publicArguments, - ], + SelfUpdateStartupResult confirmation; + using (UpdateSessionBarrier.ExclusiveLease helperLease = + harness.Manager.Barrier.AcquireExclusive()) + { + confirmation = await LauncherSelfUpdateBootstrap.HandleAsync( + [ + LauncherSelfUpdateBootstrap.ConfirmArgument, + applied.TransactionId, + .. publicArguments, + ], + harness.Manager, + harness.Target, + harness.LauncherPath); + } + + Assert.False(confirmation.ShouldExit); + Assert.Equal(publicArguments, confirmation.RemainingArguments); + Assert.True(File.Exists(harness.Manager.PendingPlanPath)); + Assert.True(harness.Manager.IsConfirmed(applied.TransactionId)); + await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target); + Assert.False(File.Exists(harness.Manager.PendingPlanPath)); + } + + [Fact] + public async Task ContendedOrdinaryStartupAllowsOnlyNoPlanOrValidatedStagedPlan() + { + using var harness = new Harness(_root); + using (UpdateSessionBarrier.SessionLease session = + harness.Manager.Barrier.AcquireSession()) + { + SelfUpdateStartupResult empty = await LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], + harness.Manager, + harness.Target, + harness.LauncherPath); + Assert.False(empty.ShouldExit); + } + + _ = await harness.StageAsync(); + using (UpdateSessionBarrier.SessionLease session = + harness.Manager.Barrier.AcquireSession()) + { + SelfUpdateStartupResult staged = await LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], + harness.Manager, + harness.Target, + harness.LauncherPath); + Assert.False(staged.ShouldExit); + } + + SelfUpdatePlan awaiting = await harness.Manager.ApplyPendingAsync(harness.Target); + using (UpdateSessionBarrier.SessionLease session = + harness.Manager.Barrier.AcquireSession()) + { + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], + harness.Manager, + harness.Target, + harness.LauncherPath)); + } + + SelfUpdatePlan rolledBack = await harness.Manager + .RollbackAwaitingConfirmationAsync(harness.Target); + using (UpdateSessionBarrier.SessionLease session = + harness.Manager.Barrier.AcquireSession()) + { + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], + harness.Manager, + harness.Target, + harness.LauncherPath)); + } + + await SetPlanStateAsync(harness.Manager.PendingPlanPath, "applying"); + using (UpdateSessionBarrier.SessionLease session = + harness.Manager.Barrier.AcquireSession()) + { + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], + harness.Manager, + harness.Target, + harness.LauncherPath)); + } + + Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, awaiting.State); + Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State); + } + + [Fact] + public async Task OrdinaryStartupRecoversApplyingAndFinalizesVerifiedRollback() + { + using var harness = new Harness(_root); + _ = await harness.StageAsync(); + _ = await harness.Manager.ApplyPendingAsync(harness.Target); + await SetPlanStateAsync(harness.Manager.PendingPlanPath, "applying"); + + SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], harness.Manager, harness.Target, harness.LauncherPath); - Assert.False(confirmation.ShouldExit); - Assert.Equal(publicArguments, confirmation.RemainingArguments); - Assert.False(File.Exists(harness.Manager.PendingPlanPath)); - Assert.False(harness.Manager.IsConfirmed(applied.TransactionId)); + Assert.False(result.ShouldExit); + Assert.Equal(["ordinary"], result.RemainingArguments); + Assert.Null(await harness.Manager.LoadPendingAsync()); + Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); + Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath)); + } + + [Fact] + public async Task InternalPrefixSpoofsCannotCrossPlanStateOrExecutableTrust() + { + using var harness = new Harness(_root); + _ = await harness.StageAsync(); + SelfUpdatePlan staged = Assert.IsType( + await harness.Manager.LoadPendingAsync()); + + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + [ + LauncherSelfUpdateBootstrap.HelperArgument, + int.MaxValue.ToString( + System.Globalization.CultureInfo.InvariantCulture), + harness.Target, + staged.TransactionId, + ], + harness.Manager, + harness.Target, + harness.LauncherPath)); + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + [LauncherSelfUpdateBootstrap.ConfirmArgument, staged.TransactionId], + harness.Manager, + harness.Target, + harness.LauncherPath)); + + SelfUpdatePlan awaiting = await harness.Manager.ApplyPendingAsync(harness.Target); + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + [LauncherSelfUpdateBootstrap.ConfirmArgument, awaiting.TransactionId], + harness.Manager, + harness.Target, + Path.Combine(harness.Target, "spoof-launcher"))); + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + [ + LauncherSelfUpdateBootstrap.HelperArgument, + int.MaxValue.ToString( + System.Globalization.CultureInfo.InvariantCulture), + harness.Target, + awaiting.TransactionId, + ], + harness.Manager, + harness.Target, + harness.LauncherPath)); + + SelfUpdatePlan rolledBack = await harness.Manager + .RollbackAwaitingConfirmationAsync(harness.Target); + foreach (string prefix in new[] + { + LauncherSelfUpdateBootstrap.HelperArgument, + LauncherSelfUpdateBootstrap.ConfirmArgument, + }) + { + string[] arguments = prefix == LauncherSelfUpdateBootstrap.HelperArgument + ? [prefix, int.MaxValue.ToString(), harness.Target, rolledBack.TransactionId] + : [prefix, rolledBack.TransactionId]; + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + arguments, + harness.Manager, + harness.Target, + harness.LauncherPath)); + } + + SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync( + ["--acdream-self-update-deferred-v1"], + harness.Manager, + harness.Target, + harness.LauncherPath); + Assert.True(deferred.ShouldExit); + Assert.Equal(64, deferred.ExitCode); + + await File.WriteAllTextAsync(harness.Manager.PendingPlanPath, "{ambiguous"); + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + [LauncherSelfUpdateBootstrap.ConfirmArgument, rolledBack.TransactionId], + harness.Manager, + harness.Target, + harness.LauncherPath)); + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + [ + LauncherSelfUpdateBootstrap.HelperArgument, + int.MaxValue.ToString(), + harness.Target, + rolledBack.TransactionId, + ], + harness.Manager, + harness.Target, + harness.LauncherPath)); + } + + private static async Task SetPlanStateAsync(string path, string state) + { + JsonObject plan = Assert.IsType(JsonNode.Parse( + await File.ReadAllTextAsync(path))); + plan["state"] = state; + await File.WriteAllTextAsync(path, plan.ToJsonString()); } private sealed class Harness : IDisposable diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs index 5185fc7d..f63b5638 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs @@ -27,7 +27,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable } [Fact] - public async Task KilledAfterCanonicalReplaceCanInvokeCanonicalAndConvergeAutomatically() + public async Task KilledApplyingPlanRecoversPriorAndContinuesCanonicalWithoutRetryLoop() { string data = Path.Combine(_root, "data"); string target = Path.Combine(_root, "launcher"); @@ -94,13 +94,21 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable "The self-update journal did not converge."); Assert.Equal( - prepared.NewCanonicalHash, + oldHash, await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath)); - Assert.True(File.Exists(Path.Combine( + Assert.False(File.Exists(Path.Combine( target, LauncherSelfUpdateManager.InstallRecordFileName))); Assert.False(Directory.Exists(manager.GetTransactionDirectory( plan.TransactionId))); + using (UpdateSessionBarrier.ExclusiveLease cleanupLease = + manager.Barrier.AcquireExclusive()) + { + Assert.True(manager.CleanupOwnedResidueUnderLease( + pending: null, + target, + cleanupLease)); + } Assert.Empty(Directory.EnumerateDirectories( target, ".acdream-self-update-*", @@ -113,11 +121,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable launchMarker, StringComparison.Ordinal); int replacementPid = ParsePid(launchMarker); - int helperPid = int.Parse( - await File.ReadAllTextAsync(helperPidPath), - System.Globalization.CultureInfo.InvariantCulture); await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10)); - await WaitForProcessExitAsync(helperPid, TimeSpan.FromSeconds(10)); + Assert.False(File.Exists(helperPidPath)); if (OperatingSystem.IsLinux()) { Assert.True( @@ -156,13 +161,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable ["canonical-probe", launched], BootstrapEnvironment(crashed, helperPidPath)); await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); - Assert.Equal(0, canonical.ExitCode); - await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20)); - await WaitForProcessExitAsync( - int.Parse( - await File.ReadAllTextAsync(helperPidPath), - System.Globalization.CultureInfo.InvariantCulture), - TimeSpan.FromSeconds(20)); + Assert.NotEqual(0, canonical.ExitCode); + Assert.False(File.Exists(helperPidPath)); Assert.False(File.Exists(launched)); SelfUpdatePlan preserved = Assert.IsType( @@ -204,13 +204,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable ["canonical-probe", launched], BootstrapEnvironment(crashed, helperPidPath)); await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); - Assert.Equal(0, canonical.ExitCode); - await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20)); - await WaitForProcessExitAsync( - int.Parse( - await File.ReadAllTextAsync(helperPidPath), - System.Globalization.CultureInfo.InvariantCulture), - TimeSpan.FromSeconds(20)); + Assert.NotEqual(0, canonical.ExitCode); + Assert.False(File.Exists(helperPidPath)); Assert.False(File.Exists(launched)); Assert.True(File.Exists(outsideCanonical)); @@ -297,8 +292,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable ["bootstrap-probe", data, target, canonical, resultPath]); await startup.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); - Assert.Equal(0, startup.ExitCode); - Assert.Equal("ordinary", await File.ReadAllTextAsync(resultPath)); + Assert.NotEqual(0, startup.ExitCode); + Assert.False(File.Exists(resultPath)); Assert.True(Directory.Exists(transaction)); Assert.False(File.Exists(observer.PendingPlanPath)); @@ -328,9 +323,9 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable string helperPid = Path.Combine(_root, "helper.pid"); Directory.CreateDirectory(target); string rid = LauncherRuntimeIdentity.DetectRid(); - string canonical = Path.Combine(target, LauncherName(rid)); - await File.WriteAllTextAsync(canonical, "old-launcher"); - byte[] archive = UpdateTestData.LauncherZip(rid, "new-launcher"); + PreparedLauncher prepared = PrepareLauncherClosure(target, rid); + string canonical = prepared.CanonicalPath; + byte[] archive = prepared.NewArchive; using var server = new LocalHttpFixture(); server.Add("launcher.zip", archive); using var http = new HttpClient(); @@ -354,7 +349,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable [HelperPidEnvironment] = helperPid, }; - using Process helper = StartFixture( + using Process helper = StartProcess( + manager.GetStagedLauncherPath(plan), [ LauncherSelfUpdateBootstrap.HelperArgument, int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture), @@ -365,16 +361,151 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable ], environment); await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); - Assert.Equal(LauncherSelfUpdateBootstrap.DeferredLeaseExitCode, helper.ExitCode); + string helperError = await helper.StandardError.ReadToEndAsync(); + string helperOutput = await helper.StandardOutput.ReadToEndAsync(); + Assert.True( + helper.ExitCode == LauncherSelfUpdateBootstrap.UpdateLeaseBusyExitCode, + $"helper exit {helper.ExitCode}; stdout: {helperOutput}; stderr: {helperError}"); Assert.True(File.Exists(helperPid)); Assert.False(File.Exists(unexpectedLaunch)); - Assert.Equal("old-launcher", await File.ReadAllTextAsync(canonical)); + Assert.NotEqual( + prepared.NewCanonicalHash, + await FileIntegrity.ComputeSha256HexAsync(canonical)); SelfUpdatePlan deferred = Assert.IsType( await manager.LoadPendingAsync()); Assert.Equal(SelfUpdatePlanState.Staged, deferred.State); Assert.Equal(plan.TransactionId, deferred.TransactionId); } + [Fact] + public async Task SpoofedInternalPrefixesCannotBypassAnyDurablePlanState() + { + string data = Path.Combine(_root, "data"); + string target = Path.Combine(_root, "launcher"); + string rid = LauncherRuntimeIdentity.DetectRid(); + PreparedLauncher prepared = PrepareLauncherClosure(target, rid); + using var server = new LocalHttpFixture(); + server.Add("launcher.zip", prepared.NewArchive); + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http); + _ = await manager.StageAsync( + LauncherVersion.Parse("2.0.0"), + rid, + new ReleaseArtifact( + server.UriFor("launcher.zip"), + UpdateTestData.Sha256(prepared.NewArchive), + prepared.NewArchive.LongLength), + target, + progress: null, + CancellationToken.None); + SelfUpdatePlan plan = Assert.IsType(await manager.LoadPendingAsync()); + var environment = new Dictionary + { + [DataEnvironment] = data, + [TargetEnvironment] = target, + }; + + await AssertInternalSpoofsRejectedAsync( + prepared.CanonicalPath, + target, + plan.TransactionId, + environment, + "staged"); + + plan = await manager.ApplyPendingAsync(target); + await SetPlanStateAsync(manager.PendingPlanPath, "applying"); + await AssertInternalSpoofsRejectedAsync( + prepared.CanonicalPath, + target, + plan.TransactionId, + environment, + "applying"); + + plan = await manager.RecoverApplyingAsync(target); + plan = await manager.ApplyPendingAsync(target); + Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, plan.State); + await AssertInternalSpoofsRejectedAsync( + prepared.CanonicalPath, + target, + plan.TransactionId, + environment, + "awaitingConfirmation"); + + plan = await manager.RollbackAwaitingConfirmationAsync(target); + await AssertInternalSpoofsRejectedAsync( + prepared.CanonicalPath, + target, + plan.TransactionId, + environment, + "rolledBack"); + + await File.WriteAllTextAsync(manager.PendingPlanPath, "{ambiguous"); + await AssertInternalSpoofsRejectedAsync( + prepared.CanonicalPath, + target, + plan.TransactionId, + environment, + "ambiguous"); + } + + private static async Task AssertInternalSpoofsRejectedAsync( + string canonicalPath, + string targetDirectory, + string transactionId, + IReadOnlyDictionary environment, + string state) + { + (string Name, string[] Arguments, int? ExactExit)[] attempts = + [ + ( + "deferred", + ["--acdream-self-update-deferred-v1"], + 64), + ( + "helper", + [ + LauncherSelfUpdateBootstrap.HelperArgument, + int.MaxValue.ToString( + System.Globalization.CultureInfo.InvariantCulture), + targetDirectory, + transactionId, + ], + null), + ( + "confirm", + [LauncherSelfUpdateBootstrap.ConfirmArgument, transactionId], + null), + ]; + + foreach ((string name, string[] arguments, int? exactExit) in attempts) + { + using Process process = StartProcess(canonicalPath, arguments, environment); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + string stderr = await process.StandardError.ReadToEndAsync(); + if (exactExit.HasValue) + { + Assert.True( + process.ExitCode == exactExit.Value, + $"{state}/{name} exited {process.ExitCode}: {stderr}"); + } + else + { + Assert.True( + process.ExitCode != 0, + $"{state}/{name} unexpectedly succeeded."); + } + } + } + + private static async Task SetPlanStateAsync(string path, string state) + { + System.Text.Json.Nodes.JsonObject plan = Assert.IsType< + System.Text.Json.Nodes.JsonObject>( + System.Text.Json.Nodes.JsonNode.Parse(await File.ReadAllTextAsync(path))); + plan["state"] = state; + await File.WriteAllTextAsync(path, plan.ToJsonString()); + } + private PreparedLauncher PrepareLauncherClosure(string target, string rid) { string fixtureDirectory = GetFixtureDirectory(); diff --git a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs index d7f223ce..7b8f5b07 100644 --- a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs @@ -147,7 +147,7 @@ public sealed class LauncherStartupOptionsTests } [Fact] - public void DeferredSelfUpdateRestartRetainsIsolationWithoutResolvingDefaults() + public void LegacyDeferredSelfUpdatePrefixIsRejectedAsUntrustedInput() { string root = Path.GetFullPath( Path.Combine(Path.GetTempPath(), "acdream-la11-deferred")); @@ -159,16 +159,11 @@ public sealed class LauncherStartupOptionsTests "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json", ]; - LauncherStartupOptions options = LauncherStartupOptions.Parse( - ["--acdream-self-update-deferred-v1", .. suffix], - () => throw new InvalidOperationException( - "canonical path resolver was touched")); - - Assert.Equal(LauncherStartupMode.SelfUpdateDeferred, options.Mode); - Assert.Equal(suffix, options.PublicArguments); - Assert.Equal(Path.Combine(root, "config"), options.Paths.ConfigDirectory); - Assert.Equal(Path.Combine(root, "data"), options.Paths.DataDirectory); - Assert.Equal(Path.Combine(root, "cache"), options.Paths.CacheDirectory); + Assert.Throws(() => + LauncherStartupOptions.Parse( + ["--acdream-self-update-deferred-v1", .. suffix], + () => throw new InvalidOperationException( + "canonical path resolver was touched"))); } [Fact] diff --git a/tools/CampaignLaProcessCorrelation.ps1 b/tools/CampaignLaProcessCorrelation.ps1 new file mode 100644 index 00000000..00718f13 --- /dev/null +++ b/tools/CampaignLaProcessCorrelation.ps1 @@ -0,0 +1,93 @@ +Set-StrictMode -Version Latest + +function Get-CampaignLaSessionProcessCorrelations { + [CmdletBinding()] + param() + + $matches = [Collections.Generic.List[object]]::new() + if ($IsWindows) { + $pattern = '(?i)(?:^|\s)(?:--config|--session-config)\s+(?:"([^"]+)"|(\S+))' + foreach ($candidate in @(Get-CimInstance Win32_Process -ErrorAction Stop)) { + $commandLine = [string]$candidate.CommandLine + if ([string]::IsNullOrWhiteSpace($commandLine)) { continue } + foreach ($match in [Text.RegularExpressions.Regex]::Matches( + $commandLine, + $pattern)) { + $value = if ($match.Groups[1].Success) { + $match.Groups[1].Value + } else { $match.Groups[2].Value } + if ([IO.Path]::IsPathFullyQualified($value)) { + $matches.Add([pscustomobject]@{ + ProcessId = [int]$candidate.ProcessId + SessionConfigPath = [IO.Path]::GetFullPath($value) + }) + } + } + } + } + elseif ($IsLinux) { + foreach ($directory in [IO.Directory]::EnumerateDirectories('/proc')) { + $leaf = [IO.Path]::GetFileName($directory) + $processId = 0 + if (-not [int]::TryParse( + $leaf, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$processId)) { + continue + } + try { + $bytes = [IO.File]::ReadAllBytes((Join-Path $directory 'cmdline')) + if ($bytes.Length -eq 0) { continue } + $arguments = @([Text.Encoding]::UTF8.GetString($bytes).Split( + [char]0, + [StringSplitOptions]::RemoveEmptyEntries)) + for ($index = 0; $index + 1 -lt $arguments.Count; $index++) { + if ($arguments[$index] -cin @('--config', '--session-config') -and + [IO.Path]::IsPathFullyQualified($arguments[$index + 1])) { + $matches.Add([pscustomobject]@{ + ProcessId = $processId + SessionConfigPath = [IO.Path]::GetFullPath( + $arguments[$index + 1]) + }) + } + } + } + catch [IO.IOException] { + # A process may exit between /proc enumeration and cmdline read. + } + catch [UnauthorizedAccessException] { + # Other-user processes cannot be the owner-readable gate child. + } + } + } + else { + throw 'Campaign LA process correlation supports Windows and Linux only.' + } + + return @($matches) +} + +function Get-CampaignLaCorrelatedProcessIds { + [CmdletBinding()] + param([Parameter(Mandatory = $true)][string]$SessionConfigPath) + + if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) { + throw 'Session-config correlation requires an absolute path.' + } + $SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + $matches = [Collections.Generic.HashSet[int]]::new() + foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) { + if ([string]::Equals( + $candidate.SessionConfigPath, + $SessionConfigPath, + $comparison)) { + $null = $matches.Add([int]$candidate.ProcessId) + } + } + + return @($matches | Sort-Object) +} diff --git a/tools/capture-campaign-la-session-process.ps1 b/tools/capture-campaign-la-session-process.ps1 new file mode 100644 index 00000000..448660d4 --- /dev/null +++ b/tools/capture-campaign-la-session-process.ps1 @@ -0,0 +1,109 @@ +<# +.SYNOPSIS + Captures one launcher child PID by its unique isolated session-config path. + +.DESCRIPTION + Writes a sanitized gate-only sidecar. It never reads the session-config + contents and records no command line, account, character, or credential. +#> +[CmdletBinding(DefaultParameterSetName = 'Path')] +param( + [Parameter(Mandatory = $true, ParameterSetName = 'Path')] + [string]$SessionConfigPath, + [Parameter(Mandatory = $true, ParameterSetName = 'Directory')] + [string]$SessionsDirectory, + [Parameter(ParameterSetName = 'Directory')] + [DateTimeOffset]$CreatedAfterUtc = [DateTimeOffset]::MinValue, + [Parameter(Mandatory = $true)][string]$ReportPath, + [ValidateRange(1, 60)][int]$WaitSeconds = 10 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA PID capture requires PowerShell 7 or newer.' +} +. (Join-Path $PSScriptRoot 'CampaignLaProcessCorrelation.ps1') + +if ($PSCmdlet.ParameterSetName -eq 'Path') { + if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) { + throw '-SessionConfigPath must be absolute.' + } + $SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) + if (-not (Test-Path -LiteralPath $SessionConfigPath -PathType Leaf)) { + throw "Session config does not exist: $SessionConfigPath" + } +} +else { + if (-not [IO.Path]::IsPathFullyQualified($SessionsDirectory)) { + throw '-SessionsDirectory must be absolute.' + } + $SessionsDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($SessionsDirectory)) + if (-not (Test-Path -LiteralPath $SessionsDirectory -PathType Container)) { + throw "Sessions directory does not exist: $SessionsDirectory" + } +} +if (-not [IO.Path]::IsPathFullyQualified($ReportPath)) { + throw '-ReportPath must be absolute.' +} +$ReportPath = [IO.Path]::GetFullPath($ReportPath) +if (Test-Path -LiteralPath $ReportPath) { + throw '-ReportPath must be fresh.' +} + +$deadline = [DateTime]::UtcNow.AddSeconds($WaitSeconds) +do { + if ($PSCmdlet.ParameterSetName -eq 'Path') { + $correlations = @(Get-CampaignLaSessionProcessCorrelations | + Where-Object { + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + [string]::Equals( + $_.SessionConfigPath, + $SessionConfigPath, + $comparison) + }) + } + else { + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + $prefix = $SessionsDirectory + [IO.Path]::DirectorySeparatorChar + $correlations = @(Get-CampaignLaSessionProcessCorrelations | + Where-Object { + $_.SessionConfigPath.StartsWith($prefix, $comparison) -and + [IO.Path]::GetFileName($_.SessionConfigPath) -ceq 'session.json' -and + (Test-Path -LiteralPath $_.SessionConfigPath -PathType Leaf) -and + (Get-Item -LiteralPath $_.SessionConfigPath).LastWriteTimeUtc -ge + $CreatedAfterUtc.UtcDateTime + }) + } + if ($correlations.Count -eq 1) { break } + if ($correlations.Count -gt 1) { + throw "More than one process uses the isolated session config." + } + Start-Sleep -Milliseconds 100 +} while ([DateTime]::UtcNow -lt $deadline) +if ($correlations.Count -ne 1) { + throw 'No live process uses the isolated session config.' +} +$SessionConfigPath = [IO.Path]::GetFullPath($correlations[0].SessionConfigPath) + +$directory = Split-Path -Parent $ReportPath +if (-not [string]::IsNullOrEmpty($directory)) { + $null = New-Item -ItemType Directory -Force -Path $directory +} +$report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-session-process-capture' + processId = [int]$correlations[0].ProcessId + sessionId = [IO.Path]::GetFileName( + [IO.Path]::GetDirectoryName($SessionConfigPath)) + sessionConfigFile = [IO.Path]::GetFileName($SessionConfigPath) + capturedUtc = [DateTime]::UtcNow.ToString('O') +} +$report | ConvertTo-Json -Depth 3 | + Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM +Write-Host "Campaign LA process capture: $ReportPath" diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1 index 52108e71..2e8723f8 100644 --- a/tools/new-campaign-la-update-fixture.ps1 +++ b/tools/new-campaign-la-update-fixture.ps1 @@ -37,6 +37,35 @@ if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { } $OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( [IO.Path]::GetFullPath($OutputDirectory)) + +function Assert-NoReparseAncestry([string]$Path, [string]$Description) { + $cursor = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path)) + while (-not (Test-Path -LiteralPath $cursor)) { + $parent = [IO.Path]::GetDirectoryName($cursor) + if ([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor) { break } + $cursor = $parent + } + while (-not [string]::IsNullOrEmpty($cursor)) { + $item = Get-Item -LiteralPath $cursor -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description has a reparse point in its ancestry." + } + $parent = [IO.Directory]::GetParent($cursor) + if ($null -eq $parent) { break } + $cursor = $parent.FullName + } +} + +function Test-SameOrDescendant([string]$Path, [string]$Ancestor) { + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + if ([string]::Equals($Path, $Ancestor, $comparison)) { return $true } + $prefix = $Ancestor + [IO.Path]::DirectorySeparatorChar + return $Path.StartsWith($prefix, $comparison) +} + +Assert-NoReparseAncestry $OutputDirectory 'Output directory' if ($Port -lt 1024 -or $Port -gt 65535) { throw '-Port must be 1024..65535.' } $semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or @@ -73,6 +102,11 @@ foreach ($key in @($sources.Keys)) { if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) { throw "Payload source '$key' does not exist: $source" } + Assert-NoReparseAncestry $source "Payload source '$key'" + if ((Test-SameOrDescendant $OutputDirectory $source) -or + (Test-SameOrDescendant $source $OutputDirectory)) { + throw "Output directory and payload source '$key' must not overlap." + } } function Require-PayloadFile([string]$Key, [string]$Name) { @@ -98,6 +132,7 @@ if (Test-Path -LiteralPath $OutputDirectory) { } } else { $null = New-Item -ItemType Directory -Path $OutputDirectory } +Assert-NoReparseAncestry $OutputDirectory 'Output directory' if ($DryRun) { $plan = [ordered]@{ @@ -121,6 +156,73 @@ Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem $fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero) +function Get-LittleEndianUInt16([byte[]]$Bytes, [int]$Offset) { + return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8) +} + +function Get-LittleEndianUInt32([byte[]]$Bytes, [int]$Offset) { + return [uint32]([uint32]$Bytes[$Offset] -bor + ([uint32]$Bytes[$Offset + 1] -shl 8) -bor + ([uint32]$Bytes[$Offset + 2] -shl 16) -bor + ([uint32]$Bytes[$Offset + 3] -shl 24)) +} + +function Set-DeterministicZipHostPlatform([string]$Path) { + [byte[]]$bytes = [IO.File]::ReadAllBytes($Path) + $minimumEocdSize = 22 + if ($bytes.Length -lt $minimumEocdSize) { + throw "Generated ZIP is too short: $Path" + } + + $eocd = -1 + $minimumOffset = [Math]::Max(0, $bytes.Length - 65557) + for ($offset = $bytes.Length - $minimumEocdSize; $offset -ge $minimumOffset; $offset--) { + if ((Get-LittleEndianUInt32 $bytes $offset) -eq 0x06054b50) { + $commentLength = Get-LittleEndianUInt16 $bytes ($offset + 20) + if ($offset + $minimumEocdSize + $commentLength -eq $bytes.Length) { + $eocd = $offset + break + } + } + } + if ($eocd -lt 0) { throw "Generated ZIP has no valid end record: $Path" } + if ((Get-LittleEndianUInt16 $bytes ($eocd + 4)) -ne 0 -or + (Get-LittleEndianUInt16 $bytes ($eocd + 6)) -ne 0) { + throw "Generated ZIP unexpectedly spans multiple disks: $Path" + } + + $entriesOnDisk = Get-LittleEndianUInt16 $bytes ($eocd + 8) + $entryCount = Get-LittleEndianUInt16 $bytes ($eocd + 10) + if ($entriesOnDisk -ne $entryCount) { + throw "Generated ZIP central-directory count is inconsistent: $Path" + } + $centralSize = Get-LittleEndianUInt32 $bytes ($eocd + 12) + $centralOffset = Get-LittleEndianUInt32 $bytes ($eocd + 16) + if ([uint64]$centralOffset + [uint64]$centralSize -ne [uint64]$eocd) { + throw "Generated ZIP central-directory bounds are inconsistent: $Path" + } + + [uint64]$cursor = $centralOffset + for ($index = 0; $index -lt $entryCount; $index++) { + if ($cursor + 46 -gt $eocd -or + (Get-LittleEndianUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) { + throw "Generated ZIP central-directory entry is invalid: $Path" + } + # ZipArchive intentionally stamps the creating host (FAT on Windows, + # Unix on Linux) in the upper byte of "version made by". Normalize it + # to FAT; permissions are already explicit in ExternalAttributes. + $bytes[[int]$cursor + 5] = 0 + $nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28) + $extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30) + $commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32) + $cursor += 46 + $nameLength + $extraLength + $commentLength + } + if ($cursor -ne $eocd) { + throw "Generated ZIP central-directory length is inconsistent: $Path" + } + [IO.File]::WriteAllBytes($Path, $bytes) +} + function New-DeterministicZip( [string]$SourceDirectory, [string]$Destination, @@ -141,17 +243,30 @@ function New-DeterministicZip( $true, [Text.Encoding]::UTF8) try { - $files = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Recurse | - Sort-Object { [IO.Path]::GetRelativePath($SourceDirectory, $_.FullName).Replace('\', '/') }) - foreach ($file in $files) { - if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Payload contains a reparse point: $($file.FullName)" + $allEntries = @(Get-ChildItem -LiteralPath $SourceDirectory -Force -Recurse) + foreach ($item in $allEntries) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Payload contains a reparse point: $($item.FullName)" } - $relative = [IO.Path]::GetRelativePath($SourceDirectory, $file.FullName).Replace('\', '/') + } + [string[]]$files = @($allEntries | + Where-Object { -not $_.PSIsContainer } | + ForEach-Object { + [IO.Path]::GetRelativePath( + $SourceDirectory, + $_.FullName).Replace('\', '/') + }) + [Array]::Sort($files, [StringComparer]::Ordinal) + $caseFolded = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase) + foreach ($relative in $files) { if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or - [IO.Path]::IsPathRooted($relative)) { + [IO.Path]::IsPathRooted($relative) -or + -not $caseFolded.Add($relative)) { throw "Payload path escaped its root: $relative" } + $file = Get-Item -LiteralPath ( + Join-Path $SourceDirectory $relative.Replace('/', [IO.Path]::DirectorySeparatorChar)) $entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal) $entry.LastWriteTime = $fixedTimestamp $executable = $relative -ceq 'AcDream.App' -or @@ -183,6 +298,7 @@ function New-DeterministicZip( finally { $archive.Dispose() } } finally { $stream.Dispose() } + Set-DeterministicZipHostPlatform $Destination } function Get-Artifact([string]$Path, [string]$Url) { @@ -232,11 +348,15 @@ foreach ($release in $releaseDefinitions) { "$baseUri/launcher-linux-x64.zip" } } - $manifest | ConvertTo-Json -Depth 8 -Compress | - Set-Content -LiteralPath (Join-Path $releaseRoot 'manifest.json') -Encoding utf8NoBOM + [IO.File]::WriteAllText( + (Join-Path $releaseRoot 'manifest.json'), + ($manifest | ConvertTo-Json -Depth 8 -Compress), + [Text.UTF8Encoding]::new($false)) } -Set-Content -LiteralPath (Join-Path $OutputDirectory 'active-release.txt') ` - -Value 'A' -Encoding ascii -NoNewline +[IO.File]::WriteAllText( + (Join-Path $OutputDirectory 'active-release.txt'), + 'A', + [Text.Encoding]::ASCII) $server = @' [CmdletBinding()] @@ -311,7 +431,10 @@ try { } finally { $listener.Close() } '@ -$server | Set-Content -LiteralPath (Join-Path $OutputDirectory 'serve-fixture.ps1') -Encoding utf8NoBOM +[IO.File]::WriteAllText( + (Join-Path $OutputDirectory 'serve-fixture.ps1'), + $server.Replace("`r`n", "`n"), + [Text.UTF8Encoding]::new($false)) $selector = @' [CmdletBinding()] @@ -340,16 +463,24 @@ finally { } Write-Host "Campaign LA fixture active release: $Release" '@ -$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM +[IO.File]::WriteAllText( + (Join-Path $OutputDirectory 'set-active-release.ps1'), + $selector.Replace("`r`n", "`n"), + [Text.UTF8Encoding]::new($false)) -$inventory = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | +$inventoryPaths = [string[]]@(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | Where-Object { $_.Name -ne 'fixture-report.json' } | - Sort-Object FullName | ForEach-Object { + [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') + }) +[Array]::Sort($inventoryPaths, [StringComparer]::Ordinal) +$inventory = @($inventoryPaths | ForEach-Object { + $fullPath = Join-Path $OutputDirectory $_.Replace('/', [IO.Path]::DirectorySeparatorChar) + $item = Get-Item -LiteralPath $fullPath [ordered]@{ - path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') - size = $_.Length - sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + path = $_ + size = $item.Length + sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant() } }) $report = [ordered]@{ diff --git a/tools/run-campaign-la-preflight.ps1 b/tools/run-campaign-la-preflight.ps1 index 13b376e1..2966ed59 100644 --- a/tools/run-campaign-la-preflight.ps1 +++ b/tools/run-campaign-la-preflight.ps1 @@ -13,6 +13,7 @@ [CmdletBinding()] param( [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [Parameter(Mandatory = $true)][string]$AllowedOutputRoot, [string]$OutputDirectory, [switch]$DryRun, [switch]$IncludeInstalledDat, @@ -30,6 +31,66 @@ $Repository = [IO.Path]::TrimEndingDirectorySeparator( if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) { throw "Repository does not contain AcDream.slnx: $Repository" } + +function Assert-NoReparseAncestry([string]$Path, [string]$Description) { + $cursor = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path)) + while (-not (Test-Path -LiteralPath $cursor)) { + $parent = [IO.Path]::GetDirectoryName($cursor) + if ([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor) { break } + $cursor = $parent + } + while (-not [string]::IsNullOrEmpty($cursor)) { + $item = Get-Item -LiteralPath $cursor -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description has a reparse point in its ancestry." + } + $parent = [IO.Directory]::GetParent($cursor) + if ($null -eq $parent) { break } + $cursor = $parent.FullName + } +} + +function Test-SameOrDescendant([string]$Path, [string]$Ancestor) { + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + if ([string]::Equals($Path, $Ancestor, $comparison)) { return $true } + return $Path.StartsWith( + $Ancestor + [IO.Path]::DirectorySeparatorChar, + $comparison) +} + +if (-not [IO.Path]::IsPathFullyQualified($AllowedOutputRoot)) { + throw '-AllowedOutputRoot must be absolute.' +} +$AllowedOutputRoot = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($AllowedOutputRoot)) +if (-not (Test-Path -LiteralPath $AllowedOutputRoot -PathType Container)) { + throw '-AllowedOutputRoot must be an existing campaign gate/log directory.' +} +Assert-NoReparseAncestry $AllowedOutputRoot 'Allowed output root' +$comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase +} else { [StringComparison]::Ordinal } +$homeDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath([Environment]::GetFolderPath( + [Environment+SpecialFolder]::UserProfile))) +if ([string]::Equals($AllowedOutputRoot, $Repository, $comparison) -or + [string]::Equals($AllowedOutputRoot, $homeDirectory, $comparison)) { + throw '-AllowedOutputRoot cannot be the repository root or user home.' +} +$repositoryLogs = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath((Join-Path $Repository 'logs'))) +$allowedLeaf = [IO.Path]::GetFileName($AllowedOutputRoot) +$allowedInRepository = Test-SameOrDescendant $AllowedOutputRoot $Repository +if ($allowedInRepository -and + -not (Test-SameOrDescendant $AllowedOutputRoot $repositoryLogs)) { + throw '-AllowedOutputRoot inside the repository must be below its logs directory.' +} +if (-not [string]::Equals($AllowedOutputRoot, $repositoryLogs, $comparison) -and + -not $allowedLeaf.StartsWith('campaign-la-', [StringComparison]::Ordinal)) { + throw '-AllowedOutputRoot must be the repository logs root or a campaign-la-* gate root.' +} if ($IncludeInstalledDat) { if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or -not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) { @@ -50,16 +111,28 @@ if ($IncludeInstalledDat) { $stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { - $OutputDirectory = Join-Path $Repository "logs/campaign-la-gate-$stamp" + $OutputDirectory = Join-Path $AllowedOutputRoot "campaign-la-preflight-$stamp" } elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { - $OutputDirectory = Join-Path $Repository $OutputDirectory + throw '-OutputDirectory must be absolute when supplied.' } $OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( [IO.Path]::GetFullPath($OutputDirectory)) +if (-not (Test-SameOrDescendant $OutputDirectory $AllowedOutputRoot) -or + [string]::Equals($OutputDirectory, $AllowedOutputRoot, $comparison)) { + throw '-OutputDirectory must be a strict descendant of -AllowedOutputRoot.' +} +if ([string]::Equals($OutputDirectory, $Repository, $comparison) -or + [string]::Equals($OutputDirectory, $homeDirectory, $comparison)) { + throw '-OutputDirectory cannot be the repository root or user home.' +} +if (Test-Path -LiteralPath $OutputDirectory) { + throw '-OutputDirectory must be fresh and must not already exist.' +} +Assert-NoReparseAncestry $OutputDirectory 'Output directory' $logsDirectory = Join-Path $OutputDirectory 'commands' $publishDirectory = Join-Path $OutputDirectory 'publish' -$null = New-Item -ItemType Directory -Force -Path $logsDirectory +$null = New-Item -ItemType Directory -Path $logsDirectory $commandResults = [Collections.Generic.List[object]]::new() $failures = [Collections.Generic.List[string]]::new() @@ -245,6 +318,22 @@ $portableTestProjects = @( try { Invoke-DotNet 'release-build' @( 'build', 'AcDream.slnx', '-c', 'Release', '--nologo', '-m:1') + Invoke-GateCommand 'campaign-la-gate-helper-contracts' ` + ([Environment]::ProcessPath ?? + $(throw 'The PowerShell process path is unavailable.')) ` + @( + '-NoProfile', + '-File', 'tools/test-campaign-la-gate-helpers.ps1', + '-Repository', $Repository, + '-OutputDirectory', (Join-Path $OutputDirectory 'helper-contracts')) + Invoke-GateCommand 'campaign-la-script-safety-contracts' ` + ([Environment]::ProcessPath ?? + $(throw 'The PowerShell process path is unavailable.')) ` + @( + '-NoProfile', + '-File', 'tools/test-campaign-la-script-safety.ps1', + '-Repository', $Repository, + '-OutputDirectory', (Join-Path $OutputDirectory 'script-safety')) Invoke-DotNet 'release-tests-serial' @( 'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1', '--', 'RunConfiguration.MaxCpuCount=1') @@ -384,14 +473,20 @@ finally { $dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all) $artifacts = @() if (-not $DryRun) { - $artifacts = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | + [string[]]$artifactPaths = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } | - Sort-Object FullName | ForEach-Object { + [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') + }) + [Array]::Sort($artifactPaths, [StringComparer]::Ordinal) + $artifacts = @($artifactPaths | ForEach-Object { + $fullPath = Join-Path $OutputDirectory $_.Replace( + '/', [IO.Path]::DirectorySeparatorChar) + $item = Get-Item -LiteralPath $fullPath [ordered]@{ - path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') - size = $_.Length - sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + path = $_ + size = $item.Length + sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant() } }) } @@ -402,6 +497,7 @@ finally { dryRun = [bool]$DryRun success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0) repository = $Repository + allowedOutputRoot = $AllowedOutputRoot head = $head dirty = ($dirtyLines.Count -gt 0) dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ }) diff --git a/tools/test-campaign-la-gate-helpers.ps1 b/tools/test-campaign-la-gate-helpers.ps1 new file mode 100644 index 00000000..11756555 --- /dev/null +++ b/tools/test-campaign-la-gate-helpers.ps1 @@ -0,0 +1,296 @@ +<# +.SYNOPSIS + Connection-free contract tests for Campaign LA gate evidence helpers. +#> +[CmdletBinding()] +param( + [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [Parameter(Mandatory = $true)][string]$OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA helper tests require PowerShell 7 or newer.' +} +$Repository = [IO.Path]::GetFullPath($Repository) +if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { + throw '-OutputDirectory must be absolute.' +} +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $OutputDirectory) { + throw '-OutputDirectory must be fresh.' +} +$null = New-Item -ItemType Directory -Path $OutputDirectory +$pwsh = [Environment]::ProcessPath +if ([string]::IsNullOrWhiteSpace($pwsh)) { + throw 'The PowerShell process path is unavailable.' +} +$validator = Join-Path $Repository 'tools/test-campaign-la-session-status.ps1' +$capture = Join-Path $Repository 'tools/capture-campaign-la-session-process.ps1' + +function Write-Profile([string]$Path, [string]$Secret) { + $document = [ordered]@{ + version = 1 + servers = @([ordered]@{ + name = 'fixture' + host = '127.0.0.1' + port = 9000 + accounts = @([ordered]@{ + account = 'fixture-account' + password = $Secret + characters = @() + }) + }) + } + [IO.File]::WriteAllText( + $Path, + ($document | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false)) + if ($IsLinux) { + [IO.File]::SetUnixFileMode( + $Path, + [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite) + } +} + +function New-GuiEvents { + $begin = [DateTimeOffset]::ParseExact( + '2026-08-15T10:00:00.0000000+00:00', + 'O', + [Globalization.CultureInfo]::InvariantCulture) + $session = 'fixture-session' + return @( + [ordered]@{ v = 1; e = 'started'; t = $begin.ToString('O'); sessionId = $session }, + [ordered]@{ v = 1; e = 'pluginLoaded'; t = $begin.AddSeconds(1).ToString('O'); sessionId = $session; plugin = 'smoke' }, + [ordered]@{ v = 1; e = 'pluginFailed'; t = $begin.AddSeconds(2).ToString('O'); sessionId = $session; plugin = 'optional'; error = 'allowed fixture failure' }, + [ordered]@{ v = 1; e = 'connected'; t = $begin.AddSeconds(3).ToString('O'); sessionId = $session }, + [ordered]@{ + v = 1; e = 'characterList'; t = $begin.AddSeconds(4).ToString('O') + sessionId = $session; accountName = 'fixture-account'; slotCount = 1 + characters = @([ordered]@{ id = 1342177290; name = 'Fixture'; secondsGreyedOut = 0 }) + }, + [ordered]@{ v = 1; e = 'enteredWorld'; t = $begin.AddSeconds(5).ToString('O'); sessionId = $session; characterId = 1342177290; characterName = 'Fixture' }, + [ordered]@{ v = 1; e = 'loginCommandFailed'; t = $begin.AddSeconds(6).ToString('O'); sessionId = $session; commandIndex = 0; command = '/fixture'; error = 'allowed fixture failure' }, + [ordered]@{ v = 1; e = 'disconnected'; t = $begin.AddSeconds(7).ToString('O'); sessionId = $session; reason = 'stopped' }, + [ordered]@{ v = 1; e = 'exited'; t = $begin.AddSeconds(8).ToString('O'); sessionId = $session; code = 0; reason = 'graceful' } + ) +} + +function Write-Events([string]$Path, [object[]]$Events) { + $lines = @($Events | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress }) + [IO.File]::WriteAllLines($Path, $lines, [Text.UTF8Encoding]::new($false)) +} + +function Invoke-Validator( + [string]$Status, + [string]$Profile, + [string]$Report, + [int]$ExpectedProcessId, + [bool]$ShouldPass, + [string]$SessionConfig = '') { + $arguments = [Collections.Generic.List[string]]::new() + foreach ($value in @( + '-NoProfile', '-File', $validator, + '-StatusFile', $Status, + '-Mode', 'gui', + '-ExpectedProcessId', $ExpectedProcessId.ToString( + [Globalization.CultureInfo]::InvariantCulture), + '-CredentialProfilePath', $Profile, + '-ExpectedPlugin', 'smoke', + '-AllowPluginFailure', + '-AllowLoginCommandFailure', + '-ReportPath', $Report)) { + $arguments.Add($value) + } + if (-not [string]::IsNullOrWhiteSpace($SessionConfig)) { + $arguments.Add('-SessionConfigPath') + $arguments.Add($SessionConfig) + } + $start = [Diagnostics.ProcessStartInfo]::new($pwsh) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + foreach ($argument in $arguments) { $start.ArgumentList.Add($argument) } + $process = [Diagnostics.Process]::Start($start) + if ($null -eq $process) { throw 'Could not start status validator.' } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $outText = $stdout.GetAwaiter().GetResult() + $errorText = $stderr.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + $process.Dispose() + if (($exitCode -eq 0) -ne $ShouldPass) { + throw "Validator result mismatch (exit $exitCode). $outText $errorText" + } +} + +$quickInfo = [Diagnostics.ProcessStartInfo]::new($pwsh) +$quickInfo.UseShellExecute = $false +$quickInfo.ArgumentList.Add('-NoProfile') +$quickInfo.ArgumentList.Add('-Command') +$quickInfo.ArgumentList.Add('exit 0') +$quick = [Diagnostics.Process]::Start($quickInfo) +if ($null -eq $quick) { throw 'Could not create an exited PID fixture.' } +$goneProcessId = $quick.Id +$quick.WaitForExit() +$quick.Dispose() + +$profile = Join-Path $OutputDirectory 'launcher-profiles.json' +Write-Profile $profile 'la11-positive-secret-7E477A2D' +$positiveStatus = Join-Path $OutputDirectory 'positive.jsonl' +Write-Events $positiveStatus (New-GuiEvents) +Invoke-Validator ` + $positiveStatus $profile (Join-Path $OutputDirectory 'positive.validation.json') ` + $goneProcessId $true + +foreach ($reason in @('transport', 'reconnect', 'other')) { + $events = @(New-GuiEvents) + $events[7].reason = $reason + $path = Join-Path $OutputDirectory "reason-$reason.jsonl" + $report = Join-Path $OutputDirectory "reason-$reason.validation.json" + Write-Events $path $events + Invoke-Validator $path $profile $report $goneProcessId $false + $result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json + if (-not ($result.failures -match 'disconnected reason')) { + throw "Disconnected reason '$reason' was not rejected by its exact assertion." + } +} + +$secretCases = @( + 'eventName', 'timestamp', 'sessionId', 'accountName', 'characterName', + 'enteredCharacterName', 'loadedPlugin', 'failedPlugin', 'pluginError', + 'command', 'commandError', 'disconnectedReason', 'exitReason') +foreach ($case in $secretCases) { + $secret = "la11-secret-$case-5A7D" + $caseProfile = Join-Path $OutputDirectory "secret-$case.profile.json" + Write-Profile $caseProfile $secret + $events = @(New-GuiEvents) + switch ($case) { + 'eventName' { $events[0].e = $secret } + 'timestamp' { $events[0].t = $secret } + 'sessionId' { foreach ($event in $events) { $event.sessionId = $secret } } + 'accountName' { $events[4].accountName = $secret } + 'characterName' { $events[4].characters[0].name = $secret } + 'enteredCharacterName' { $events[5].characterName = $secret } + 'loadedPlugin' { $events[1].plugin = $secret } + 'failedPlugin' { $events[2].plugin = $secret } + 'pluginError' { $events[2].error = $secret } + 'command' { $events[6].command = $secret } + 'commandError' { $events[6].error = $secret } + 'disconnectedReason' { $events[7].reason = $secret } + 'exitReason' { $events[8].reason = $secret } + } + $path = Join-Path $OutputDirectory "secret-$case.jsonl" + $report = Join-Path $OutputDirectory "secret-$case.validation.json" + Write-Events $path $events + Invoke-Validator $path $caseProfile $report $goneProcessId $false + $result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json + if (-not ($result.failures -match 'credential value')) { + throw "Credential echo case '$case' was not rejected by recursive scanning." + } +} + +$fixtureSource = Join-Path ` + $Repository 'tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/bin/Release/net10.0' +$fixtureRoot = Join-Path $OutputDirectory 'process-fixture' +Copy-Item -LiteralPath $fixtureSource -Destination $fixtureRoot -Recurse +$sourceBase = 'AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder' +$suffix = if ($IsWindows) { '.exe' } else { '' } +$sourceHost = Join-Path $fixtureRoot "$sourceBase$suffix" +$sameNameHost = Join-Path $fixtureRoot "acdream-headless$suffix" +Copy-Item -LiteralPath $sourceHost -Destination $sameNameHost +foreach ($extension in @('.runtimeconfig.json', '.deps.json')) { + Copy-Item -LiteralPath (Join-Path $fixtureRoot "$sourceBase$extension") ` + -Destination (Join-Path $fixtureRoot "acdream-headless$extension") +} +if ($IsLinux) { + [IO.File]::SetUnixFileMode( + $sameNameHost, + [IO.File]::GetUnixFileMode($sourceHost)) +} + +$sessionConfig = Join-Path $OutputDirectory 'session.json' +[IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false)) +$targetReady = Join-Path $OutputDirectory 'target.ready' +$targetRelease = Join-Path $OutputDirectory 'target.release' +$unrelatedReady = Join-Path $OutputDirectory 'unrelated.ready' +$unrelatedRelease = Join-Path $OutputDirectory 'unrelated.release' + +function Start-Fixture([string[]]$Arguments) { + $start = [Diagnostics.ProcessStartInfo]::new($sameNameHost) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) } + return [Diagnostics.Process]::Start($start) +} + +$target = Start-Fixture @( + 'hold-campaign-la-process', '--config', $sessionConfig, $targetReady, $targetRelease) +$unrelated = Start-Fixture @( + 'hold-update-lease', 'session', (Join-Path $OutputDirectory 'unrelated-data'), + $unrelatedReady, $unrelatedRelease) +if ($null -eq $target -or $null -eq $unrelated) { + throw 'Could not start process-correlation fixtures.' +} +try { + $deadline = [DateTime]::UtcNow.AddSeconds(10) + while ((-not (Test-Path -LiteralPath $targetReady) -or + -not (Test-Path -LiteralPath $unrelatedReady)) -and + [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 50 + } + if (-not (Test-Path -LiteralPath $targetReady) -or + -not (Test-Path -LiteralPath $unrelatedReady)) { + throw 'Process-correlation fixtures did not become ready.' + } + + $captureReport = Join-Path $OutputDirectory 'process-capture.json' + & $pwsh -NoProfile -File $capture ` + -SessionConfigPath $sessionConfig -ReportPath $captureReport + if ($LASTEXITCODE -ne 0) { throw 'Process capture failed.' } + $captured = Get-Content -LiteralPath $captureReport -Raw | ConvertFrom-Json + if ([int]$captured.processId -ne $target.Id) { + throw 'Process capture did not return the exact correlated PID.' + } + + $liveReport = Join-Path $OutputDirectory 'live-pid.validation.json' + Invoke-Validator ` + $positiveStatus $profile $liveReport $target.Id $false $sessionConfig + $liveResult = Get-Content -LiteralPath $liveReport -Raw | ConvertFrom-Json + if (-not ($liveResult.failures -match 'remains alive')) { + throw 'A live exact child PID was not rejected by the terminal validator.' + } + + Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline + $target.WaitForExit() + Invoke-Validator ` + $positiveStatus $profile ` + (Join-Path $OutputDirectory 'unrelated-same-name.validation.json') ` + $target.Id $true $sessionConfig +} +finally { + Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline + Set-Content -LiteralPath $unrelatedRelease -Value 'release' -NoNewline + if (-not $target.HasExited) { $target.WaitForExit() } + if (-not $unrelated.HasExited) { $unrelated.WaitForExit() } + $target.Dispose() + $unrelated.Dispose() +} + +$summary = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-gate-helper-tests' + success = $true + disconnectedReasonNegatives = 3 + credentialStringFieldNegatives = $secretCases.Count + exactPidCapture = $true + livePidRejected = $true + unrelatedSameNameIgnored = $true + platform = if ($IsWindows) { 'windows' } else { 'linux' } +} +$summary | ConvertTo-Json -Depth 4 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM +Write-Host "Campaign LA gate helper tests: $OutputDirectory" diff --git a/tools/test-campaign-la-script-safety.ps1 b/tools/test-campaign-la-script-safety.ps1 new file mode 100644 index 00000000..7124823c --- /dev/null +++ b/tools/test-campaign-la-script-safety.ps1 @@ -0,0 +1,254 @@ +<# +.SYNOPSIS + Connection-free negative and determinism tests for Campaign LA scripts. +#> +[CmdletBinding()] +param( + [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [Parameter(Mandatory = $true)][string]$OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA script-safety tests require PowerShell 7 or newer.' +} +$Repository = [IO.Path]::GetFullPath($Repository) +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $OutputDirectory) { + throw '-OutputDirectory must be fresh.' +} +$null = New-Item -ItemType Directory -Path $OutputDirectory +$pwsh = [Environment]::ProcessPath +if ([string]::IsNullOrWhiteSpace($pwsh)) { + throw 'The PowerShell process path is unavailable.' +} +$preflight = Join-Path $Repository 'tools/run-campaign-la-preflight.ps1' +$fixture = Join-Path $Repository 'tools/new-campaign-la-update-fixture.ps1' +$negativeCount = 0 + +function Invoke-Expected( + [string]$Script, + [string[]]$Arguments, + [bool]$ShouldPass, + [string]$Name) { + $start = [Diagnostics.ProcessStartInfo]::new($pwsh) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.ArgumentList.Add('-NoProfile') + $start.ArgumentList.Add('-File') + $start.ArgumentList.Add($Script) + foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) } + $process = [Diagnostics.Process]::Start($start) + if ($null -eq $process) { throw "Could not start safety case '$Name'." } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $outText = $stdout.GetAwaiter().GetResult() + $errorText = $stderr.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + $process.Dispose() + if (($exitCode -eq 0) -ne $ShouldPass) { + throw "Safety case '$Name' result mismatch (exit $exitCode). $outText $errorText" + } + if (-not $ShouldPass) { $script:negativeCount++ } +} + +$allowed = Join-Path $OutputDirectory 'campaign-la-preflight-safety' +$null = New-Item -ItemType Directory -Path $allowed +Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $allowed, + '-OutputDirectory', (Join-Path $allowed 'positive'), + '-DryRun') $true 'preflight-positive' + +$existingEmpty = Join-Path $allowed 'existing-empty' +$null = New-Item -ItemType Directory -Path $existingEmpty +Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $allowed, + '-OutputDirectory', $existingEmpty, + '-DryRun') $false 'preflight-existing-empty' + +$existingNonempty = Join-Path $allowed 'existing-nonempty' +$null = New-Item -ItemType Directory -Path $existingNonempty +Set-Content -LiteralPath (Join-Path $existingNonempty 'owner') -Value 'preserve' +Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $allowed, + '-OutputDirectory', $existingNonempty, + '-DryRun') $false 'preflight-existing-nonempty' + +$payloadRootRefusal = Join-Path $OutputDirectory 'update-payloads' +$null = New-Item -ItemType Directory -Path $payloadRootRefusal +foreach ($case in @( + [pscustomobject]@{ Name = 'preflight-root'; Allowed = $Repository; Output = (Join-Path $Repository 'blocked') }, + [pscustomobject]@{ Name = 'preflight-home'; Allowed = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile); Output = (Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)) 'blocked') }, + [pscustomobject]@{ Name = 'preflight-source'; Allowed = (Join-Path $Repository 'src'); Output = (Join-Path $Repository 'src/blocked') }, + [pscustomobject]@{ Name = 'preflight-payload'; Allowed = $payloadRootRefusal; Output = (Join-Path $payloadRootRefusal 'blocked') }, + [pscustomobject]@{ Name = 'preflight-outside'; Allowed = $allowed; Output = (Join-Path $OutputDirectory 'outside') }, + [pscustomobject]@{ Name = 'preflight-allowed-root-itself'; Allowed = $allowed; Output = $allowed })) { + Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $case.Allowed, + '-OutputDirectory', $case.Output, + '-DryRun') $false $case.Name +} + +$reparseTarget = Join-Path $OutputDirectory 'campaign-la-reparse-target' +$reparseRoot = Join-Path $OutputDirectory 'campaign-la-reparse-link' +$null = New-Item -ItemType Directory -Path $reparseTarget +if ($IsWindows) { + $null = New-Item -ItemType Junction -Path $reparseRoot -Target $reparseTarget +} +else { + $null = New-Item -ItemType SymbolicLink -Path $reparseRoot -Target $reparseTarget +} +Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $reparseRoot, + '-OutputDirectory', (Join-Path $reparseRoot 'blocked'), + '-DryRun') $false 'preflight-reparse-root' + +$source = Join-Path $OutputDirectory 'payload-source' +$null = New-Item -ItemType Directory -Path $source +function Fixture-DryArguments([string]$Destination, [string]$PayloadSource) { + return @( + '-OutputDirectory', $Destination, + '-ClientWinX64DirectoryA', $PayloadSource, + '-LauncherWinX64DirectoryA', $PayloadSource, + '-ClientLinuxX64DirectoryA', $PayloadSource, + '-LauncherLinuxX64DirectoryA', $PayloadSource, + '-ClientWinX64DirectoryB', $PayloadSource, + '-LauncherWinX64DirectoryB', $PayloadSource, + '-ClientLinuxX64DirectoryB', $PayloadSource, + '-LauncherLinuxX64DirectoryB', $PayloadSource, + '-DryRun') +} +Invoke-Expected $fixture (Fixture-DryArguments (Join-Path $source 'child') $source) ` + $false 'fixture-output-inside-source' +Invoke-Expected $fixture (Fixture-DryArguments $source (Join-Path $source 'child-source')) ` + $false 'fixture-source-inside-output' +Invoke-Expected $fixture (Fixture-DryArguments $source $source) ` + $false 'fixture-output-equals-source' +$nearMatch = Join-Path $OutputDirectory 'payload-source-near' +Invoke-Expected $fixture (Fixture-DryArguments $nearMatch $source) ` + $true 'fixture-near-match' + +$sourceLink = Join-Path $OutputDirectory 'payload-source-link' +if ($IsWindows) { + $null = New-Item -ItemType Junction -Path $sourceLink -Target $source +} +else { + $null = New-Item -ItemType SymbolicLink -Path $sourceLink -Target $source +} +Invoke-Expected $fixture ( + Fixture-DryArguments (Join-Path $OutputDirectory 'reparse-source-output') $sourceLink) ` + $false 'fixture-reparse-source' +$outputTarget = Join-Path $OutputDirectory 'fixture-output-target' +$outputLink = Join-Path $OutputDirectory 'fixture-output-link' +$null = New-Item -ItemType Directory -Path $outputTarget +if ($IsWindows) { + $null = New-Item -ItemType Junction -Path $outputLink -Target $outputTarget +} +else { + $null = New-Item -ItemType SymbolicLink -Path $outputLink -Target $outputTarget +} +Invoke-Expected $fixture (Fixture-DryArguments $outputLink $source) ` + $false 'fixture-reparse-output' + +function Write-PayloadFile([string]$Root, [string]$Name, [string]$Content) { + $path = Join-Path $Root $Name + $directory = Split-Path -Parent $path + $null = New-Item -ItemType Directory -Force -Path $directory + [IO.File]::WriteAllText($path, $Content, [Text.UTF8Encoding]::new($false)) +} +$payloadRoot = Join-Path $OutputDirectory 'deterministic-payloads' +$payloads = [ordered]@{ + ClientWin = Join-Path $payloadRoot 'client-win' + LauncherWin = Join-Path $payloadRoot 'launcher-win' + ClientLinux = Join-Path $payloadRoot 'client-linux' + LauncherLinux = Join-Path $payloadRoot 'launcher-linux' +} +foreach ($directory in $payloads.Values) { + foreach ($entry in @( + @('nested/I.txt', 'I'), @('nested/Z.txt', 'Z'), + @('nested/ä.txt', 'a-umlaut'), @('nested/ı.txt', 'dotless-i'))) { + Write-PayloadFile $directory $entry[0] $entry[1] + } +} +Write-PayloadFile $payloads.ClientWin 'AcDream.App.exe' 'client-win-gui' +Write-PayloadFile $payloads.ClientWin 'acdream-headless.exe' 'client-win-headless' +Write-PayloadFile $payloads.LauncherWin 'acdream-launcher.exe' 'launcher-win' +Write-PayloadFile $payloads.LauncherWin 'acdream-bake.exe' 'bake-win' +Write-PayloadFile $payloads.ClientLinux 'AcDream.App' 'client-linux-gui' +Write-PayloadFile $payloads.ClientLinux 'acdream-headless' 'client-linux-headless' +Write-PayloadFile $payloads.LauncherLinux 'acdream-launcher' 'launcher-linux' +Write-PayloadFile $payloads.LauncherLinux 'acdream-bake' 'bake-linux' + +$fixtureParameters = @{ + ClientWinX64DirectoryA = $payloads.ClientWin + LauncherWinX64DirectoryA = $payloads.LauncherWin + ClientLinuxX64DirectoryA = $payloads.ClientLinux + LauncherLinuxX64DirectoryA = $payloads.LauncherLinux + ClientWinX64DirectoryB = $payloads.ClientWin + LauncherWinX64DirectoryB = $payloads.LauncherWin + ClientLinuxX64DirectoryB = $payloads.ClientLinux + LauncherLinuxX64DirectoryB = $payloads.LauncherLinux +} +$inventories = [Collections.Generic.List[object]]::new() +$originalCulture = [Globalization.CultureInfo]::CurrentCulture +$originalUiCulture = [Globalization.CultureInfo]::CurrentUICulture +try { + foreach ($cultureName in @('en-US', 'tr-TR', 'sv-SE')) { + $culture = [Globalization.CultureInfo]::GetCultureInfo($cultureName) + [Globalization.CultureInfo]::CurrentCulture = $culture + [Globalization.CultureInfo]::CurrentUICulture = $culture + $destination = Join-Path $OutputDirectory "fixture-$cultureName" + & $fixture -OutputDirectory $destination @fixtureParameters + $relativePaths = [string[]]@(Get-ChildItem -LiteralPath $destination -File -Recurse | + Where-Object { $_.Name -ne 'fixture-report.json' } | + ForEach-Object { + [IO.Path]::GetRelativePath($destination, $_.FullName).Replace('\', '/') + }) + [Array]::Sort($relativePaths, [StringComparer]::Ordinal) + $inventory = @($relativePaths | ForEach-Object { + $path = Join-Path $destination $_.Replace('/', [IO.Path]::DirectorySeparatorChar) + "$_|$((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant())" + }) + $inventories.Add($inventory) + } +} +finally { + [Globalization.CultureInfo]::CurrentCulture = $originalCulture + [Globalization.CultureInfo]::CurrentUICulture = $originalUiCulture +} +$firstInventory = [string]::Join("`n", [string[]]$inventories[0]) +foreach ($inventory in $inventories) { + if ([string]::Join("`n", [string[]]$inventory) -cne $firstInventory) { + throw 'Fixture hashes changed with the current culture.' + } +} +$digestBytes = [Security.Cryptography.SHA256]::HashData( + [Text.Encoding]::UTF8.GetBytes($firstInventory)) +$deterministicDigest = [Convert]::ToHexString($digestBytes).ToLowerInvariant() +$expectedCrossPlatformDigest = + '9c77b7204dd19e77fad62e572304d52e810afe2d0821c2ec57a692d27a0cc167' +if ($deterministicDigest -cne $expectedCrossPlatformDigest) { + throw 'Fixture artifact hashes differ from the pinned Windows/Linux contract.' +} + +$summary = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-script-safety-tests' + success = $true + negativeCases = $negativeCount + cultures = @('en-US', 'tr-TR', 'sv-SE') + fixtureArtifactSetSha256 = $deterministicDigest + crossPlatformExpectedSha256 = $expectedCrossPlatformDigest +} +$summary | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM +Write-Host "Campaign LA script safety tests: $OutputDirectory" diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1 index 8f3b2c61..1f93340a 100644 --- a/tools/test-campaign-la-session-status.ps1 +++ b/tools/test-campaign-la-session-status.ps1 @@ -14,15 +14,15 @@ param( [Parameter(Mandatory = $true)][string]$StatusFile, [Parameter(Mandatory = $true)] [ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode, + [Parameter(Mandatory = $true)] + [ValidateRange(1, 2147483647)][int]$ExpectedProcessId, + [Parameter(Mandatory = $true)][string]$CredentialProfilePath, + [string]$SessionConfigPath, [string]$ExpectedSessionId, [string[]]$ExpectedPlugin = @(), [switch]$ExpectNoEnteredWorld, [switch]$AllowPluginFailure, [switch]$AllowLoginCommandFailure, - [switch]$AllowLauncherChildren, - [string[]]$ForbiddenEnvironmentVariable = @( - 'ACDREAM_TEST_PASS', - 'ACDREAM_LA_GATE_SECRET'), [string]$ReportPath, [int]$ProcessExitWaitSeconds = 5 ) @@ -35,12 +35,59 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') { throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.' } +. (Join-Path $PSScriptRoot 'CampaignLaProcessCorrelation.ps1') if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) { $StatusFile = [IO.Path]::GetFullPath($StatusFile) } if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) { throw "Status file does not exist: $StatusFile" } +if (-not [IO.Path]::IsPathFullyQualified($CredentialProfilePath)) { + throw '-CredentialProfilePath must be absolute.' +} +$CredentialProfilePath = [IO.Path]::GetFullPath($CredentialProfilePath) +if (-not (Test-Path -LiteralPath $CredentialProfilePath -PathType Leaf)) { + throw "Credential profile does not exist: $CredentialProfilePath" +} +$credentialItem = Get-Item -LiteralPath $CredentialProfilePath -Force +if (($credentialItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Credential profile must not be a reparse point.' +} +if ($IsLinux) { + $ownerOnly = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite + if ([IO.File]::GetUnixFileMode($CredentialProfilePath) -ne $ownerOnly) { + throw 'Credential profile must have exact owner-only mode 0600.' + } +} +elseif ($IsWindows) { + $broadSids = @( + 'S-1-1-0', # Everyone + 'S-1-5-11', # Authenticated Users + 'S-1-5-32-545', # Builtin Users + 'S-1-5-32-546') # Guests + $acl = Get-Acl -LiteralPath $CredentialProfilePath + if ($null -eq $acl.Owner) { throw 'Credential profile has no ACL owner.' } + foreach ($rule in $acl.Access) { + if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) { + continue + } + try { + $sid = $rule.IdentityReference.Translate( + [Security.Principal.SecurityIdentifier]).Value + } + catch { $sid = [string]$rule.IdentityReference.Value } + if ($sid -in $broadSids -and $rule.FileSystemRights -ne 0) { + throw 'Credential profile grants access to a broad Windows identity.' + } + } +} +else { throw 'Campaign LA status validation supports Windows and Linux only.' } +if (-not [string]::IsNullOrWhiteSpace($SessionConfigPath)) { + if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) { + throw '-SessionConfigPath must be absolute.' + } + $SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) +} if ([string]::IsNullOrWhiteSpace($ReportPath)) { $ReportPath = "$StatusFile.validation.json" } @@ -65,6 +112,55 @@ $loadedPlugins = [Collections.Generic.List[string]]::new() $sessionId = $null $previousTimestamp = [DateTimeOffset]::MinValue $terminalSeen = $false +$forbiddenValues = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal) + +function Add-CredentialValues([Text.Json.JsonElement]$Element) { + if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) { + foreach ($property in $Element.EnumerateObject()) { + if ($property.Name -imatch '^(password|secret)$' -and + $property.Value.ValueKind -eq [Text.Json.JsonValueKind]::String) { + $value = $property.Value.GetString() + if (-not [string]::IsNullOrEmpty($value)) { + $null = $forbiddenValues.Add($value) + } + } + Add-CredentialValues $property.Value + } + } + elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) { + foreach ($item in $Element.EnumerateArray()) { Add-CredentialValues $item } + } +} + +$credentialDocument = [Text.Json.JsonDocument]::Parse( + [IO.File]::ReadAllText($CredentialProfilePath)) +try { Add-CredentialValues $credentialDocument.RootElement } +finally { $credentialDocument.Dispose() } +if ($forbiddenValues.Count -eq 0) { + throw 'Credential profile contains no non-empty password/secret value.' +} + +function Test-CredentialEcho([Text.Json.JsonElement]$Element) { + if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::String) { + [string]$text = $Element.GetString() + foreach ($secret in $forbiddenValues) { + if ($text.Contains($secret, [StringComparison]::Ordinal)) { return $true } + } + return $false + } + if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) { + foreach ($property in $Element.EnumerateObject()) { + if (Test-CredentialEcho $property.Value) { return $true } + } + } + elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) { + foreach ($item in $Element.EnumerateArray()) { + if (Test-CredentialEcho $item) { return $true } + } + } + return $false +} function Get-Properties([Text.Json.JsonElement]$Element) { $properties = [Collections.Generic.List[object]]::new() @@ -112,13 +208,6 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { $failures.Add("line $lineNumber is empty") continue } - foreach ($variable in $ForbiddenEnvironmentVariable) { - $secret = [Environment]::GetEnvironmentVariable($variable) - if (-not [string]::IsNullOrEmpty($secret) -and - $line.Contains($secret, [StringComparison]::Ordinal)) { - $failures.Add("line $lineNumber contains the value of forbidden environment variable $variable") - } - } if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') { $failures.Add("line $lineNumber contains a credential-like JSON field") } @@ -130,6 +219,9 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) { throw 'root is not an object' } + if (Test-CredentialEcho $root) { + throw 'an allowed string field contains an exact credential value' + } $properties = @(Get-Properties $root) $names = @($properties | ForEach-Object { $_.Name }) if (@($names | Sort-Object -Unique).Count -ne $names.Count) { @@ -214,7 +306,12 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { throw 'loginCommandFailed is not allowed for this row' } } - 'disconnected' { $null = Assert-String $root 'reason' } + 'disconnected' { + $reason = Assert-String $root 'reason' + if ($reason -cne 'stopped') { + throw "terminal disconnected reason is '$reason', expected 'stopped'" + } + } 'exited' { $code = Assert-Int32 $root 'code' $reason = Assert-String $root 'reason' @@ -304,16 +401,27 @@ for ($index = 0; $index -lt $eventNames.Count; $index++) { } } -if (-not $AllowLauncherChildren) { +$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) +do { + $expectedProcess = Get-Process -Id $ExpectedProcessId -ErrorAction SilentlyContinue + if ($null -eq $expectedProcess) { break } + Start-Sleep -Milliseconds 100 +} while ([DateTime]::UtcNow -lt $deadline) +if ($null -ne $expectedProcess) { + $failures.Add("expected launcher child PID $ExpectedProcessId remains alive") +} + +$pathCorrelationChecked = -not [string]::IsNullOrWhiteSpace($SessionConfigPath) +if ($pathCorrelationChecked) { $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) do { - $children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue) - if ($children.Count -eq 0) { break } + $correlated = @(Get-CampaignLaCorrelatedProcessIds $SessionConfigPath) + if ($correlated.Count -eq 0) { break } Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $deadline) - if ($children.Count -gt 0) { + if ($correlated.Count -gt 0) { $failures.Add( - "launcher child process leak(s): $((@($children | ForEach-Object { $_.ProcessName + ':' + $_.Id })) -join ',')") + "session-config-correlated launcher child PID(s) remain: $($correlated -join ',')") } } @@ -334,7 +442,11 @@ $report = [ordered]@{ eventNames = @($eventNames) loadedPluginCount = $loadedPlugins.Count terminalObserved = $terminalSeen - launcherChildrenAllowed = [bool]$AllowLauncherChildren + expectedProcessId = $ExpectedProcessId + processExited = ($null -eq $expectedProcess) + sessionConfigCorrelationChecked = $pathCorrelationChecked + credentialPermissionsValidated = $true + forbiddenCredentialValueCount = $forbiddenValues.Count failures = @($failures) validatedUtc = [DateTime]::UtcNow.ToString('O') }