From f881e5b46724a571e6d25da5085bc7f4ca251256 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 23:09:08 +0200 Subject: [PATCH 1/4] feat(launcher): prepare Campaign LA11 user gate --- AcDream.slnx | 2 + docs/ISSUES.md | 66 +- docs/architecture/acdream-architecture.md | 5 +- docs/plans/2026-08-14-launcher-campaign.md | 7 +- .../2026-08-14-campaign-la-test-script.md | 571 ++++++++++++ .../2026-08-14-launcher-campaign-design.md | 4 +- .../Launching/ILauncherChildProcess.cs | 23 +- .../Launching/LauncherProcessSpec.cs | 8 +- .../Launching/LauncherProcessSupervisor.cs | 4 +- .../Launching/WindowsSystemChildProcess.cs | 843 ++++++++++++++++++ .../Orchestration/LauncherExecutableSet.cs | 3 +- src/AcDream.Launcher/AcDream.Launcher.csproj | 4 + .../LauncherStartupOptions.cs | 248 ++++++ ...e.Tests.Fixtures.ConsoleSignalChild.csproj | 10 + .../Program.cs | 54 ++ ...ixtures.ConsolelessSupervisorParent.csproj | 14 + .../Program.cs | 135 +++ .../AcDream.Launcher.Core.Tests.csproj | 12 + .../LauncherProcessSupervisorTests.cs | 318 +++++++ .../LauncherExecutableSetTests.cs | 10 + .../LauncherStartupOptionsTests.cs | 182 ++++ tools/new-campaign-la-update-fixture.ps1 | 333 +++++++ tools/run-campaign-la-preflight.ps1 | 394 ++++++++ tools/test-campaign-la-session-status.ps1 | 346 +++++++ 24 files changed, 3538 insertions(+), 58 deletions(-) create mode 100644 docs/research/2026-08-14-campaign-la-test-script.md create mode 100644 src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs create mode 100644 src/AcDream.Launcher/LauncherStartupOptions.cs create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs create mode 100644 tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs create mode 100644 tools/new-campaign-la-update-fixture.ps1 create mode 100644 tools/run-campaign-la-preflight.ps1 create mode 100644 tools/test-campaign-la-session-status.ps1 diff --git a/AcDream.slnx b/AcDream.slnx index 6080bb7f..b003cfca 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -28,6 +28,8 @@ + + diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 881ed834..421731ab 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -26,53 +26,41 @@ What does NOT go here: ## #397 — Windows: LauncherProcessSupervisor.Stop has no reliable graceful-stop signal for a no-window console host -**Status:** OPEN +**Status:** IN-PROGRESS — the isolated process-group implementation and real +Windows fixtures are complete; the LA11 connected acceptance row remains +required before closure. **Severity:** MODERATE (a hard-killed `AcDream.Headless` leaves the ACE account session stuck for several minutes — a documented project landmine; see CLAUDE.md "Logout-before-reconnect") **Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3) **Component:** Launcher.Core / process supervision -**Description.** `LauncherProcessSupervisor.Stop` now attempts a graceful -stop signal (`ILauncherChildProcess.TryRequestGracefulStop`) BEFORE -`CloseMainWindow`. On Linux this sends `SIGINT` via a `libc` P/Invoke -(`kill(pid, 2)`), which the K4-proven headless host already turns into an -ACE-confirmed graceful logout. On Windows there is no equivalent today for a -console process with no message-pump window: `CloseMainWindow` is a no-op -for a console host (there is no `HWND` to target), and -`GenerateConsoleCtrlEvent` cannot usefully target an arbitrary child process -today — Windows delivers console control events to every process attached -to the SAME console as the calling process, so an unscoped call would also -signal the launcher itself (and anything else sharing that console), not -just the intended child. `TryRequestGracefulStop` therefore returns `false` -on Windows unconditionally, and `Stop` degrades straight to `CloseMainWindow` -(still a no-op for a console child) and then the timeout-driven `Kill()` — -exactly the hard-kill behavior this finding was written to describe, just -with a documented (rather than silent) gap. +**Implementation checkpoint.** `LauncherProcessSupervisor.Stop` attempts +`ILauncherChildProcess.TryRequestGracefulStop` before `CloseMainWindow` and +the timeout/kill fallback. Linux retains its K4-proven targeted `SIGINT`. +On Windows, console-capable launcher specs now use a narrow no-shell +`CreateProcessW` seam with `CREATE_NEW_PROCESS_GROUP`, a suspended start, and +an explicit inherited-handle list that preserves only redirected stdin plus +stdout/stderr. A consoleless Avalonia parent briefly allocates and hides a +console for the creation transaction, detaches after the new group inherits +it, and later attaches only long enough to send +`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)`. Each such +child is therefore both the root of its own process group and, for the normal +Explorer-launched case, attached to its own console. Graphical children opt +out and retain the ordinary `Process`/`WM_CLOSE` path. -**Known fix direction (not yet implemented).** Spawn the Windows child with -the `CREATE_NEW_PROCESS_GROUP` creation flag (available via a native -`CreateProcess` call or by setting it on the `ProcessStartInfo`/`Process` -plumbing in `SystemChildProcess`) so the child gets its own console process -group, detached from the launcher's own group. Then -`TryRequestGracefulStop` on Windows calls -`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)` — -`CTRL_BREAK` (unlike `CTRL_C`) can target a specific process group ID and, -unlike `CTRL_CLOSE`/`CTRL_LOGOFF`/`CTRL_SHUTDOWN`, is deliverable to a -process that has installed no console-control handler at all (the default -CRT handler treats it as a terminating signal, so `AcDream.Headless` doesn't -strictly need new code to receive SOME form of shutdown from it) — though -wiring a real `SetConsoleCtrlHandler` handler that routes `CTRL_BREAK` into -the same graceful-shutdown path K4 already built for Linux SIGINT is the -better long-term target, so a Windows headless launch gets the identical -ACE-confirmed graceful logout instead of just "exits somehow." +Two real Windows fixture gates cover both a console parent and a consoleless +WinExe parent. They prove exact complex argv, redirected stdin, receipt of a +targeted CTRL_BREAK marker, exit code 0 before timeout, no supervisor `Kill`, +and a sibling process group that remains running until it receives its own +targeted break. Safe-handle cleanup, early-failure termination, and the +Linux SIGINT gate remain covered by the Launcher.Core suite. -**Acceptance for closing this issue:** `SystemChildProcess` spawns Windows -children with `CREATE_NEW_PROCESS_GROUP`; `TryRequestGracefulStop` sends -`CTRL_BREAK_EVENT` to that child's process group on Windows; a live -connected gate proves `AcDream.Headless` exits gracefully (ACE clears the -session immediately, not after the ~3-minute stale-session window) when -stopped via `LauncherProcessSupervisor.Stop` on Windows, matching the +**Acceptance for closing this issue:** automated process-group and targeted- +signal coverage is complete. Keep the issue IN-PROGRESS until the LA11 live +connected row proves `AcDream.Headless` exits gracefully and ACE clears the +session immediately (not after the ~3-minute stale-session window) when +stopped through `LauncherProcessSupervisor.Stop` on Windows, matching the Linux SIGINT behavior. ## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 1d1f44de..c909820d 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -312,7 +312,10 @@ src/ AcDream.Launcher.Core/ BCL-only launcher state/orchestration owner Profiles/ -> sole credential/profile document + CRUD owner - Launching/ -> config composition and supervised process seams + Launching/ -> config composition and supervised process seams; + Windows console hosts are no-shell, redirected- + stdin process-group leaders receiving targeted + CTRL_BREAK, while Linux hosts receive SIGINT Status/ -> incremental host-status parsing/tailing Orchestration/ -> immutable UI snapshots, typed actions, capability gates, and running-session lifetime diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 180d320f..76fd65bc 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -697,7 +697,10 @@ forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions. ## LA11 — closeout -- One connected-gate script `docs/research/2026-XX-XX-campaign-la-test-script.md` +- One exact operator script + `docs/research/2026-08-14-campaign-la-test-script.md`, fronted by the + connection-free `tools/run-campaign-la-preflight.ps1` and followed by + serial user rows, covering: all three launch modes vs local ACE, probe round-trip ×2 (no lingering session), char-select visual matrix + delete flow, login-commands + plugin behavior on both hosts, add-server/add-account purely in UI, @@ -729,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | LA8 | — | | | | | LA9 | — | | | | | LA10 | — | | | | -| LA11 | — | | | | +| LA11 | **IMPLEMENTATION CHECKPOINT 2026-08-14 — USER GATE PENDING** | pending integration | Review and connected/visual acceptance pending | Strict isolated-root/feed parsing, Windows targeted CTRL_BREAK fixtures, deterministic A/B loopback fixture, automated preflight/status validators, and the exact Windows + Ubuntu/WSL operator script are implemented. Launcher startup composition awaits the LA10 review-fix rebase; no connected row has run and the campaign is not shipped. | diff --git a/docs/research/2026-08-14-campaign-la-test-script.md b/docs/research/2026-08-14-campaign-la-test-script.md new file mode 100644 index 00000000..664a569e --- /dev/null +++ b/docs/research/2026-08-14-campaign-la-test-script.md @@ -0,0 +1,571 @@ +# Campaign LA11 — automated preflight and connected user gate + +**Status:** implementation checkpoint only. Run this script after the reviewed +LA10/LA11 commits are integrated and the campaign branch is clean. Campaign LA, +the Linux graphical client, and issue #397 remain open until the user records a +verdict for every applicable row below. + +This is the single Campaign LA operator script. The automated section is +display-free and connection-free. Rows A–I are deliberately manual and serial: +they use real retail DATs, a local ACE server, user-entered credentials, and +visual judgment that automation cannot supply. + +## 1. Safety boundary and required inputs + +Use placeholders throughout; never paste a password into a terminal, this +document, a screenshot, or a gate report. + +- ``: a clean Campaign LA worktree at the exact commit + under test. +- ``: a read-only source containing + `client_portal.dat`, `client_cell_1.dat`, `client_highres.dat`, and + `client_local_English.dat`. +- ``, ``, and ``: a local ACE endpoint and + account. Enter the account password only in the launcher's masked Password + field. The launcher intentionally stores it as plaintext in the **isolated** + `launcher-profiles.json`; children receive it through redirected stdin. +- ``: a second user-controlled character that can observe a + private `/tell` from each play mode. +- ``: a server-operator-approved disposable character. + Never substitute a primary character. If none exists, provision one with the + local server's normal admin procedure before row G. +- Windows 11 x64, PowerShell 7, .NET 10 SDK, a local ACE server, and a supported + Vulkan Windows machine for rows A–H. Ubuntu x64 with PowerShell 7 and a Linux + desktop/WSLg is required for row I. The Avalonia launcher is supported on + Linux; `gui` and `guiSelect` **client** actions must remain disabled with the + Modern Runtime Slice-L explanation. + +Close every unrelated `AcDream.App`, `acdream-headless`, and acdream launcher +before starting. Do not run another acdream gate in parallel. All generated +files must stay below one new gate directory; the canonical `%APPDATA%`, +`%LOCALAPPDATA%`, and XDG acdream roots are out of scope. + +## 2. Automated preflight — no UI, connection, credential, or bake + +In PowerShell 7 on Windows: + +```powershell +$Repo = [IO.Path]::GetFullPath('') +$Stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') +$Gate = Join-Path $Repo "logs/campaign-la-user-gate-$Stamp" +$Preflight = Join-Path $Gate 'automated-preflight' +New-Item -ItemType Directory -Path $Gate | Out-Null + +pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') ` + -Repository $Repo ` + -OutputDirectory $Preflight + +$Report = Get-Content -LiteralPath (Join-Path $Preflight 'report.json') -Raw | + ConvertFrom-Json +if (-not $Report.success -or $Report.dirty) { + throw 'Stop: automated preflight failed or recorded a dirty worktree.' +} +if ($Report.head -cne (git -C $Repo rev-parse HEAD).Trim()) { + throw 'Stop: preflight HEAD does not equal the current HEAD.' +} +``` + +The expected matrix is: + +| Platform | Automated command group | Required result | Typical time | +|---|---|---|---:| +| Windows | Release `AcDream.slnx` build, `-m:1` | exit 0 | 5–15 min | +| Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 20–60 min | +| Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 1–4 min | +| Windows | canonical portable project build/test closure from `headless-portability.yml` | every project exits 0 | 10–25 min | +| Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 3–10 min | +| Windows | native launcher `--verify-publish` and bake `--help` with bogus `DOTNET_ROOT*` | both exit 0 | <1 min | +| Ubuntu/WSL | run the same helper natively from the Linux path to the worktree | Linux RID report and every row exit 0 | 35–90 min | + +`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 26 commands. It never launches App or Headless in connected mode and +never reads a credential. + +### Optional installed-DAT read-only row + +This is not a bake and must not replace row A. Add the switches below only when +the DAT directory may be read by tests: + +```powershell +pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') ` + -Repository $Repo ` + -OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') ` + -IncludeInstalledDat ` + -InstalledDatDirectory '' +``` + +The mandatory installed-DAT result is +`CharacterManagementLiveDatTests` with both `ACDREAM_PROBE_LIVE_MOUNT=1` and +`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: 30 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 +`wsl.exe` as the Linux row. + +## 3. Prepare the deterministic local A/B feed + +Build distinct, version-stamped payloads so the staged launcher really changes +from A to B. These commands write only below `$Gate` (normal project `obj/bin` +incremental outputs are the already-authorized build outputs): + +```powershell +$VersionA = '1.0.1-la11.a' +$VersionB = '1.0.1-la11.b' +$Payloads = Join-Path $Gate 'update-payloads' +$Fixture = Join-Path $Gate 'update-fixture' + +function Publish-LaRelease([string]$Version, [string]$Label) { + $ClientWin = Join-Path $Payloads "$Label/client-win-x64" + $LauncherWin = Join-Path $Payloads "$Label/launcher-win-x64" + $ClientLinux = Join-Path $Payloads "$Label/client-linux-x64" + $LauncherLinux = Join-Path $Payloads "$Label/launcher-linux-x64" + + dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') ` + -c Release -r win-x64 --self-contained true -p:Version=$Version ` + -o $ClientWin --nologo + if ($LASTEXITCODE) { throw "App win-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') ` + -c Release -r win-x64 --self-contained true -p:Version=$Version ` + -o $ClientWin --nologo + if ($LASTEXITCODE) { throw "Headless win-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') ` + -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true ` + -p:Version=$Version -o $LauncherWin --nologo + if ($LASTEXITCODE) { throw "Launcher win-x64 publish failed: $Label" } + + dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') ` + -c Release -r linux-x64 --self-contained true -p:Version=$Version ` + -o $ClientLinux --nologo + if ($LASTEXITCODE) { throw "App linux-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') ` + -c Release -r linux-x64 --self-contained true -p:Version=$Version ` + -o $ClientLinux --nologo + if ($LASTEXITCODE) { throw "Headless linux-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') ` + -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true ` + -p:Version=$Version -o $LauncherLinux --nologo + if ($LASTEXITCODE) { throw "Launcher linux-x64 publish failed: $Label" } +} + +Publish-LaRelease $VersionA 'A' +Publish-LaRelease $VersionB 'B' + +pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1') ` + -OutputDirectory $Fixture ` + -ClientWinX64DirectoryA (Join-Path $Payloads 'A/client-win-x64') ` + -LauncherWinX64DirectoryA (Join-Path $Payloads 'A/launcher-win-x64') ` + -ClientLinuxX64DirectoryA (Join-Path $Payloads 'A/client-linux-x64') ` + -LauncherLinuxX64DirectoryA (Join-Path $Payloads 'A/launcher-linux-x64') ` + -ClientWinX64DirectoryB (Join-Path $Payloads 'B/client-win-x64') ` + -LauncherWinX64DirectoryB (Join-Path $Payloads 'B/launcher-win-x64') ` + -ClientLinuxX64DirectoryB (Join-Path $Payloads 'B/client-linux-x64') ` + -LauncherLinuxX64DirectoryB (Join-Path $Payloads 'B/launcher-linux-x64') +``` + +The helper rejects nonempty output, invalid or non-monotonic versions, missing +root executables, and nonabsolute inputs. It writes fixed-timestamp sorted ZIPs, +the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only +server, and an A/B selector. It does not download or mutate payload sources. + +Start the Windows loopback server without a shell or visible helper window: + +```powershell +$ServerInfo = [Diagnostics.ProcessStartInfo]::new() +$ServerInfo.FileName = (Get-Command pwsh).Source +$ServerInfo.UseShellExecute = $false +$ServerInfo.CreateNoWindow = $true +foreach ($Value in @( + '-NoProfile', '-File', (Join-Path $Fixture 'serve-fixture.ps1'), + '-Root', $Fixture, '-Port', '43119')) { + $ServerInfo.ArgumentList.Add($Value) +} +$FixtureServer = [Diagnostics.Process]::Start($ServerInfo) +$ManifestUri = 'http://127.0.0.1:43119/manifest.json' +if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionA) { + throw 'Stop: local fixture did not begin on release A.' +} +``` + +## 4. Windows isolated launcher command and evidence rule + +```powershell +$WinRoot = Join-Path $Gate 'windows-roots' +$WinConfig = Join-Path $WinRoot 'config' +$WinData = Join-Path $WinRoot 'data' +$WinCache = Join-Path $WinRoot 'cache' +$Evidence = Join-Path $Gate 'evidence' +New-Item -ItemType Directory -Path $Evidence | Out-Null + +$LauncherA = Join-Path $Payloads 'A/launcher-win-x64/acdream-launcher.exe' +$LauncherArguments = @( + '--config-dir', $WinConfig, + '--data-dir', $WinData, + '--cache-dir', $WinCache, + '--update-manifest-uri', $ManifestUri) +& $LauncherA @LauncherArguments +``` + +All four options are process-local. The three roots are an indivisible set; +the local feed reaches only the updater and is never persisted. A self-update +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: + +```powershell +$Status = Join-Path $WinCache 'launcher/sessions//status.jsonl' +pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` + -StatusFile $Status ` + -Mode '' ` + -ExpectedSessionId '' ` + -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. + +## 5. Serial Windows user rows A–H + +### A — isolated first run, real DAT bake, and release-A client baseline + +1. Confirm the launcher opens First-run setup and all launch buttons are + unavailable. Save a redacted screenshot as `A-first-run-required.png`. +2. Enter `` in the wizard, select a sensible + worker count, and click **Validate**. Confirm all four DATs pass. +3. Click **Build and install**. Do not cancel or close the launcher. The real + bake may take 30–180 minutes. Confirm every phase reaches **Completed** and + the status says `Client content installed and verified. Launch is enabled.` +4. Open **Check for updates**. Confirm available release A, click **Install + client**, and wait for `Client update installed and activated.` Do not stage + launcher A; the test launcher already has version A. +5. Confirm these exact isolated artifacts exist and no `.previous-install` + remains after success: + +```powershell +$RequiredA = @( + (Join-Path $WinData 'install.json'), + (Join-Path $WinData 'pak/acdream.pak'), + (Join-Path $WinData 'app/current.json')) +foreach ($Path in $RequiredA) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing $Path" } +} +$RequiredA | ForEach-Object { + $Item = Get-Item -LiteralPath $_ + [ordered]@{ + name = $Item.Name + size = $Item.Length + sha256 = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash.ToLowerInvariant() + } +} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'A-install-hashes.json') +``` + +Do not copy `install.json` into shared evidence because it records the local DAT +path. Expected time: 45–200 minutes including bake. + +### B — server/account CRUD entirely through the UI + +1. Add `` at `127.0.0.1:`, edit its name + and port, then remove it. Confirm Cancel/Escape makes no mutation. +2. Add `` at `127.0.0.1:`. +3. Under it add `` with a user-invented throwaway field + value, edit its account name/value, then remove it. Do not reuse a real + password for this temporary row. +4. Add `` and enter its real password only in the masked field. +5. Close and reopen the launcher with the **same** `$LauncherArguments`. Confirm + only the real server/account persisted. Save redacted before/reopen images as + `B-crud-before-reopen.png` and `B-crud-after-reopen.png`. +6. Record only the profile file's size/hash, never its contents: + +```powershell +$Profile = Join-Path $WinConfig 'launcher-profiles.json' +$Item = Get-Item -LiteralPath $Profile +[ordered]@{ + size = $Item.Length + sha256 = (Get-FileHash -LiteralPath $Profile -Algorithm SHA256).Hash.ToLowerInvariant() +} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'B-profile-hash.json') +``` + +Expected time: 10–15 minutes. + +### C — live character probe twice, no stale ACE session + +1. Select ``, click **Refresh characters**, and wait for the + probe row to finish. Confirm the roster appears without entering world. +2. Run the validator in `probe` mode for its session id. Confirm its exact + event order is `started, connected, characterList, disconnected, exited`, + with no `enteredWorld`, terminal code 0, and terminal reason `probe`. +3. In the ACE console/session administration view, confirm the account is no + longer logged in. Save a redacted `C-probe-1-ace-cleared.png`. +4. Repeat steps 1–3 immediately, producing a different session id, + `C-probe-2-status.validation.json`, and `C-probe-2-ace-cleared.png`. +5. Confirm Refresh is re-enabled and no `acdream-headless` process remains. + +Expected time: 5–10 minutes. A stale ACE account or timeout is a gate failure; +do not wait three minutes and call the next attempt a pass. + +### D — `guiSelect`, retail character screen, plugin, and login command + +1. Select one non-disposable roster character. Set its mode to `guiSelect`, + Plugins to exactly `acdream.smoke`, and its one login command to + `/tell , LA11-D-`. Save settings. +2. Click **GUI — character select**. Confirm the flat retail character list, + selection highlight, Enter button, Delete/Restore swap state, dialogs, and + absence of any invented rotating 3D preview. Save redacted + `D-character-select.png`. +3. Select the configured character and enter world. Confirm the observer gets + the exact D nonce once. Save `D-observer-tell.png` with names redacted. +4. Click **Stop** in the launcher. Confirm the game closes gracefully and ACE + releases the account. Validate `guiSelect` with + `-ExpectedPlugin acdream.smoke`. + +Expected time: 5–10 minutes. + +### E — direct `gui`, plugin, and login command + +1. Change the same character to `gui`, retain `acdream.smoke`, and change the + command nonce to `LA11-E-`. +2. Click **GUI — enter world**. Confirm it selects the exact cached character, + reaches the world, loads the plugin once, and the observer gets the E nonce + once. +3. Stop from the launcher, confirm ACE logout, and validate `gui` with the + expected plugin. Save `E-world.png`, `E-observer-tell.png`, and + `E-status.validation.json` with identifying text redacted. + +Expected time: 5–10 minutes. + +### F — headless, plugin/login command, and connected #397 acceptance + +1. Change the same character to `headless`, retain `acdream.smoke`, and use + `LA11-F-`. +2. Click **Headless**. Confirm `pluginLoaded(acdream.smoke)`, `enteredWorld`, + and the observer's single exact F nonce. +3. Click **Stop** once. On Windows this must target that child's distinct + process group with `CTRL_BREAK`; it must reach `disconnected` then + `exited(code:0, reason:graceful)` before the timeout, without a hard kill. + ACE must release the account immediately and the launcher must stay open. +4. Validate `headless` with the expected plugin and save + `F-status.validation.json` plus redacted ACE-clear evidence. + +The real automated fixture separately proves complex argv and redirected stdin +survive native `CreateProcessW`, the target receives `CTRL_BREAK`, a sibling +process group receives nothing, exit 0 precedes timeout, and `Kill` is never +called. This connected row proves the actual ACE graceful-logout half. Issue +#397 remains open if either half is missing. Expected time: 5–10 minutes. + +### G — disposable delete and restore + +1. Launch `guiSelect` for ``. Do not enter world. +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`. +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 + release. Validate with: + +```powershell +pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` + -StatusFile (Join-Path $WinCache 'launcher/sessions//status.jsonl') ` + -Mode guiSelect ` + -ExpectNoEnteredWorld ` + -ExpectedSessionId '' ` + -ExpectedPlugin acdream.smoke ` + -ReportPath (Join-Path $Evidence 'G-status.validation.json') +``` + +If restore fails, stop the row, preserve evidence, and restore only that +disposable character with the server's normal admin recovery. Never continue +with another character. Expected time: 5–10 minutes. + +### H — local A→B client update, active-session refusal, rollback, self-update + +1. Record release A's `app/current.json`. Start one headless session and wait + for `enteredWorld`. +2. Switch the fixture atomically to B: + +```powershell +pwsh -NoProfile -File (Join-Path $Fixture 'set-active-release.ps1') ` + -Release B -Root $Fixture +if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionB) { + throw 'Stop: fixture did not switch to B.' +} +``` + +3. Open **Check for updates** and **Check again**. While the session is active, + confirm Install client, Rollback client, and Stage launcher are disabled or + refuse without changing `app/current.json`. Save `H-active-refusal.png`. +4. Stop the headless session and validate its graceful status. Install client + B. Confirm `app/current.json` names B, A is previous, all installed-file + hashes verify, and new sessions resolve from the B directory. +5. Click **Rollback client**. Confirm A becomes current and B becomes previous. + Check again and install B once more, leaving B current. Save sanitized copies + of the three pointer states as `H-pointer-a.json`, `H-pointer-b.json`, and + `H-pointer-rollback-a.json`; they contain no credentials. +6. Click **Stage launcher**. Confirm restart is required, then close the + launcher normally. The copied helper must apply B and restart the launcher + with the same config/data/cache/feed suffix. +7. Confirm profiles, install record, and update state still come from the + isolated roots; `campaign-la-fixture-release.txt` beside the relaunched + executable says `release=B`; `launcher-update/pending.json` is gone; and no + transaction backup remains. Check again and confirm launcher B is current. + Save `H-self-update-confirmed.png` and a hash-only post-state inventory. + +Never edit a manifest to force this row and never point the launcher at a +non-loopback HTTP endpoint. Expected time: 15–30 minutes. + +## 6. Row I — native Ubuntu/WSL launcher, XDG-shaped isolated roots + +Stop the Windows launcher and fixture server only after every Windows session +is terminal: + +```powershell +if (-not $FixtureServer.HasExited) { + $FixtureServer.Kill() + $FixtureServer.WaitForExit() +} +``` + +In a native Ubuntu/WSL PowerShell 7 terminal, set Linux paths. The repository +and fixture may be read from a mounted Windows path, but roots must live on the +Linux filesystem. Run the generated server natively so its `127.0.0.1` URLs +cannot escape the Linux environment: + +```powershell +$RepoLinux = [IO.Path]::GetFullPath('') +$FixtureLinux = [IO.Path]::GetFullPath('') +$PayloadsLinux = [IO.Path]::GetFullPath('') +$LinuxGate = [IO.Path]::GetFullPath('') +$env:XDG_CONFIG_HOME = Join-Path $LinuxGate 'xdg-config-home' +$env:XDG_DATA_HOME = Join-Path $LinuxGate 'xdg-data-home' +$env:XDG_CACHE_HOME = Join-Path $LinuxGate 'xdg-cache-home' +$LinuxConfig = Join-Path $env:XDG_CONFIG_HOME 'acdream' +$LinuxData = Join-Path $env:XDG_DATA_HOME 'acdream' +$LinuxCache = Join-Path $env:XDG_CACHE_HOME 'acdream' +$LinuxEvidence = Join-Path $LinuxGate 'evidence' +New-Item -ItemType Directory -Path $LinuxEvidence | Out-Null + +pwsh -NoProfile -File (Join-Path $FixtureLinux 'set-active-release.ps1') ` + -Release A -Root $FixtureLinux +``` + +Start `serve-fixture.ps1 -Root $FixtureLinux -Port 43119` in a dedicated native +terminal and leave it running. In another terminal: + +```powershell +$LauncherLinuxA = Join-Path $PayloadsLinux 'A/launcher-linux-x64/acdream-launcher' +& $LauncherLinuxA ` + --config-dir $LinuxConfig ` + --data-dir $LinuxData ` + --cache-dir $LinuxCache ` + --update-manifest-uri 'http://127.0.0.1:43119/manifest.json' +``` + +Complete this exact serial matrix: + +1. **Manual-DAT first run:** enter ``; + auto-detection may be empty by design. Validate, bake to + `$LinuxData/pak/acdream.pak`, verify, then install release-A client. +2. **CRUD:** add/edit/remove a temporary server and account entirely in the + launcher, then add the real Linux-reachable ACE profile. Enter its password + only in the masked field. Restart and confirm persistence. Run + `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. +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. +5. **Headless:** configure `acdream.smoke` and + `/tell , LA11-I-`, launch, observe + the tell, click Stop, and validate `headless` + expected plugin. Native Linux + sends SIGINT and must reach graceful terminal status with no process leak. +6. **Update:** switch the native fixture to B, prove update actions refuse while + a headless session is active, stop it gracefully, install B, rollback to A, + reinstall B, stage launcher B, and close normally. Confirm the relaunched + binary's B marker, preserved explicit roots/feed, cleaned pending journal, + and executable owner bits on App, Headless, Launcher, and Bake. + +Copy only redacted screenshots, validation reports, pointer JSON, hashes, and +file-mode results into `$LinuxEvidence`. Keep the Linux profile and raw session +files local. Expected time: 60–220 minutes, dominated by the real bake. + +## 7. Evidence, redaction, verdict, and cleanup + +Expected evidence tree: + +```text +logs/campaign-la-user-gate-/ + automated-preflight/report.json + automated-preflight/commands/*.log + automated-preflight/publish/{win-x64,linux-x64}/... + update-fixture/fixture-report.json + update-fixture/{A,B}/manifest.json + evidence/A-install-hashes.json + evidence/B-*.png + evidence/C-probe-{1,2}-status.validation.json + evidence/D-*.png + D-status.validation.json + evidence/E-*.png + E-status.validation.json + evidence/F-*.png + F-status.validation.json + evidence/G-*.png + G-status.validation.json + evidence/H-*.png + H-pointer-*.json + evidence/I-*.png + I-status.validation.json + I-modes.txt + verdict.json +``` + +Before sharing evidence: + +- remove or mask account names, character names, DAT paths, hostnames other than + loopback, and server-admin identifiers from screenshots; +- never copy `launcher-profiles.json`, raw session configs/status streams, + stdout/stderr that may contain user text, or environment values; +- search the shareable evidence for the exact user-entered password and any + gate-only sentinel secret; the match count must be zero; +- retain SHA-256 and sizes so local raw artifacts remain auditable. + +No additional raw child/plugin diagnostic sink is required: `pluginLoaded`, +the strict terminal status, the observer's redacted tell evidence, and the +automated targeted-signal fixture cover the acceptance questions without +capturing credentials or arbitrary chat. + +Create `verdict.json` manually with schema version 1, exact tested HEAD, rows +A–I as `pass`, `fail`, or `notApplicable`, a short redacted note per row, and +the user's overall verdict. Row I is not applicable only when no native +Ubuntu/WSL desktop is available; it blocks Campaign LA shipping under the +current Linux requirement, so it cannot be silently omitted. + +Cleanup is graceful-first and serial: + +1. Restore `` and verify it is ordinary before closing + its session. +2. Stop every launcher session once; require a passing validator and ACE-clear + observation. If a child survives the timeout, record the gate failure and + its PID before any emergency termination. +3. Close each launcher normally, then stop only the fixture-server process + created above. Do not kill ACE as a substitute for logout evidence. +4. Leave update pointers on B or roll the **isolated** client back to A through + the UI; never edit pointers or journals by hand. +5. Remove the real account through the isolated launcher UI. After review, + delete only the explicitly recorded `$WinConfig`/`$LinuxConfig` gate roots + that held plaintext passwords, or change the test account password. Do not + recursively delete a computed, empty, canonical, home, repository, or XDG + parent path. +6. Preserve the redacted evidence and reports. The large isolated pak/payload + trees may be removed only after resolving and checking their full paths are + descendants of the recorded gate roots. + +Estimated total: 3–7 hours, primarily the two real DAT bakes and full serial +test suites. A failure stops the current row; restore/stop/collect evidence, +then diagnose before advancing. Do not mark LA11, Campaign LA, or #397 shipped +until the user accepts the complete applicable matrix. diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index db477433..dbd662b3 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -331,7 +331,9 @@ the process supervisor executes. Campaign V); visuals settle at the user gate. - **Headless plugin host:** fixture plugin in the Headless suite (load, capability flag, teardown). -- **Connected gates (user-driven):** every launch mode against local ACE +- **Connected gates (user-driven):** execute the exact serial matrix in + `docs/research/2026-08-14-campaign-la-test-script.md` only after its + connection-free automated preflight passes. Cover every launch mode against local ACE (gui / guiSelect / headless), the character probe (fresh account → refresh → roster appears, and repeated probes leaving no stale ACE session), clean-profile first-run wizard end-to-end, staged-manifest diff --git a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs index 872c0130..b8816ab7 100644 --- a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs +++ b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs @@ -41,11 +41,10 @@ public interface ILauncherChildProcess : IDisposable /// documented project landmine; see CLAUDE.md /// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved /// the headless host's SIGINT handler produces an ACE-confirmed - /// graceful logout). On Windows there is no reliable cross-console - /// mechanism for an arbitrary no-window child process today — see - /// docs/ISSUES.md for the tracked gap and fix direction; this - /// returns false there. Returns true only when the signal was - /// actually delivered; never throws. + /// graceful logout). On Windows, console-capable children are started + /// as distinct process-group leaders and receive a targeted + /// CTRL_BREAK_EVENT. Returns true only when the signal was actually + /// delivered; never throws. /// bool TryRequestGracefulStop(); @@ -72,7 +71,9 @@ public interface ILauncherChildProcessFactory public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory { public ILauncherChildProcess Create(LauncherProcessSpec spec) => - new SystemChildProcess(spec); + OperatingSystem.IsWindows() && spec.SupportsConsoleGracefulStop + ? new WindowsSystemChildProcess(spec) + : new SystemChildProcess(spec); } internal sealed partial class SystemChildProcess : ILauncherChildProcess @@ -86,11 +87,13 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess private static partial int kill(int pid, int sig); private readonly Process _process; + private readonly bool _supportsConsoleGracefulStop; private bool _raisingEnabled; internal SystemChildProcess(LauncherProcessSpec spec) { ArgumentNullException.ThrowIfNull(spec); + _supportsConsoleGracefulStop = spec.SupportsConsoleGracefulStop; var startInfo = new ProcessStartInfo { @@ -130,11 +133,11 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess public bool TryRequestGracefulStop() { - if (!OperatingSystem.IsLinux()) + if (!OperatingSystem.IsLinux() || !_supportsConsoleGracefulStop) { - // No reliable cross-console mechanism exists for an - // arbitrary no-window Windows child process — tracked gap, - // see docs/ISSUES.md. + // Windows console-capable children use + // WindowsSystemChildProcess. Graphical/non-console children + // deliberately retain the Process/WM_CLOSE path. return false; } diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs index 11b59dee..599a932d 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs @@ -7,9 +7,13 @@ namespace AcDream.Launcher.Core.Launching; /// Deliberately carries no credential field — the password is a separate /// transient parameter to /// that flows only to the child's stdin, never into this spec, an -/// argument list, or a process environment. +/// argument list, or a process environment. Console-capable specs set +/// so Windows starts them +/// as isolated process-group leaders for targeted CTRL_BREAK_EVENT and +/// Linux sends SIGINT; graphical specs leave it false and use WM_CLOSE. /// public sealed record LauncherProcessSpec( string ExecutablePath, IReadOnlyList Arguments, - string? WorkingDirectory = null); + string? WorkingDirectory = null, + bool SupportsConsoleGracefulStop = true); diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs index 3a4c2923..252847c5 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs @@ -171,8 +171,8 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor /// /// Requests a graceful stop — first /// (SIGINT - /// on Linux; a no-op on Windows today, see - /// 's docs), + /// on Linux; targeted CTRL_BREAK_EVENT for supported Windows console + /// children), /// then — falling /// back to if the process has /// not exited within . A no-op if diff --git a/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs new file mode 100644 index 00000000..00138c30 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs @@ -0,0 +1,843 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace AcDream.Launcher.Core.Launching; + +/// +/// Windows launcher child created without a shell as a true console process- +/// group leader. The native start is deliberately narrow: it exists only +/// because does not expose +/// CREATE_NEW_PROCESS_GROUP while the launcher must retain redirected stdin. +/// +internal sealed class WindowsSystemChildProcess : ILauncherChildProcess +{ + private readonly LauncherProcessSpec _spec; + private readonly IWindowsConsoleControl _consoleControl; + private Process? _process; + private TextWriter? _standardInput; + private int _processGroupId; + private bool _raisingEnabled; + + internal WindowsSystemChildProcess( + LauncherProcessSpec spec, + IWindowsConsoleControl? consoleControl = null) + { + _spec = spec ?? throw new ArgumentNullException(nameof(spec)); + _consoleControl = consoleControl ?? WindowsConsoleControl.Instance; + } + + public bool HasExited => RequireProcess().HasExited; + + public int ExitCode => RequireProcess().ExitCode; + + public TextWriter StandardInput => _standardInput + ?? throw new InvalidOperationException("The child process has not started."); + + public event EventHandler? Exited; + + public void Start() + { + if (_process is not null) + { + throw new InvalidOperationException("The child process already started."); + } + + WindowsProcessStartResult started = WindowsProcessNative.Start(_spec); + try + { + _process = Process.GetProcessById(started.ProcessId); + _process.EnableRaisingEvents = true; + _process.Exited += OnExited; + _raisingEnabled = true; + _standardInput = started.TakeStandardInput(); + _processGroupId = started.ProcessId; + started.Resume(); + } + catch + { + started.Terminate(); + _standardInput?.Dispose(); + _standardInput = null; + if (_process is not null) + { + if (_raisingEnabled) + { + _process.Exited -= OnExited; + } + + _process.Dispose(); + _process = null; + } + + throw; + } + finally + { + started.Dispose(); + } + } + + public bool TryRequestGracefulStop() + { + try + { + if (!_spec.SupportsConsoleGracefulStop + || _process is not { HasExited: false } process + || _processGroupId <= 0) + { + return false; + } + + return _consoleControl.TrySendBreak(process.Id, _processGroupId); + } + catch + { + // The process may have exited between the state check and the + // control request. Graceful-stop attempts never escape Stop(). + return false; + } + } + + public bool CloseMainWindow() => RequireProcess().CloseMainWindow(); + + public void Kill() => RequireProcess().Kill(entireProcessTree: true); + + public bool WaitForExit(TimeSpan timeout) => RequireProcess().WaitForExit(timeout); + + public void Dispose() + { + _standardInput?.Dispose(); + _standardInput = null; + if (_process is not null) + { + if (_raisingEnabled) + { + _process.Exited -= OnExited; + } + + _process.Dispose(); + _process = null; + } + } + + private Process RequireProcess() => _process + ?? throw new InvalidOperationException("The child process has not started."); + + private void OnExited(object? sender, EventArgs e) => + Exited?.Invoke(this, EventArgs.Empty); +} + +internal interface IWindowsConsoleControl +{ + bool TrySendBreak(int childProcessId, int childProcessGroupId); +} + +internal sealed class WindowsConsoleControl : IWindowsConsoleControl +{ + private const uint CtrlBreakEvent = 1; + + internal static WindowsConsoleControl Instance { get; } = new(); + + private WindowsConsoleControl() + { + } + + public bool TrySendBreak(int childProcessId, int childProcessGroupId) + { + if (!OperatingSystem.IsWindows() + || childProcessId <= 0 + || childProcessGroupId <= 0) + { + return false; + } + + lock (WindowsConsoleSynchronization.Gate) + { + bool attachedHere = false; + try + { + uint[] processes = new uint[1]; + if (Native.GetConsoleProcessList(processes, 1) == 0) + { + if (!Native.AttachConsole((uint)childProcessId)) + { + return false; + } + + attachedHere = true; + } + + return Native.GenerateConsoleCtrlEvent( + CtrlBreakEvent, + (uint)childProcessGroupId); + } + catch + { + return false; + } + finally + { + if (attachedHere) + { + _ = Native.FreeConsole(); + } + } + } + } + + private static class Native + { + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool AttachConsole(uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool GenerateConsoleCtrlEvent( + uint controlEvent, + uint processGroupId); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern uint GetConsoleProcessList( + [Out] uint[] processList, + uint processCount); + } +} + +/// +/// A process can be attached to only one console. Child creation and targeted +/// control-event attachment therefore share one process-wide gate. +/// +internal static class WindowsConsoleSynchronization +{ + internal static object Gate { get; } = new(); +} + +internal sealed class WindowsProcessStartResult : IDisposable +{ + private readonly SafeKernelHandle _processHandle; + private readonly SafeKernelHandle _threadHandle; + private SafeFileHandle? _standardInput; + private bool _resumed; + + internal WindowsProcessStartResult( + int processId, + SafeKernelHandle processHandle, + SafeKernelHandle threadHandle, + SafeFileHandle standardInput) + { + ProcessId = processId; + _processHandle = processHandle; + _threadHandle = threadHandle; + _standardInput = standardInput; + } + + internal int ProcessId { get; } + + internal TextWriter TakeStandardInput() + { + SafeFileHandle handle = _standardInput + ?? throw new InvalidOperationException("Standard input was already claimed."); + var stream = new FileStream(handle, FileAccess.Write, 4096, isAsync: false); + _standardInput = null; + try + { + return new StreamWriter( + stream, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) + { + AutoFlush = true, + }; + } + catch + { + stream.Dispose(); + throw; + } + } + + internal void Resume() + { + if (WindowsProcessNative.ResumeThread(_threadHandle) == uint.MaxValue) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The Windows launcher child could not be resumed."); + } + + _resumed = true; + } + + internal void Terminate() + { + if (!_processHandle.IsInvalid) + { + _ = WindowsProcessNative.TerminateProcess(_processHandle, 74); + } + } + + public void Dispose() + { + if (!_resumed) + { + Terminate(); + } + + _standardInput?.Dispose(); + _threadHandle.Dispose(); + _processHandle.Dispose(); + } +} + +internal static class WindowsProcessNative +{ + private const uint CreateSuspended = 0x00000004; + private const uint CreateNewProcessGroup = 0x00000200; + private const uint ExtendedStartupInfoPresent = 0x00080000; + private const uint StartfUseStdHandles = 0x00000100; + private const short SwHide = 0; + private const uint HandleFlagInherit = 0x00000001; + private const uint DuplicateSameAccess = 0x00000002; + private const uint GenericWrite = 0x40000000; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint OpenExisting = 3; + private const uint FileAttributeNormal = 0x00000080; + private const int StdOutputHandle = -11; + private const int StdErrorHandle = -12; + private static readonly IntPtr ProcThreadAttributeHandleList = new(0x00020002); + + internal static WindowsProcessStartResult Start(LauncherProcessSpec spec) + { + ArgumentException.ThrowIfNullOrWhiteSpace(spec.ExecutablePath); + ArgumentNullException.ThrowIfNull(spec.Arguments); + + lock (WindowsConsoleSynchronization.Gate) + { + bool allocatedConsole = false; + try + { + // An Avalonia launcher started from Explorer has no console. + // CREATE_NEW_PROCESS_GROUP alone does not allocate one, and a + // console-less group cannot receive GenerateConsoleCtrlEvent. + // Allocate one only for the creation transaction, hide it, + // let the group leader inherit it, then detach the launcher. + // Each such child consequently owns a distinct console as + // well as a distinct process group. + if (!HasConsole()) + { + if (!AllocConsole()) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The Windows launcher could not allocate the child console."); + } + + allocatedConsole = true; + IntPtr consoleWindow = GetConsoleWindow(); + if (consoleWindow != IntPtr.Zero) + { + _ = ShowWindow(consoleWindow, SwHide); + } + } + + return StartCore(spec); + } + finally + { + if (allocatedConsole) + { + _ = FreeConsole(); + } + } + } + } + + private static WindowsProcessStartResult StartCore(LauncherProcessSpec spec) + { + SafeFileHandle? parentInput = null; + try + { + using SafeFileHandle childInput = CreateChildInputPipe( + out SafeFileHandle createdParentInput); + parentInput = createdParentInput; + using SafeKernelHandle childOutput = DuplicateOrOpenNull(StdOutputHandle); + using SafeKernelHandle childError = DuplicateOrOpenNull(StdErrorHandle); + using var attributes = new ProcessThreadAttributeList( + childInput.DangerousGetHandle(), + childOutput.DangerousGetHandle(), + childError.DangerousGetHandle()); + + var startup = new StartupInfoEx + { + StartupInfo = new StartupInfo + { + Size = Marshal.SizeOf(), + Flags = StartfUseStdHandles, + StandardInput = childInput.DangerousGetHandle(), + StandardOutput = childOutput.DangerousGetHandle(), + StandardError = childError.DangerousGetHandle(), + }, + AttributeList = attributes.Pointer, + }; + string executable = ResolveExecutable(spec.ExecutablePath); + string commandLineText = BuildCommandLine(executable, spec.Arguments); + var commandLine = new StringBuilder(commandLineText, commandLineText.Length + 1); + string? workingDirectory = string.IsNullOrWhiteSpace(spec.WorkingDirectory) + ? null + : Path.GetFullPath(spec.WorkingDirectory); + + if (!CreateProcessW( + executable, + commandLine, + IntPtr.Zero, + IntPtr.Zero, + inheritHandles: true, + CreateSuspended | CreateNewProcessGroup | ExtendedStartupInfoPresent, + IntPtr.Zero, + workingDirectory, + ref startup, + out ProcessInformation information)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The Windows launcher child could not be created."); + } + + var processHandle = new SafeKernelHandle( + information.Process, + ownsHandle: true); + var threadHandle = new SafeKernelHandle( + information.Thread, + ownsHandle: true); + try + { + var result = new WindowsProcessStartResult( + checked((int)information.ProcessId), + processHandle, + threadHandle, + parentInput); + parentInput = null; + return result; + } + catch + { + _ = TerminateProcess(processHandle, 74); + threadHandle.Dispose(); + processHandle.Dispose(); + throw; + } + } + finally + { + parentInput?.Dispose(); + } + } + + private static bool HasConsole() + { + uint[] processes = new uint[1]; + return GetConsoleProcessList(processes, 1) != 0; + } + + internal static uint ResumeThread(SafeKernelHandle thread) => + NativeResumeThread(thread); + + internal static bool TerminateProcess(SafeKernelHandle process, uint exitCode) => + NativeTerminateProcess(process, exitCode); + + internal static string BuildCommandLine( + string executable, + IReadOnlyList arguments) + { + var builder = new StringBuilder(); + AppendQuotedArgument(builder, executable); + foreach (string argument in arguments) + { + ArgumentNullException.ThrowIfNull(argument); + builder.Append(' '); + AppendQuotedArgument(builder, argument); + } + + return builder.ToString(); + } + + private static void AppendQuotedArgument(StringBuilder builder, string value) + { + builder.Append('"'); + int backslashes = 0; + foreach (char character in value) + { + if (character == '\\') + { + backslashes++; + continue; + } + + if (character == '"') + { + builder.Append('\\', backslashes * 2 + 1); + builder.Append('"'); + backslashes = 0; + continue; + } + + builder.Append('\\', backslashes); + backslashes = 0; + builder.Append(character); + } + + builder.Append('\\', backslashes * 2); + builder.Append('"'); + } + + private static SafeFileHandle CreateChildInputPipe(out SafeFileHandle parentInput) + { + var security = new SecurityAttributes + { + Length = Marshal.SizeOf(), + InheritHandle = true, + }; + if (!CreatePipe(out IntPtr read, out IntPtr write, ref security, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The launcher child stdin pipe could not be created."); + } + + var child = new SafeFileHandle(read, ownsHandle: true); + parentInput = new SafeFileHandle(write, ownsHandle: true); + if (!SetHandleInformation( + parentInput, + HandleFlagInherit, + 0)) + { + int error = Marshal.GetLastWin32Error(); + child.Dispose(); + parentInput.Dispose(); + throw new Win32Exception(error, + "The launcher child stdin pipe could not be isolated."); + } + + return child; + } + + private static SafeKernelHandle DuplicateOrOpenNull(int standardHandle) + { + IntPtr source = GetStdHandle(standardHandle); + if (source != IntPtr.Zero && source != new IntPtr(-1)) + { + IntPtr current = GetCurrentProcess(); + if (DuplicateHandle( + current, + source, + current, + out IntPtr duplicate, + 0, + inheritHandle: true, + DuplicateSameAccess)) + { + return new SafeKernelHandle(duplicate, ownsHandle: true); + } + } + + IntPtr nul = CreateFileW( + "NUL", + GenericWrite, + FileShareRead | FileShareWrite, + IntPtr.Zero, + OpenExisting, + FileAttributeNormal, + IntPtr.Zero); + if (nul == IntPtr.Zero || nul == new IntPtr(-1)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The launcher child fallback output handle could not be opened."); + } + + var handle = new SafeKernelHandle(nul, ownsHandle: true); + if (!SetHandleInformation(handle, HandleFlagInherit, HandleFlagInherit)) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error, + "The launcher child fallback output handle could not be inherited."); + } + + return handle; + } + + private static string ResolveExecutable(string executable) + { + if (Path.IsPathFullyQualified(executable)) + { + return Path.GetFullPath(executable); + } + + var buffer = new StringBuilder(32_768); + uint length = SearchPathW( + null, + executable, + null, + (uint)buffer.Capacity, + buffer, + IntPtr.Zero); + if (length == 0 || length >= buffer.Capacity) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + $"Launcher child executable '{executable}' was not found."); + } + + return Path.GetFullPath(buffer.ToString()); + } + + private sealed class ProcessThreadAttributeList : IDisposable + { + private IntPtr _pointer; + private IntPtr _handles; + private bool _initialized; + + internal ProcessThreadAttributeList(params IntPtr[] handles) + { + nuint size = 0; + _ = InitializeProcThreadAttributeList( + IntPtr.Zero, + 1, + 0, + ref size); + _pointer = Marshal.AllocHGlobal(checked((nint)size)); + if (!InitializeProcThreadAttributeList(_pointer, 1, 0, ref size)) + { + int error = Marshal.GetLastWin32Error(); + Dispose(); + throw new Win32Exception(error, + "The launcher child handle list could not be initialized."); + } + _initialized = true; + + _handles = Marshal.AllocHGlobal(handles.Length * IntPtr.Size); + for (int index = 0; index < handles.Length; index++) + { + Marshal.WriteIntPtr(_handles, index * IntPtr.Size, handles[index]); + } + + if (!UpdateProcThreadAttribute( + _pointer, + 0, + ProcThreadAttributeHandleList, + _handles, + checked((nuint)(handles.Length * IntPtr.Size)), + IntPtr.Zero, + IntPtr.Zero)) + { + int error = Marshal.GetLastWin32Error(); + Dispose(); + throw new Win32Exception(error, + "The launcher child inherited-handle list could not be set."); + } + } + + internal IntPtr Pointer => _pointer; + + public void Dispose() + { + if (_pointer != IntPtr.Zero) + { + if (_initialized) + { + DeleteProcThreadAttributeList(_pointer); + _initialized = false; + } + Marshal.FreeHGlobal(_pointer); + _pointer = IntPtr.Zero; + } + + if (_handles != IntPtr.Zero) + { + Marshal.FreeHGlobal(_handles); + _handles = IntPtr.Zero; + } + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes + { + internal int Length; + internal IntPtr SecurityDescriptor; + [MarshalAs(UnmanagedType.Bool)] internal bool InheritHandle; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + internal int Size; + internal string? Reserved; + internal string? Desktop; + internal string? Title; + internal int X; + internal int Y; + internal int XSize; + internal int YSize; + internal int XCountChars; + internal int YCountChars; + internal int FillAttribute; + internal uint Flags; + internal short ShowWindow; + internal short Reserved2Size; + internal IntPtr Reserved2; + internal IntPtr StandardInput; + internal IntPtr StandardOutput; + internal IntPtr StandardError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct StartupInfoEx + { + internal StartupInfo StartupInfo; + internal IntPtr AttributeList; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + internal IntPtr Process; + internal IntPtr Thread; + internal uint ProcessId; + internal uint ThreadId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcessW( + string applicationName, + StringBuilder commandLine, + IntPtr processAttributes, + IntPtr threadAttributes, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, + IntPtr environment, + string? currentDirectory, + ref StartupInfoEx startupInfo, + out ProcessInformation processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreatePipe( + out IntPtr readPipe, + out IntPtr writePipe, + ref SecurityAttributes pipeAttributes, + uint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetHandleInformation( + SafeHandle handle, + uint mask, + uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AllocConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint GetConsoleProcessList( + [Out] uint[] processList, + uint processCount); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetConsoleWindow(); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(IntPtr window, int commandShow); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int standardHandle); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetCurrentProcess(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DuplicateHandle( + IntPtr sourceProcess, + IntPtr sourceHandle, + IntPtr targetProcess, + out IntPtr targetHandle, + uint desiredAccess, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, + uint options); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateFileW( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint SearchPathW( + string? path, + string fileName, + string? extension, + uint bufferLength, + StringBuilder buffer, + IntPtr filePart); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool InitializeProcThreadAttributeList( + IntPtr attributeList, + int attributeCount, + int flags, + ref nuint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UpdateProcThreadAttribute( + IntPtr attributeList, + uint flags, + IntPtr attribute, + IntPtr value, + nuint size, + IntPtr previousValue, + IntPtr returnSize); + + [DllImport("kernel32.dll")] + private static extern void DeleteProcThreadAttributeList(IntPtr attributeList); + + [DllImport("kernel32.dll", EntryPoint = "ResumeThread", SetLastError = true)] + private static extern uint NativeResumeThread(SafeKernelHandle thread); + + [DllImport("kernel32.dll", EntryPoint = "TerminateProcess", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool NativeTerminateProcess( + SafeKernelHandle process, + uint exitCode); +} + +internal sealed class SafeKernelHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + internal SafeKernelHandle(IntPtr handle, bool ownsHandle) + : base(ownsHandle) + { + SetHandle(handle); + } + + protected override bool ReleaseHandle() => CloseHandle(handle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs index 4c3ffe16..5a98b795 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -104,7 +104,8 @@ public sealed class LauncherExecutableSet : new LauncherProcessSpec( paths.GraphicalHostPath, ["--session-config", configFilePath], - paths.WorkingDirectory); + paths.WorkingDirectory, + SupportsConsoleGracefulStop: false); } public LauncherProcessSpec CreateProbeSpec(string configFilePath) diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index 350260b2..1b02f993 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -28,6 +28,10 @@ + + + + diff --git a/src/AcDream.Launcher/LauncherStartupOptions.cs b/src/AcDream.Launcher/LauncherStartupOptions.cs new file mode 100644 index 00000000..1f1c9164 --- /dev/null +++ b/src/AcDream.Launcher/LauncherStartupOptions.cs @@ -0,0 +1,248 @@ +using AcDream.Launcher.Core.Updates; +using AcDream.Platform; + +namespace AcDream.Launcher; + +internal enum LauncherStartupMode +{ + Desktop, + VerifyPublish, + SelfUpdateHelper, + SelfUpdateConfirmation, +} + +/// +/// Immutable, process-local launcher inputs. Parsing happens before any +/// launcher owner is constructed so every owner receives the same exact path +/// set and the test-feed URI can reach only the updater composition. +/// +internal sealed class LauncherStartupOptions +{ + private readonly IReadOnlyList _publicArguments; + + private LauncherStartupOptions( + LauncherStartupMode mode, + ApplicationPathSet paths, + Uri updateManifestUri, + IReadOnlyList publicArguments) + { + Mode = mode; + Paths = paths; + UpdateManifestUri = updateManifestUri; + _publicArguments = Array.AsReadOnly(publicArguments.ToArray()); + } + + internal LauncherStartupMode Mode { get; } + + internal ApplicationPathSet Paths { get; } + + internal Uri UpdateManifestUri { get; } + + /// + /// The validated public option suffix. LA10 passes this suffix through its + /// helper and confirmation processes so an isolated self-update cannot + /// fall back to canonical user roots or the production feed. + /// + internal IReadOnlyList PublicArguments => _publicArguments; + + internal static LauncherStartupOptions Parse( + IReadOnlyList arguments, + Func? resolveDefaultPaths = null) + { + ArgumentNullException.ThrowIfNull(arguments); + resolveDefaultPaths ??= () => ApplicationPathSet.Resolve(); + + (LauncherStartupMode mode, int publicStart) = ReadMode(arguments); + string[] publicArguments = arguments.Skip(publicStart).ToArray(); + + if (publicArguments.Contains("--verify-publish", StringComparer.Ordinal)) + { + if (mode != LauncherStartupMode.Desktop + || publicArguments.Length != 1 + || !string.Equals( + publicArguments[0], + "--verify-publish", + StringComparison.Ordinal)) + { + throw new LauncherStartupOptionsException( + "--verify-publish must be the only launcher argument."); + } + + return new LauncherStartupOptions( + LauncherStartupMode.VerifyPublish, + // The publish probe returns before this value is observed. A + // non-resolving sentinel keeps the probe display- and + // user-profile-free even under a deliberately broken runtime. + new ApplicationPathSet(string.Empty, string.Empty, string.Empty, null), + ReleaseManifestClient.ProductionManifestUri, + publicArguments); + } + + string? configDirectory = null; + string? dataDirectory = null; + string? cacheDirectory = null; + Uri? updateManifestUri = null; + + for (int index = 0; index < publicArguments.Length; index += 2) + { + string name = publicArguments[index]; + if (index + 1 >= publicArguments.Length + || publicArguments[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{name}' requires a value."); + } + + string value = publicArguments[index + 1]; + if (string.IsNullOrWhiteSpace(value)) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{name}' requires a non-empty value."); + } + + switch (name) + { + case "--config-dir": + SetDirectoryOnce(ref configDirectory, value, name); + break; + case "--data-dir": + SetDirectoryOnce(ref dataDirectory, value, name); + break; + case "--cache-dir": + SetDirectoryOnce(ref cacheDirectory, value, name); + break; + case "--update-manifest-uri": + if (updateManifestUri is not null) + { + throw new LauncherStartupOptionsException( + "Launcher options cannot be repeated."); + } + + if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed)) + { + throw new LauncherStartupOptionsException( + "--update-manifest-uri must be an absolute URI."); + } + + if (parsed.Scheme != Uri.UriSchemeHttps + && !(parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback)) + { + throw new LauncherStartupOptionsException( + "The update manifest URI must use HTTPS " + + "(loopback HTTP is test-only)."); + } + + if (!string.IsNullOrEmpty(parsed.UserInfo)) + { + throw new LauncherStartupOptionsException( + "The update manifest URI cannot contain user information."); + } + + updateManifestUri = parsed; + break; + default: + throw new LauncherStartupOptionsException( + $"Unknown launcher option '{name}'."); + } + } + + int suppliedRoots = new[] { configDirectory, dataDirectory, cacheDirectory } + .Count(path => path is not null); + if (suppliedRoots is > 0 and < 3) + { + throw new LauncherStartupOptionsException( + "--config-dir, --data-dir, and --cache-dir must be supplied together."); + } + + ApplicationPathSet paths = suppliedRoots == 3 + ? new ApplicationPathSet( + configDirectory!, + dataDirectory!, + cacheDirectory!, + LegacyConfigDirectory: null) + : resolveDefaultPaths(); + return new LauncherStartupOptions( + mode, + paths, + updateManifestUri ?? ReleaseManifestClient.ProductionManifestUri, + publicArguments); + } + + private static (LauncherStartupMode Mode, int PublicStart) ReadMode( + IReadOnlyList arguments) + { + if (arguments.Count == 0) + { + return (LauncherStartupMode.Desktop, 0); + } + + if (string.Equals( + arguments[0], + LauncherSelfUpdateBootstrap.HelperArgument, + StringComparison.Ordinal)) + { + // Malformed internal invocations are rejected by the bootstrap + // with EX_USAGE. Do not reinterpret their operands as public + // options while resolving the manager they need to report that. + return ( + LauncherStartupMode.SelfUpdateHelper, + arguments.Count >= 4 ? 4 : arguments.Count); + } + + if (string.Equals( + arguments[0], + LauncherSelfUpdateBootstrap.ConfirmArgument, + StringComparison.Ordinal)) + { + return ( + LauncherStartupMode.SelfUpdateConfirmation, + arguments.Count >= 2 ? 2 : arguments.Count); + } + + return (LauncherStartupMode.Desktop, 0); + } + + private static void SetDirectoryOnce( + ref string? destination, + string value, + string option) + { + if (destination is not null) + { + throw new LauncherStartupOptionsException( + "Launcher options cannot be repeated."); + } + + if (!Path.IsPathFullyQualified(value)) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{option}' must be an absolute path."); + } + + try + { + destination = Path.TrimEndingDirectorySeparator(Path.GetFullPath(value)); + } + catch (Exception ex) when (ex is ArgumentException + or IOException + or NotSupportedException) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{option}' is not a valid absolute path.", + ex); + } + } +} + +internal sealed class LauncherStartupOptionsException : Exception +{ + internal LauncherStartupOptionsException(string message) + : base(message) + { + } + + internal LauncherStartupOptionsException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj new file mode 100644 index 00000000..e5c9a2c7 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0 + enable + enable + latest + true + + diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs new file mode 100644 index 00000000..dc047cdf --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs @@ -0,0 +1,54 @@ +using System.Text.Json; + +if (args.Length < 4 + || args[0] != "wait-for-break" + || string.IsNullOrWhiteSpace(args[1]) + || string.IsNullOrWhiteSpace(args[2])) +{ + return 64; +} + +string readyPath = Path.GetFullPath(args[1]); +string breakPath = Path.GetFullPath(args[2]); +string label = args[3]; +string[] payloadArguments = args.Skip(4).ToArray(); +using var stopped = new ManualResetEventSlim(false); +ConsoleCancelEventHandler handler = (_, eventArgs) => +{ + if (eventArgs.SpecialKey != ConsoleSpecialKey.ControlBreak) + { + return; + } + + eventArgs.Cancel = true; + try + { + File.WriteAllText(breakPath, label); + } + finally + { + stopped.Set(); + } +}; +Console.CancelKeyPress += handler; +try +{ + string stdin = Console.In.ReadToEnd(); + Directory.CreateDirectory(Path.GetDirectoryName(readyPath)!); + File.WriteAllText( + readyPath, + JsonSerializer.Serialize(new + { + processId = Environment.ProcessId, + label, + arguments = payloadArguments, + stdinLength = stdin.Length, + stdinLineCount = stdin.Count(character => character == '\n'), + })); + + return stopped.Wait(TimeSpan.FromSeconds(30)) ? 0 : 75; +} +finally +{ + Console.CancelKeyPress -= handler; +} diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj new file mode 100644 index 00000000..441bc38a --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj @@ -0,0 +1,14 @@ + + + WinExe + net10.0 + enable + enable + latest + true + true + + + + + diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs new file mode 100644 index 00000000..22ad9230 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs @@ -0,0 +1,135 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using AcDream.Launcher.Core.Launching; + +if (args.Length != 3) +{ + return 64; +} + +string resultPath = Path.GetFullPath(args[0]); +string dotnetPath = args[1]; +string childAssembly = Path.GetFullPath(args[2]); +string root = Path.Combine( + Path.GetDirectoryName(resultPath)!, + "consoleless-children"); +Directory.CreateDirectory(root); +string targetReady = Path.Combine(root, "target.ready.json"); +string targetBreak = Path.Combine(root, "target.break"); +string siblingReady = Path.Combine(root, "sibling.ready.json"); +string siblingBreak = Path.Combine(root, "sibling.break"); + +bool parentHadConsoleBefore = HasConsole(); +using var target = new LauncherProcessSupervisor(); +using var sibling = new LauncherProcessSupervisor(); +try +{ + target.Start( + Spec(dotnetPath, childAssembly, targetReady, targetBreak, "target"), + "stdin-from-consoleless-parent"); + sibling.Start( + Spec(dotnetPath, childAssembly, siblingReady, siblingBreak, "sibling"), + password: null); + WaitForFile(targetReady, target); + WaitForFile(siblingReady, sibling); + bool parentHadConsoleAfterStarts = HasConsole(); + + target.Stop(TimeSpan.FromSeconds(10)); + bool siblingUnaffected = sibling.State != LauncherSessionState.Exited + && !File.Exists(siblingBreak); + sibling.Stop(TimeSpan.FromSeconds(10)); + + Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!); + File.WriteAllText( + resultPath, + JsonSerializer.Serialize(new + { + parentHadConsoleBefore, + parentHadConsoleAfterStarts, + targetExitCode = target.ExitCode, + siblingExitCode = sibling.ExitCode, + targetBreakObserved = File.Exists(targetBreak), + siblingBreakObserved = File.Exists(siblingBreak), + siblingUnaffected, + })); + return 0; +} +catch (Exception error) +{ + Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!); + File.WriteAllText( + resultPath, + JsonSerializer.Serialize(new + { + parentHadConsoleBefore, + error = error.GetType().Name + ": " + error.Message, + })); + return 1; +} +finally +{ + ForceStop(target); + ForceStop(sibling); +} + +static LauncherProcessSpec Spec( + string dotnetPath, + string childAssembly, + string ready, + string breakMarker, + string label) => + new( + dotnetPath, + [ + childAssembly, + "wait-for-break", + ready, + breakMarker, + label, + "argument with spaces", + ]); + +static void WaitForFile(string path, LauncherProcessSupervisor supervisor) +{ + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (!File.Exists(path)) + { + if (supervisor.State == LauncherSessionState.Exited) + { + throw new InvalidOperationException( + $"Child exited early with {supervisor.ExitCode}."); + } + + if (DateTime.UtcNow >= deadline) + { + throw new TimeoutException("Child did not become ready."); + } + + Thread.Sleep(20); + } +} + +static void ForceStop(LauncherProcessSupervisor supervisor) +{ + try + { + supervisor.Stop(TimeSpan.Zero); + } + catch + { + } +} + +static bool HasConsole() +{ + uint[] processes = new uint[1]; + return Native.GetConsoleProcessList(processes, 1) != 0; +} + +internal static partial class Native +{ + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial uint GetConsoleProcessList( + [Out] uint[] processList, + uint processCount); +} diff --git a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj index 78fa3e4f..f1677cc9 100644 --- a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj +++ b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj @@ -25,5 +25,17 @@ false true + + + false + true + + + + false + true + diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs index 4ec1431e..ca55ff60 100644 --- a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs @@ -1,4 +1,6 @@ using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.Json; using System.Threading; using AcDream.Launcher.Core.Launching; @@ -6,6 +8,27 @@ namespace AcDream.Launcher.Core.Tests.Launching; public sealed class LauncherProcessSupervisorTests { + [Fact] + public void WindowsFactoryUsesNativeProcessGroupsOnlyForConsoleChildren() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var factory = new SystemChildProcessFactory(); + using ILauncherChildProcess console = factory.Create( + new LauncherProcessSpec("headless.exe", [])); + using ILauncherChildProcess graphical = factory.Create( + new LauncherProcessSpec( + "graphical.exe", + [], + SupportsConsoleGracefulStop: false)); + + Assert.IsType(console); + Assert.IsType(graphical); + } + [Fact] public void StartWritesPasswordThenClosesStdinAndTransitionsToRunning() { @@ -197,6 +220,158 @@ public sealed class LauncherProcessSupervisorTests } } + [Fact] + public async Task WindowsCtrlBreakStopsOnlyTheTargetProcessGroupWithoutKill() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + string root = Path.Combine( + Path.GetTempPath(), + "acdream-la11-ctrl-break", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var targetFactory = new RecordingRealChildProcessFactory(); + var siblingFactory = new RecordingRealChildProcessFactory(); + using var target = new LauncherProcessSupervisor(targetFactory); + using var sibling = new LauncherProcessSupervisor(siblingFactory); + string targetReady = Path.Combine(root, "target.ready.json"); + string targetBreak = Path.Combine(root, "target.break"); + string siblingReady = Path.Combine(root, "sibling.ready.json"); + string siblingBreak = Path.Combine(root, "sibling.break"); + string[] exactArguments = + [ + "plain", + "contains spaces", + "quoted-\"value", + "ends-with-backslash\\", + string.Empty, + ]; + + try + { + target.Start( + ConsoleFixtureSpec( + targetReady, + targetBreak, + "target", + exactArguments), + "fixture-input"); + sibling.Start( + ConsoleFixtureSpec( + siblingReady, + siblingBreak, + "sibling", + ["sibling"]), + password: null); + await WaitForFileAsync(targetReady, targetFactory.LastCreated!); + await WaitForFileAsync(siblingReady, siblingFactory.LastCreated!); + + using (JsonDocument ready = JsonDocument.Parse( + await File.ReadAllTextAsync(targetReady))) + { + string[] observed = ready.RootElement + .GetProperty("arguments") + .EnumerateArray() + .Select(value => value.GetString()!) + .ToArray(); + Assert.Equal(exactArguments, observed); + Assert.Equal( + "fixture-input\n".Length, + ready.RootElement.GetProperty("stdinLength").GetInt32()); + Assert.Equal( + 1, + ready.RootElement.GetProperty("stdinLineCount").GetInt32()); + } + + target.Stop(TimeSpan.FromSeconds(10)); + + Assert.Equal(0, target.ExitCode); + Assert.True(File.Exists(targetBreak), + "the target fixture did not observe CTRL_BREAK"); + Assert.Equal(0, targetFactory.LastCreated!.KillCallCount); + Assert.False(siblingFactory.LastCreated!.HasExited); + Assert.False(File.Exists(siblingBreak), + "CTRL_BREAK spilled into the sibling process group"); + + sibling.Stop(TimeSpan.FromSeconds(10)); + Assert.Equal(0, sibling.ExitCode); + Assert.True(File.Exists(siblingBreak)); + Assert.Equal(0, siblingFactory.LastCreated!.KillCallCount); + } + finally + { + targetFactory.LastCreated?.ForceCleanup(); + siblingFactory.LastCreated?.ForceCleanup(); + try + { + Directory.Delete(root, recursive: true); + } + catch (IOException) + { + } + } + } + + [Fact] + public async Task WindowsConsolelessParentStillTargetsDistinctChildProcessGroups() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + string root = Path.Combine( + Path.GetTempPath(), + "acdream-la11-consoleless-parent", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + string resultPath = Path.Combine(root, "result.json"); + var startInfo = new ProcessStartInfo + { + FileName = GetConsolelessParentFixturePath(), + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add(resultPath); + startInfo.ArgumentList.Add(FindDotnetExecutable()); + startInfo.ArgumentList.Add(GetConsoleFixturePath()); + + try + { + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException( + "The consoleless supervisor fixture did not start."); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(40)); + await process.WaitForExitAsync(timeout.Token); + + Assert.True(File.Exists(resultPath), + "the consoleless supervisor fixture did not write its result"); + using JsonDocument result = JsonDocument.Parse( + await File.ReadAllTextAsync(resultPath)); + Assert.Equal(0, process.ExitCode); + Assert.False(result.RootElement.GetProperty("parentHadConsoleBefore").GetBoolean()); + Assert.False(result.RootElement.GetProperty("parentHadConsoleAfterStarts").GetBoolean()); + Assert.Equal(0, result.RootElement.GetProperty("targetExitCode").GetInt32()); + Assert.Equal(0, result.RootElement.GetProperty("siblingExitCode").GetInt32()); + Assert.True(result.RootElement.GetProperty("targetBreakObserved").GetBoolean()); + Assert.True(result.RootElement.GetProperty("siblingBreakObserved").GetBoolean()); + Assert.True(result.RootElement.GetProperty("siblingUnaffected").GetBoolean()); + } + finally + { + try + { + Directory.Delete(root, recursive: true); + } + catch (IOException) + { + } + } + } + [Fact] public void StartKillsAndDisposesTheChildWhenFeedingStdinThrowsAfterTheProcessHasStarted() { @@ -371,6 +546,22 @@ public sealed class LauncherProcessSupervisorTests private static LauncherProcessSpec Spec() => new("fake-host", ["--session-config", "session.json"]); + private static LauncherProcessSpec ConsoleFixtureSpec( + string ready, + string breakMarker, + string label, + IReadOnlyList exactArguments) => + new( + FindDotnetExecutable(), + [ + GetConsoleFixturePath(), + "wait-for-break", + ready, + breakMarker, + label, + .. exactArguments, + ]); + private static string FindDotnetExecutable() => // PATH-based resolution: .NET Core's Process.Start searches PATH // for a bare filename when UseShellExecute is false, on both @@ -378,6 +569,133 @@ public sealed class LauncherProcessSupervisorTests // because this test is itself running under `dotnet test`. OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + private static string GetConsoleFixturePath() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent?.Name ?? "Release"; + return Path.Combine( + FindRepositoryRoot(), + "tests", + "AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild", + "bin", + configuration, + "net10.0", + "AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.dll"); + } + + private static string GetConsolelessParentFixturePath() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent?.Name ?? "Release"; + return Path.Combine( + FindRepositoryRoot(), + "tests", + "AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent", + "bin", + configuration, + "net10.0", + "AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.exe"); + } + + private static string FindRepositoryRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return directory.FullName; + } + } + + throw new InvalidOperationException("Repository root was not found."); + } + + private static async Task WaitForFileAsync( + string path, + RecordingChildProcess child) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + while (!File.Exists(path)) + { + if (child.HasExited) + { + throw new InvalidOperationException( + $"Console fixture exited early with {child.ExitCode}."); + } + + if (DateTimeOffset.UtcNow >= deadline) + { + throw new TimeoutException("Console fixture did not become ready."); + } + + await Task.Delay(20); + } + } + + private sealed class RecordingRealChildProcessFactory : ILauncherChildProcessFactory + { + private readonly SystemChildProcessFactory _inner = new(); + + internal RecordingChildProcess? LastCreated { get; private set; } + + public ILauncherChildProcess Create(LauncherProcessSpec spec) + { + LastCreated = new RecordingChildProcess(_inner.Create(spec)); + return LastCreated; + } + } + + private sealed class RecordingChildProcess(ILauncherChildProcess inner) + : ILauncherChildProcess + { + public int KillCallCount { get; private set; } + + public bool HasExited => inner.HasExited; + + public int ExitCode => inner.ExitCode; + + public TextWriter StandardInput => inner.StandardInput; + + public event EventHandler? Exited + { + add => inner.Exited += value; + remove => inner.Exited -= value; + } + + public void Start() => inner.Start(); + + public bool TryRequestGracefulStop() => inner.TryRequestGracefulStop(); + + public bool CloseMainWindow() => inner.CloseMainWindow(); + + public void Kill() + { + KillCallCount++; + inner.Kill(); + } + + public bool WaitForExit(TimeSpan timeout) => inner.WaitForExit(timeout); + + public void ForceCleanup() + { + try + { + if (!HasExited) + { + inner.Kill(); + _ = inner.WaitForExit(TimeSpan.FromSeconds(5)); + } + } + catch + { + } + } + + public void Dispose() => inner.Dispose(); + } + private sealed class FakeChildProcessFactory( bool exitsWithinStopTimeout, bool exitDuringStart = false, diff --git a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs index 3d146ffd..a8442f65 100644 --- a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs @@ -44,6 +44,16 @@ public sealed class LauncherExecutableSetTests : IDisposable Assert.Equal( headless, set.CreateProbeSpec("session.json").ExecutablePath); + Assert.False( + set.CreatePlaySpec(LaunchMode.Gui, "session.json") + .SupportsConsoleGracefulStop); + Assert.False( + set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json") + .SupportsConsoleGracefulStop); + Assert.True( + set.CreatePlaySpec(LaunchMode.Headless, "session.json") + .SupportsConsoleGracefulStop); + Assert.True(set.CreateProbeSpec("session.json").SupportsConsoleGracefulStop); } [Fact] diff --git a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs new file mode 100644 index 00000000..ff3bb24c --- /dev/null +++ b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs @@ -0,0 +1,182 @@ +using AcDream.Launcher.Core.Updates; +using AcDream.Platform; + +namespace AcDream.Launcher.Tests; + +public sealed class LauncherStartupOptionsTests +{ + [Fact] + public void ExplicitIsolationRootsAreNormalizedAndNeverResolveCanonicalPaths() + { + string root = Path.Combine(Path.GetTempPath(), "acdream-la11 options", "..", "isolation"); + string config = Path.Combine(root, "config") + Path.DirectorySeparatorChar; + string data = Path.Combine(root, "data", ".", "state"); + string cache = Path.Combine(root, "cache") + Path.DirectorySeparatorChar; + bool defaultResolverCalled = false; + + LauncherStartupOptions options = LauncherStartupOptions.Parse( + [ + "--config-dir", config, + "--data-dir", data, + "--cache-dir", cache, + "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json", + ], + () => + { + defaultResolverCalled = true; + throw new InvalidOperationException("canonical path resolver was touched"); + }); + + Assert.False(defaultResolverCalled); + Assert.Equal( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(config)), + options.Paths.ConfigDirectory); + Assert.Equal( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(data)), + options.Paths.DataDirectory); + Assert.Equal( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(cache)), + options.Paths.CacheDirectory); + Assert.Null(options.Paths.LegacyConfigDirectory); + Assert.Equal( + "http://127.0.0.1:43119/manifest.json", + options.UpdateManifestUri.AbsoluteUri); + Assert.Equal(LauncherStartupMode.Desktop, options.Mode); + } + + [Fact] + public void NoOverridesResolveDefaultsExactlyOnce() + { + var expected = new ApplicationPathSet("config", "data", "cache", "legacy"); + int calls = 0; + + LauncherStartupOptions options = LauncherStartupOptions.Parse( + [], + () => + { + calls++; + return expected; + }); + + Assert.Same(expected, options.Paths); + Assert.Equal(1, calls); + Assert.Equal(ReleaseManifestClient.ProductionManifestUri, options.UpdateManifestUri); + } + + [Fact] + public void VerifyPublishIsExclusiveAndDoesNotResolvePaths() + { + int calls = 0; + + LauncherStartupOptions options = LauncherStartupOptions.Parse( + ["--verify-publish"], + () => + { + calls++; + throw new InvalidOperationException(); + }); + + Assert.Equal(LauncherStartupMode.VerifyPublish, options.Mode); + Assert.Equal(0, calls); + Assert.Throws(() => + LauncherStartupOptions.Parse( + ["--verify-publish", "--cache-dir", Path.GetTempPath()])); + } + + [Theory] + [MemberData(nameof(InvalidArguments))] + public void RejectsInvalidPublicArguments(string[] arguments) + { + Assert.Throws(() => + LauncherStartupOptions.Parse( + arguments, + () => new ApplicationPathSet("c", "d", "x", null))); + } + + [Theory] + [InlineData("https://updates.example.test/manifest.json")] + [InlineData("http://localhost:8123/manifest.json")] + [InlineData("http://[::1]:8123/manifest.json")] + public void AcceptsHttpsAndLoopbackHttpFeeds(string value) + { + LauncherStartupOptions options = LauncherStartupOptions.Parse( + ["--update-manifest-uri", value], + () => new ApplicationPathSet("c", "d", "x", null)); + + Assert.Equal(new Uri(value), options.UpdateManifestUri); + } + + [Fact] + public void SelfUpdatePrefixesRetainOnlyTheValidatedPublicSuffix() + { + string root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11-self")); + string[] suffix = + [ + "--config-dir", Path.Combine(root, "config"), + "--data-dir", Path.Combine(root, "data"), + "--cache-dir", Path.Combine(root, "cache"), + "--update-manifest-uri", "http://localhost:8123/manifest.json", + ]; + string[] helper = + [ + LauncherSelfUpdateBootstrap.HelperArgument, + "123", + root, + "0123456789abcdef0123456789abcdef", + .. suffix, + ]; + string[] confirmation = + [ + LauncherSelfUpdateBootstrap.ConfirmArgument, + "0123456789abcdef0123456789abcdef", + .. suffix, + ]; + + LauncherStartupOptions helperOptions = LauncherStartupOptions.Parse(helper); + LauncherStartupOptions confirmationOptions = + LauncherStartupOptions.Parse(confirmation); + + Assert.Equal(LauncherStartupMode.SelfUpdateHelper, helperOptions.Mode); + Assert.Equal( + LauncherStartupMode.SelfUpdateConfirmation, + confirmationOptions.Mode); + Assert.Equal(suffix, helperOptions.PublicArguments); + Assert.Equal(suffix, confirmationOptions.PublicArguments); + Assert.Equal(helperOptions.Paths, confirmationOptions.Paths); + Assert.Equal(helperOptions.UpdateManifestUri, confirmationOptions.UpdateManifestUri); + } + + public static TheoryData InvalidArguments() + { + string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11")); + var data = new TheoryData(); + data.Add(["--unknown", "value"]); + data.Add(["--config-dir"]); + data.Add(["--config-dir", "relative"]); + data.Add(["--config-dir", absolute]); + data.Add( + [ + "--config-dir", absolute, + "--data-dir", absolute, + ]); + data.Add( + [ + "--config-dir", absolute, + "--data-dir", absolute, + "--cache-dir", absolute, + "--cache-dir", absolute, + ]); + data.Add( + ["--update-manifest-uri", "http://updates.example.test/manifest.json"]); + data.Add(["--update-manifest-uri", "file:///tmp/manifest.json"]); + data.Add( + ["--update-manifest-uri", "https://user:secret@example.test/manifest.json"]); + data.Add(["--update-manifest-uri", "not-a-uri"]); + data.Add( + [ + "--update-manifest-uri", "https://example.test/a", + "--update-manifest-uri", "https://example.test/b", + ]); + return data; + } +} diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1 new file mode 100644 index 00000000..98d129eb --- /dev/null +++ b/tools/new-campaign-la-update-fixture.ps1 @@ -0,0 +1,333 @@ +<# +.SYNOPSIS + Creates deterministic isolated Campaign LA A/B update feeds. + +.DESCRIPTION + Packages caller-supplied published client and launcher roots for win-x64 + and linux-x64, adds a deterministic release marker, calculates the exact + LA10 SHA-256/size manifest fields, and emits a loopback-only static server + plus a local A/B selector. It never downloads, connects, edits a payload + source, or writes outside -OutputDirectory. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$OutputDirectory, + [Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryA, + [Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryA, + [Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryA, + [Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryA, + [Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryB, + [Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryB, + [Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryB, + [Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryB, + [string]$VersionA = '1.0.1-la11.a', + [string]$VersionB = '1.0.1-la11.b', + [string]$MinimumLauncherVersion = '1.0.0', + [int]$Port = 43119, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA update fixture creation requires PowerShell 7 or newer.' +} +if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { + throw '-OutputDirectory must be absolute.' +} +$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($OutputDirectory)) +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 + $MinimumLauncherVersion -notmatch $semver -or $VersionA -ceq $VersionB) { + throw 'VersionA, VersionB, and MinimumLauncherVersion must be SemVer 2.0; A and B must differ.' +} +$parsedVersionA = [semver]$VersionA +$parsedVersionB = [semver]$VersionB +$parsedMinimumLauncherVersion = [semver]$MinimumLauncherVersion +if ($parsedVersionB.CompareTo($parsedVersionA) -le 0) { + throw 'VersionB must be newer than VersionA.' +} +if ($parsedMinimumLauncherVersion.CompareTo($parsedVersionA) -gt 0) { + throw 'MinimumLauncherVersion must not be newer than VersionA.' +} + +$sources = [ordered]@{ + 'A-client-win-x64' = $ClientWinX64DirectoryA + 'A-launcher-win-x64' = $LauncherWinX64DirectoryA + 'A-client-linux-x64' = $ClientLinuxX64DirectoryA + 'A-launcher-linux-x64' = $LauncherLinuxX64DirectoryA + 'B-client-win-x64' = $ClientWinX64DirectoryB + 'B-launcher-win-x64' = $LauncherWinX64DirectoryB + 'B-client-linux-x64' = $ClientLinuxX64DirectoryB + 'B-launcher-linux-x64' = $LauncherLinuxX64DirectoryB +} +foreach ($key in @($sources.Keys)) { + $source = [string]$sources[$key] + if (-not [IO.Path]::IsPathFullyQualified($source)) { + throw "Payload source '$key' must be absolute." + } + $source = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($source)) + $sources[$key] = $source + if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) { + throw "Payload source '$key' does not exist: $source" + } +} + +function Require-PayloadFile([string]$Key, [string]$Name) { + if ($DryRun) { return } + if (-not (Test-Path -LiteralPath (Join-Path $sources[$Key] $Name) -PathType Leaf)) { + throw "Payload source '$Key' is missing root file '$Name'." + } +} +foreach ($release in @('A', 'B')) { + Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe' + Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe' + Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe' + Require-PayloadFile "$release-client-linux-x64" 'AcDream.App' + Require-PayloadFile "$release-client-linux-x64" 'acdream-headless' + Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher' +} + +if (Test-Path -LiteralPath $OutputDirectory) { + if (@(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0) { + throw '-OutputDirectory must not already contain files.' + } +} +else { $null = New-Item -ItemType Directory -Path $OutputDirectory } + +if ($DryRun) { + $plan = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-update-fixture-plan' + outputDirectory = $OutputDirectory + versions = @($VersionA, $VersionB) + minimumLauncherVersion = $MinimumLauncherVersion + port = $Port + sources = $sources + writesOutsideOutputDirectory = $false + externalNetwork = $false + } + $plan | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'dry-run.json') -Encoding utf8NoBOM + Write-Host "Campaign LA update fixture dry run: $OutputDirectory" + return +} + +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 New-DeterministicZip( + [string]$SourceDirectory, + [string]$Destination, + [string]$ReleaseLabel, + [string]$PayloadKind, + [string]$Rid) { + $destinationDirectory = Split-Path -Parent $Destination + $null = New-Item -ItemType Directory -Force -Path $destinationDirectory + $stream = [IO.FileStream]::new( + $Destination, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None) + try { + $archive = [IO.Compression.ZipArchive]::new( + $stream, + [IO.Compression.ZipArchiveMode]::Create, + $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)" + } + $relative = [IO.Path]::GetRelativePath($SourceDirectory, $file.FullName).Replace('\', '/') + if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or + [IO.Path]::IsPathRooted($relative)) { + throw "Payload path escaped its root: $relative" + } + $entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal) + $entry.LastWriteTime = $fixedTimestamp + $executable = $relative -ceq 'AcDream.App' -or + $relative -ceq 'acdream-headless' -or + $relative -ceq 'acdream-launcher' -or + $relative.EndsWith('.sh', [StringComparison]::Ordinal) + $mode = if ($executable) { 0x81ED } else { 0x81A4 } + $entry.ExternalAttributes = $mode -shl 16 + $input = [IO.File]::OpenRead($file.FullName) + $output = $entry.Open() + try { $input.CopyTo($output) } + finally { $output.Dispose(); $input.Dispose() } + } + $marker = $archive.CreateEntry( + 'campaign-la-fixture-release.txt', + [IO.Compression.CompressionLevel]::Optimal) + $marker.LastWriteTime = $fixedTimestamp + $marker.ExternalAttributes = 0x81A4 -shl 16 + $writer = [IO.StreamWriter]::new( + $marker.Open(), + [Text.UTF8Encoding]::new($false)) + try { + $writer.NewLine = "`n" + $writer.Write("release=$ReleaseLabel`npayload=$PayloadKind`nrid=$Rid`n") + } + finally { $writer.Dispose() } + } + finally { $archive.Dispose() } + } + finally { $stream.Dispose() } +} + +function Get-Artifact([string]$Path, [string]$Url) { + $item = Get-Item -LiteralPath $Path + return [ordered]@{ + url = $Url + sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + size = $item.Length + } +} + +$releaseDefinitions = @( + [pscustomobject]@{ Label = 'A'; Version = $VersionA }, + [pscustomobject]@{ Label = 'B'; Version = $VersionB } +) +foreach ($release in $releaseDefinitions) { + $releaseRoot = Join-Path $OutputDirectory $release.Label + foreach ($rid in @('win-x64', 'linux-x64')) { + New-DeterministicZip ` + $sources["$($release.Label)-client-$rid"] ` + (Join-Path $releaseRoot "client-$rid.zip") ` + $release.Label 'client' $rid + New-DeterministicZip ` + $sources["$($release.Label)-launcher-$rid"] ` + (Join-Path $releaseRoot "launcher-$rid.zip") ` + $release.Label 'launcher' $rid + } + $baseUri = "http://127.0.0.1:$Port/$($release.Label)" + $manifest = [ordered]@{ + schemaVersion = 1 + version = $release.Version + minimumLauncherVersion = $MinimumLauncherVersion + clients = [ordered]@{ + 'win-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'client-win-x64.zip') ` + "$baseUri/client-win-x64.zip" + 'linux-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'client-linux-x64.zip') ` + "$baseUri/client-linux-x64.zip" + } + launchers = [ordered]@{ + 'win-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'launcher-win-x64.zip') ` + "$baseUri/launcher-win-x64.zip" + 'linux-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'launcher-linux-x64.zip') ` + "$baseUri/launcher-linux-x64.zip" + } + } + $manifest | ConvertTo-Json -Depth 8 -Compress | + Set-Content -LiteralPath (Join-Path $releaseRoot 'manifest.json') -Encoding utf8NoBOM +} +Set-Content -LiteralPath (Join-Path $OutputDirectory 'active-release.txt') ` + -Value 'A' -Encoding ascii -NoNewline + +$server = @' +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][int]$Port +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) +$prefix = "http://127.0.0.1:$Port/" +$listener = [Net.HttpListener]::new() +$listener.Prefixes.Add($prefix) +$listener.Start() +Write-Host "Campaign LA fixture listening on $prefix" +try { + while ($listener.IsListening) { + $context = $listener.GetContext() + try { + if ($context.Request.HttpMethod -cne 'GET') { + $context.Response.StatusCode = 405 + continue + } + $relative = [Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/')) + if ($relative -ceq 'manifest.json') { + $active = (Get-Content -LiteralPath (Join-Path $Root 'active-release.txt') -Raw).Trim() + if ($active -notin @('A', 'B')) { throw 'active-release.txt must contain A or B.' } + $relative = "$active/manifest.json" + } + if ([string]::IsNullOrWhiteSpace($relative) -or $relative.Contains('..')) { + $context.Response.StatusCode = 404 + continue + } + $path = [IO.Path]::GetFullPath((Join-Path $Root $relative)) + if (-not $path.StartsWith($Root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::Ordinal) -or + -not (Test-Path -LiteralPath $path -PathType Leaf)) { + $context.Response.StatusCode = 404 + continue + } + $context.Response.ContentType = if ($path.EndsWith('.json', [StringComparison]::Ordinal)) { + 'application/json' + } else { 'application/zip' } + $context.Response.StatusCode = 200 + $context.Response.Headers['Cache-Control'] = 'no-store' + $context.Response.ContentLength64 = (Get-Item -LiteralPath $path).Length + $input = [IO.File]::OpenRead($path) + try { $input.CopyTo($context.Response.OutputStream) } + finally { $input.Dispose() } + } + catch { + $context.Response.StatusCode = 500 + Write-Error $_ + } + finally { $context.Response.Close() } + } +} +finally { $listener.Close() } +'@ +$server | Set-Content -LiteralPath (Join-Path $OutputDirectory 'serve-fixture.ps1') -Encoding utf8NoBOM + +$selector = @' +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][ValidateSet('A', 'B')][string]$Release, + [string]$Root = $PSScriptRoot +) +Set-StrictMode -Version Latest +$path = Join-Path ([IO.Path]::GetFullPath($Root)) 'active-release.txt' +Set-Content -LiteralPath $path -Value $Release -Encoding ascii -NoNewline +Write-Host "Campaign LA fixture active release: $Release" +'@ +$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM + +$inventory = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | + Where-Object { $_.Name -ne 'fixture-report.json' } | + Sort-Object FullName | + ForEach-Object { + [ordered]@{ + path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') + size = $_.Length + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + }) +$report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-update-fixture' + versions = [ordered]@{ A = $VersionA; B = $VersionB } + minimumLauncherVersion = $MinimumLauncherVersion + manifestUri = "http://127.0.0.1:$Port/manifest.json" + loopbackOnly = $true + initialRelease = 'A' + sourceDirectories = $sources + artifacts = $inventory +} +$report | ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'fixture-report.json') -Encoding utf8NoBOM +Write-Host "Campaign LA update fixture: $OutputDirectory" diff --git a/tools/run-campaign-la-preflight.ps1 b/tools/run-campaign-la-preflight.ps1 new file mode 100644 index 00000000..0a676df1 --- /dev/null +++ b/tools/run-campaign-la-preflight.ps1 @@ -0,0 +1,394 @@ +<# +.SYNOPSIS + Campaign LA11 display-free, connection-free automated preflight. + +.DESCRIPTION + Runs the exact Release and portability ladder used before the launcher + user gate. It never starts App/Headless in connected mode, never opens a + window, never reads credentials, and never bakes retail DATs. All logs and + publishes are contained beneath one logs/campaign-la-gate- + directory. Use -DryRun to emit the complete command matrix without + executing it. +#> +[CmdletBinding()] +param( + [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [string]$OutputDirectory, + [switch]$DryRun, + [switch]$IncludeInstalledDat, + [string]$InstalledDatDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA preflight requires PowerShell 7 or newer.' +} + +$Repository = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($Repository)) +if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) { + throw "Repository does not contain AcDream.slnx: $Repository" +} +if ($IncludeInstalledDat) { + if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or + -not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) { + throw '-IncludeInstalledDat requires an absolute -InstalledDatDirectory.' + } + $InstalledDatDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($InstalledDatDirectory)) + foreach ($file in @( + 'client_portal.dat', + 'client_cell_1.dat', + 'client_highres.dat', + 'client_local_English.dat')) { + if (-not (Test-Path -LiteralPath (Join-Path $InstalledDatDirectory $file) -PathType Leaf)) { + throw "Installed DAT directory is missing $file." + } + } +} + +$stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = Join-Path $Repository "logs/campaign-la-gate-$stamp" +} +elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { + $OutputDirectory = Join-Path $Repository $OutputDirectory +} +$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($OutputDirectory)) +$logsDirectory = Join-Path $OutputDirectory 'commands' +$publishDirectory = Join-Path $OutputDirectory 'publish' +$null = New-Item -ItemType Directory -Force -Path $logsDirectory + +$commandResults = [Collections.Generic.List[object]]::new() +$failures = [Collections.Generic.List[string]]::new() +$startedUtc = [DateTime]::UtcNow + +function Protect-Text([string]$Text) { + if ($null -eq $Text) { return '' } + $protected = $Text + foreach ($name in @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')) { + $value = [Environment]::GetEnvironmentVariable($name) + if (-not [string]::IsNullOrEmpty($value)) { + $protected = $protected.Replace($value, '', [StringComparison]::Ordinal) + } + } + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)(--password|-password)(\s+|=)([^\s"'']+)', + '$1$2') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")', + '$1$2') + return $protected +} + +function Format-Command([string]$FilePath, [string[]]$Arguments) { + $parts = [Collections.Generic.List[string]]::new() + $parts.Add($FilePath) + foreach ($argument in $Arguments) { + if ($argument -match '[\s"]') { + $parts.Add('"' + $argument.Replace('"', '\"') + '"') + } + else { $parts.Add($argument) } + } + return $parts -join ' ' +} + +function Add-PlannedCommand( + [string]$Name, + [string]$FilePath, + [string[]]$Arguments, + [Collections.IDictionary]$Environment = @{}) { + $commandResults.Add([ordered]@{ + name = $Name + command = Format-Command $FilePath $Arguments + status = 'planned' + startedUtc = $null + durationSeconds = 0 + exitCode = $null + stdout = $null + stderr = $null + environmentKeys = @($Environment.Keys | Sort-Object) + }) +} + +function Invoke-GateCommand( + [string]$Name, + [string]$FilePath, + [string[]]$Arguments, + [Collections.IDictionary]$Environment = @{}) { + if ($DryRun) { + Add-PlannedCommand $Name $FilePath $Arguments $Environment + return + } + + $safeName = $Name -replace '[^A-Za-z0-9_.-]', '-' + $stdoutRelative = "commands/$safeName.out.log" + $stderrRelative = "commands/$safeName.err.log" + $stdoutPath = Join-Path $OutputDirectory $stdoutRelative + $stderrPath = Join-Path $OutputDirectory $stderrRelative + $begin = [DateTime]::UtcNow + $watch = [Diagnostics.Stopwatch]::StartNew() + $exitCode = 74 + try { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FilePath + $startInfo.WorkingDirectory = $Repository + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } + foreach ($entry in $Environment.GetEnumerator()) { + $startInfo.Environment[[string]$entry.Key] = [string]$entry.Value + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start()) { throw "Could not start $FilePath." } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + $process.Dispose() + [IO.File]::WriteAllText($stdoutPath, (Protect-Text $stdout)) + [IO.File]::WriteAllText($stderrPath, (Protect-Text $stderr)) + } + catch { + [IO.File]::WriteAllText($stderrPath, (Protect-Text ($_ | Out-String))) + } + finally { + $watch.Stop() + $commandResults.Add([ordered]@{ + name = $Name + command = Format-Command $FilePath $Arguments + status = if ($exitCode -eq 0) { 'passed' } else { 'failed' } + startedUtc = $begin.ToString('O') + durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3) + exitCode = $exitCode + stdout = $stdoutRelative + stderr = $stderrRelative + environmentKeys = @($Environment.Keys | Sort-Object) + }) + } + if ($exitCode -ne 0) { + throw "Preflight command '$Name' failed with exit code $exitCode." + } +} + +function Add-InternalCheck([string]$Name, [scriptblock]$Action) { + if ($DryRun) { + $commandResults.Add([ordered]@{ + name = $Name; command = ''; status = 'planned' + startedUtc = $null; durationSeconds = 0; exitCode = $null + stdout = $null; stderr = $null; environmentKeys = @() + }) + return + } + $begin = [DateTime]::UtcNow + $watch = [Diagnostics.Stopwatch]::StartNew() + $exitCode = 0 + try { & $Action } + catch { $exitCode = 1; throw } + finally { + $watch.Stop() + $commandResults.Add([ordered]@{ + name = $Name; command = '' + status = if ($exitCode -eq 0) { 'passed' } else { 'failed' } + startedUtc = $begin.ToString('O') + durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3) + exitCode = $exitCode; stdout = $null; stderr = $null; environmentKeys = @() + }) + } +} + +function Invoke-DotNet([string]$Name, [string[]]$Arguments) { + Invoke-GateCommand $Name 'dotnet' $Arguments +} + +$portableBuildProjects = @( + 'src/AcDream.Platform/AcDream.Platform.csproj', + 'src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj', + 'src/AcDream.Bake/AcDream.Bake.csproj', + 'src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj', + 'src/AcDream.Core/AcDream.Core.csproj', + 'src/AcDream.Core.Net/AcDream.Core.Net.csproj', + 'src/AcDream.Content/AcDream.Content.csproj', + 'src/AcDream.Runtime/AcDream.Runtime.csproj', + 'src/AcDream.Headless/AcDream.Headless.csproj' +) +$portableTestProjects = @( + 'tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj', + 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj', + 'tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj', + 'tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj', + 'tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj', + 'tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj', + 'tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj' +) + +try { + Invoke-DotNet 'release-build' @( + 'build', 'AcDream.slnx', '-c', 'Release', '--nologo', '-m:1') + Invoke-DotNet 'release-tests-serial' @( + 'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1', + '--', 'RunConfiguration.MaxCpuCount=1') + Invoke-DotNet 'focused-launcher-updater-core' @( + 'test', 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~Updates') + Invoke-DotNet 'focused-launcher-updater-ui' @( + 'test', 'tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~LauncherUpdateViewModelTests|FullyQualifiedName~LauncherStartupOptionsTests') + + foreach ($project in $portableBuildProjects) { + $leaf = [IO.Path]::GetFileNameWithoutExtension($project) + Invoke-DotNet "portable-build-$leaf" @( + 'build', $project, '-c', 'Release', '--no-restore', '--nologo', '-m:1') + } + foreach ($project in $portableTestProjects) { + $leaf = [IO.Path]::GetFileNameWithoutExtension($project) + Invoke-DotNet "portable-test-$leaf" @( + 'test', $project, '-c', 'Release', '--no-build', '--nologo', + '--', 'RunConfiguration.MaxCpuCount=1') + } + + foreach ($rid in @('win-x64', 'linux-x64')) { + $destination = Join-Path $publishDirectory $rid + Invoke-DotNet "publish-launcher-$rid" @( + 'publish', 'src/AcDream.Launcher/AcDream.Launcher.csproj', + '-c', 'Release', '-r', $rid, '--self-contained', 'true', + '-p:PublishSingleFile=true', '-o', $destination, '--nologo') + Add-InternalCheck "publish-contract-$rid" { + $suffix = if ($rid.StartsWith('win-', [StringComparison]::Ordinal)) { '.exe' } else { '' } + foreach ($name in @("acdream-launcher$suffix", "acdream-bake$suffix")) { + if (-not (Test-Path -LiteralPath (Join-Path $destination $name) -PathType Leaf)) { + throw "$rid publish is missing $name." + } + } + if (Test-Path -LiteralPath (Join-Path $destination 'acdream-launcher.dll')) { + throw "$rid launcher publish is not single-file." + } + if (Test-Path -LiteralPath (Join-Path $destination 'acdream-bake.dll')) { + throw "$rid bake publish is not single-file." + } + if (-not $IsWindows -and $rid -eq 'linux-x64') { + $mode = [IO.File]::GetUnixFileMode((Join-Path $destination 'acdream-launcher')) + if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) { + throw 'linux-x64 launcher is not executable.' + } + } + } + } + + $nativeRid = if ($IsWindows) { 'win-x64' } else { 'linux-x64' } + $nativeSuffix = if ($IsWindows) { '.exe' } else { '' } + $nativeRoot = Join-Path $publishDirectory $nativeRid + $bogusRoot = if ($IsWindows) { 'Z:\definitely-not-installed' } else { '/definitely-not-installed' } + $bogusEnvironment = @{ + DOTNET_ROOT = $bogusRoot + DOTNET_ROOT_X64 = $bogusRoot + DOTNET_MULTILEVEL_LOOKUP = '0' + } + Invoke-GateCommand 'native-launcher-bogus-dotnet-root' ` + (Join-Path $nativeRoot "acdream-launcher$nativeSuffix") ` + @('--verify-publish') $bogusEnvironment + Invoke-GateCommand 'native-bake-bogus-dotnet-root' ` + (Join-Path $nativeRoot "acdream-bake$nativeSuffix") ` + @('--help') $bogusEnvironment + + if ($IncludeInstalledDat) { + $datEnvironment = @{ + ACDREAM_DAT_DIR = $InstalledDatDirectory + ACDREAM_PROBE_LIVE_MOUNT = '1' + } + $datResults = Join-Path $OutputDirectory 'installed-dat-results' + Invoke-GateCommand 'installed-dat-character-management-readonly' 'dotnet' @( + 'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~CharacterManagementLiveDatTests', + '--results-directory', $datResults, + '--logger', 'trx;LogFileName=character-management.trx') $datEnvironment + Add-InternalCheck 'installed-dat-character-management-require-pass' { + $trx = Join-Path $datResults 'character-management.trx' + if (-not (Test-Path -LiteralPath $trx -PathType Leaf)) { + throw 'CharacterManagementLiveDatTests did not produce a TRX result.' + } + [xml]$result = Get-Content -LiteralPath $trx -Raw + $outcomes = @($result.TestRun.Results.UnitTestResult | ForEach-Object { $_.outcome }) + if ($outcomes.Count -eq 0 -or $outcomes -ccontains 'NotExecuted' -or + @($outcomes | Where-Object { $_ -cne 'Passed' }).Count -gt 0) { + throw "CharacterManagementLiveDatTests must pass (not skip): $($outcomes -join ',')." + } + } + Invoke-GateCommand 'installed-dat-action-map-readonly' 'dotnet' @( + 'test', 'tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~RetailActionMapReader_LiveDatTests') $datEnvironment + Invoke-GateCommand 'installed-dat-portal-assets-readonly' 'dotnet' @( + 'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~PortalTunnelAssetTests.InstalledDat_ResolvesRetailPortalSetupAndAnimation') $datEnvironment + } +} +catch { + $failures.Add((Protect-Text ($_ | Out-String)).Trim()) +} +finally { + $finishedUtc = [DateTime]::UtcNow + $head = (& git -C $Repository rev-parse HEAD).Trim() + $dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all) + $artifacts = @() + if (-not $DryRun) { + $artifacts = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | + Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } | + Sort-Object FullName | + ForEach-Object { + [ordered]@{ + path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') + size = $_.Length + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + }) + } + $failedCommands = @($commandResults | Where-Object { $_.status -eq 'failed' }) + $report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-automated-preflight' + dryRun = [bool]$DryRun + success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0) + repository = $Repository + head = $head + dirty = ($dirtyLines.Count -gt 0) + dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ }) + platform = [ordered]@{ + os = [Runtime.InteropServices.RuntimeInformation]::OSDescription + architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + processArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() + rid = [Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier + framework = [Runtime.InteropServices.RuntimeInformation]::FrameworkDescription + powershell = $PSVersionTable.PSVersion.ToString() + } + startedUtc = $startedUtc.ToString('O') + finishedUtc = $finishedUtc.ToString('O') + durationSeconds = [Math]::Round(($finishedUtc - $startedUtc).TotalSeconds, 3) + installedDatIncluded = [bool]$IncludeInstalledDat + commands = @($commandResults) + failures = @($failures) + redaction = [ordered]@{ + applied = $true + environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET') + credentialArgumentsAllowed = $false + } + artifacts = $artifacts + } + $reportPath = Join-Path $OutputDirectory 'report.json' + $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8NoBOM + Write-Host "Campaign LA preflight report: $reportPath" + if (-not $report.success) { exit 1 } +} diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1 new file mode 100644 index 00000000..834ff031 --- /dev/null +++ b/tools/test-campaign-la-session-status.ps1 @@ -0,0 +1,346 @@ +<# +.SYNOPSIS + Strict Campaign LA v1 session-status and terminal-process validator. + +.DESCRIPTION + Validates exact JSONL property sets and property order, lifecycle order for + probe/guiSelect/gui/headless, terminal semantics, plugin expectations, + credential redaction, and absence of launcher child-process leaks. The + report contains hashes and event names only; it does not copy account, + character, command, plugin-error, or other payload text. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$StatusFile, + [Parameter(Mandatory = $true)] + [ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode, + [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 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA status validation requires PowerShell 7 or newer.' +} +if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') { + throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.' +} +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 ([string]::IsNullOrWhiteSpace($ReportPath)) { + $ReportPath = "$StatusFile.validation.json" +} +elseif (-not [IO.Path]::IsPathFullyQualified($ReportPath)) { + $ReportPath = [IO.Path]::GetFullPath($ReportPath) +} + +$exactFields = @{ + started = @('v', 'e', 't', 'sessionId') + connected = @('v', 'e', 't', 'sessionId') + characterList = @('v', 'e', 't', 'sessionId', 'accountName', 'slotCount', 'characters') + enteredWorld = @('v', 'e', 't', 'sessionId', 'characterId', 'characterName') + pluginLoaded = @('v', 'e', 't', 'sessionId', 'plugin') + pluginFailed = @('v', 'e', 't', 'sessionId', 'plugin', 'error') + loginCommandFailed = @('v', 'e', 't', 'sessionId', 'commandIndex', 'command', 'error') + disconnected = @('v', 'e', 't', 'sessionId', 'reason') + exited = @('v', 'e', 't', 'sessionId', 'code', 'reason') +} +$failures = [Collections.Generic.List[string]]::new() +$eventNames = [Collections.Generic.List[string]]::new() +$loadedPlugins = [Collections.Generic.List[string]]::new() +$sessionId = $null +$previousTimestamp = [DateTimeOffset]::MinValue +$terminalSeen = $false + +function Get-Properties([Text.Json.JsonElement]$Element) { + $properties = [Collections.Generic.List[object]]::new() + foreach ($property in $Element.EnumerateObject()) { $properties.Add($property) } + return @($properties) +} + +function Assert-String( + [Text.Json.JsonElement]$Root, + [string]$Name, + [bool]$AllowEmpty = $false) { + $value = $Root.GetProperty($Name) + if ($value.ValueKind -ne [Text.Json.JsonValueKind]::String) { + throw "field '$Name' is not a string" + } + $text = $value.GetString() + if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($text)) { + throw "field '$Name' is empty" + } + return $text +} + +function Assert-Int32([Text.Json.JsonElement]$Root, [string]$Name) { + $value = $Root.GetProperty($Name) + if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) { + throw "field '$Name' is not a number" + } + return $value.GetInt32() +} + +function Assert-UInt32([Text.Json.JsonElement]$Root, [string]$Name) { + $value = $Root.GetProperty($Name) + if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) { + throw "field '$Name' is not a number" + } + return $value.GetUInt32() +} + +$lines = @(Get-Content -LiteralPath $StatusFile) +if ($lines.Count -eq 0) { $failures.Add('status stream is empty') } +for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { + $lineNumber = $lineIndex + 1 + $line = $lines[$lineIndex] + if ([string]::IsNullOrWhiteSpace($line)) { + $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") + } + + $document = $null + try { + $document = [Text.Json.JsonDocument]::Parse($line) + $root = $document.RootElement + if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw 'root is not an object' + } + $properties = @(Get-Properties $root) + $names = @($properties | ForEach-Object { $_.Name }) + if (@($names | Sort-Object -Unique).Count -ne $names.Count) { + throw 'object contains duplicate fields' + } + $eventName = Assert-String $root 'e' + if (-not $exactFields.ContainsKey($eventName)) { + throw "event '$eventName' is not in the v1 vocabulary" + } + $expected = $exactFields[$eventName] + if ($names.Count -ne $expected.Count -or + [string]::Join("`n", $names) -cne [string]::Join("`n", $expected)) { + throw "event '$eventName' fields/order are '$($names -join ',')'; expected '$($expected -join ',')'" + } + if ((Assert-Int32 $root 'v') -ne 1) { throw 'field v is not 1' } + $timestampText = Assert-String $root 't' + $timestamp = [DateTimeOffset]::MinValue + if (-not [DateTimeOffset]::TryParseExact( + $timestampText, + 'O', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::RoundtripKind, + [ref]$timestamp) -or $timestamp.Offset -ne [TimeSpan]::Zero) { + throw 'field t is not an exact UTC round-trip timestamp' + } + if ($timestamp -lt $previousTimestamp) { + throw 'timestamp order moved backwards' + } + $previousTimestamp = $timestamp + $lineSessionId = Assert-String $root 'sessionId' + if ($null -eq $sessionId) { $sessionId = $lineSessionId } + if ($lineSessionId -cne $sessionId) { throw 'sessionId changed within the stream' } + if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and + $lineSessionId -cne $ExpectedSessionId) { + throw 'sessionId does not match -ExpectedSessionId' + } + if ($terminalSeen) { throw 'an event appears after terminal exited' } + + switch ($eventName) { + 'characterList' { + $null = Assert-String $root 'accountName' $true + $slotCount = Assert-Int32 $root 'slotCount' + if ($slotCount -lt 0) { throw 'slotCount is negative' } + $characters = $root.GetProperty('characters') + if ($characters.ValueKind -ne [Text.Json.JsonValueKind]::Array) { + throw 'characters is not an array' + } + foreach ($character in $characters.EnumerateArray()) { + if ($character.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw 'a character is not an object' + } + $characterNames = @((Get-Properties $character) | ForEach-Object { $_.Name }) + $characterExpected = @('id', 'name', 'secondsGreyedOut') + if ([string]::Join("`n", $characterNames) -cne + [string]::Join("`n", $characterExpected)) { + throw 'a character fields/order is not id,name,secondsGreyedOut' + } + $null = Assert-UInt32 $character 'id' + $null = Assert-String $character 'name' + $null = Assert-UInt32 $character 'secondsGreyedOut' + } + } + 'enteredWorld' { + $null = Assert-UInt32 $root 'characterId' + $null = Assert-String $root 'characterName' + } + 'pluginLoaded' { + $loadedPlugins.Add((Assert-String $root 'plugin')) + } + 'pluginFailed' { + $null = Assert-String $root 'plugin' + $null = Assert-String $root 'error' + if (-not $AllowPluginFailure) { throw 'pluginFailed is not allowed for this row' } + } + 'loginCommandFailed' { + if ((Assert-Int32 $root 'commandIndex') -lt 0) { + throw 'commandIndex is negative' + } + $null = Assert-String $root 'command' $true + $null = Assert-String $root 'error' + if (-not $AllowLoginCommandFailure) { + throw 'loginCommandFailed is not allowed for this row' + } + } + 'disconnected' { $null = Assert-String $root 'reason' } + 'exited' { + $code = Assert-Int32 $root 'code' + $reason = Assert-String $root 'reason' + if ($code -ne 0) { throw "terminal exit code is $code, expected 0" } + $expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' } + if ($reason -cne $expectedReason) { + throw "terminal reason is '$reason', expected '$expectedReason'" + } + $terminalSeen = $true + } + } + $eventNames.Add($eventName) + } + catch { + $failures.Add("line ${lineNumber}: $($_.Exception.Message)") + } + finally { if ($null -ne $document) { $document.Dispose() } } +} + +function Require-Count([string]$EventName, [int]$Count) { + $actual = @($eventNames | Where-Object { $_ -ceq $EventName }).Count + if ($actual -ne $Count) { + $failures.Add("event '$EventName' count is $actual, expected $Count") + } +} +function First-Index([string]$EventName) { + for ($index = 0; $index -lt $eventNames.Count; $index++) { + if ($eventNames[$index] -ceq $EventName) { return $index } + } + return -1 +} + +Require-Count 'started' 1 +Require-Count 'connected' 1 +Require-Count 'characterList' 1 +Require-Count 'disconnected' 1 +Require-Count 'exited' 1 +$expectEnteredWorld = $Mode -ne 'probe' -and -not $ExpectNoEnteredWorld +Require-Count 'enteredWorld' $(if ($expectEnteredWorld) { 1 } else { 0 }) +if ($eventNames.Count -gt 0 -and $eventNames[0] -cne 'started') { + $failures.Add('started is not the first event') +} +if ($eventNames.Count -gt 0 -and $eventNames[-1] -cne 'exited') { + $failures.Add('exited is not the final event') +} +$orderedRequired = if (-not $expectEnteredWorld) { + @('started', 'connected', 'characterList', 'disconnected', 'exited') +} else { + @('started', 'connected', 'characterList', 'enteredWorld', 'disconnected', 'exited') +} +$last = -1 +foreach ($name in $orderedRequired) { + $next = First-Index $name + if ($next -ge 0 -and $next -le $last) { + $failures.Add("event '$name' is out of lifecycle order") + } + $last = $next +} +$connectedIndex = First-Index 'connected' +foreach ($index in 0..([Math]::Max(0, $eventNames.Count - 1))) { + if ($eventNames.Count -eq 0) { break } + if ($eventNames[$index] -in @('pluginLoaded', 'pluginFailed') -and + ($index -le 0 -or $index -ge $connectedIndex)) { + $failures.Add("plugin event at index $index is outside started-to-connected startup") + } +} +foreach ($plugin in $ExpectedPlugin) { + if (-not ($loadedPlugins -ccontains $plugin)) { + $failures.Add("expected plugin '$plugin' did not emit pluginLoaded") + } +} +$expectedPluginSet = @($ExpectedPlugin | Sort-Object -Unique) +$loadedPluginSet = @($loadedPlugins | Sort-Object -Unique) +if ($loadedPlugins.Count -ne $loadedPluginSet.Count) { + $failures.Add('a plugin emitted pluginLoaded more than once') +} +if ([string]::Join("`n", $loadedPluginSet) -cne + [string]::Join("`n", $expectedPluginSet)) { + $failures.Add( + "loaded plugin set has $($loadedPluginSet.Count) member(s), expected $($expectedPluginSet.Count)") +} +$enteredWorldIndex = First-Index 'enteredWorld' +for ($index = 0; $index -lt $eventNames.Count; $index++) { + if ($eventNames[$index] -ceq 'loginCommandFailed' -and + ($enteredWorldIndex -lt 0 -or $index -le $enteredWorldIndex)) { + $failures.Add("loginCommandFailed at index $index did not follow enteredWorld") + } +} + +if (-not $AllowLauncherChildren) { + $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) + do { + $children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue) + if ($children.Count -eq 0) { break } + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $deadline) + if ($children.Count -gt 0) { + $failures.Add( + "launcher child process leak(s): $((@($children | ForEach-Object { $_.ProcessName + ':' + $_.Id })) -join ',')") + } +} + +$reportDirectory = Split-Path -Parent $ReportPath +if (-not [string]::IsNullOrEmpty($reportDirectory)) { + $null = New-Item -ItemType Directory -Force -Path $reportDirectory +} +$report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-session-status-validation' + success = ($failures.Count -eq 0) + mode = $Mode + enteredWorldExpected = $expectEnteredWorld + statusFile = [IO.Path]::GetFileName($StatusFile) + statusSize = (Get-Item -LiteralPath $StatusFile).Length + statusSha256 = (Get-FileHash -LiteralPath $StatusFile -Algorithm SHA256).Hash.ToLowerInvariant() + lineCount = $lines.Count + eventNames = @($eventNames) + loadedPluginCount = $loadedPlugins.Count + terminalObserved = $terminalSeen + launcherChildrenAllowed = [bool]$AllowLauncherChildren + failures = @($failures) + validatedUtc = [DateTime]::UtcNow.ToString('O') +} +$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM +Write-Host "Campaign LA status validation report: $ReportPath" +if (-not $report.success) { + $failures | ForEach-Object { Write-Error $_ } + exit 1 +} From 134edabed29fcf63d53ae68a62973d7aae74009f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 00:02:04 +0200 Subject: [PATCH 2/4] feat(launcher): complete Campaign LA11 pre-gate support --- docs/architecture/acdream-architecture.md | 8 ++ docs/plans/2026-08-14-launcher-campaign.md | 2 +- .../2026-08-14-campaign-la-test-script.md | 18 +++-- .../2026-08-14-launcher-campaign-design.md | 5 +- .../Updates/ReleaseManifestClient.cs | 48 +++++++++++- src/AcDream.Launcher/AcDream.Launcher.csproj | 4 - src/AcDream.Launcher/App.axaml.cs | 21 ++++- .../LauncherStartupOptions.cs | 22 ++++++ .../LauncherUpdateComposition.cs | 23 +++++- src/AcDream.Launcher/Program.cs | 47 ++++++++---- .../Program.cs | 9 ++- .../Updates/LauncherSelfUpdateManagerTests.cs | 28 +++++-- .../Updates/LauncherSelfUpdateProcessTests.cs | 16 +++- .../Updates/ReleaseTransportTests.cs | 20 +++++ .../LauncherStartupOptionsTests.cs | 76 +++++++++++++++++++ .../LauncherUpdateCompositionTests.cs | 40 ++++++++++ tools/new-campaign-la-update-fixture.ps1 | 43 ++++++++++- tools/run-campaign-la-preflight.ps1 | 54 +++++++++++-- tools/test-campaign-la-session-status.ps1 | 4 +- 19 files changed, 433 insertions(+), 55 deletions(-) diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index c909820d..b4b000b9 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -343,6 +343,14 @@ src/ -> references Platform only; no Avalonia or game-host dependency AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell + Startup/Program -> one immutable process-local option graph before + owner construction; config/data/cache require + three absolute normalized roots and one exact + `ApplicationPathSet` reaches profiles, installer, + versions/updater, sessions, cache, orchestration + -> manifest override reaches only update composition, + is never persisted, and permits HTTP only for a + loopback fixture; production remains pinned HTTPS ViewModels/ -> thin MVVM projection over Launcher.Core, including the first-run DAT/bake wizard and nonfatal startup/manual update state, actions, diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 76fd65bc..a07b767a 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -732,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | LA8 | — | | | | | LA9 | — | | | | | LA10 | — | | | | -| LA11 | **IMPLEMENTATION CHECKPOINT 2026-08-14 — USER GATE PENDING** | pending integration | Review and connected/visual acceptance pending | Strict isolated-root/feed parsing, Windows targeted CTRL_BREAK fixtures, deterministic A/B loopback fixture, automated preflight/status validators, and the exact Windows + Ubuntu/WSL operator script are implemented. Launcher startup composition awaits the LA10 review-fix rebase; no connected row has run and the campaign is not shipped. | +| LA11 | **IMPLEMENTATION CHECKPOINT 2026-08-14 — USER GATE PENDING** | pending integration | Review and connected/visual acceptance pending | Strict isolated-root/feed parsing and one exact launcher path graph are composed on the reviewed LA10 updater base; Windows targeted CTRL_BREAK fixtures, deterministic A/B loopback fixture, automated preflight/status validators, and the exact Windows + Ubuntu/WSL operator script are implemented. No connected row has run and the campaign is not shipped. | 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 664a569e..25109d73 100644 --- a/docs/research/2026-08-14-campaign-la-test-script.md +++ b/docs/research/2026-08-14-campaign-la-test-script.md @@ -72,15 +72,18 @@ The expected matrix is: | Windows | Release `AcDream.slnx` build, `-m:1` | exit 0 | 5–15 min | | Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 20–60 min | | Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 1–4 min | -| Windows | canonical portable project build/test closure from `headless-portability.yml` | every project exits 0 | 10–25 min | +| Windows | canonical portable project build/test closure plus Headless `--help` and empty-config `validate` from `headless-portability.yml` | every project/CLI row exits 0 | 10–25 min | | Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 3–10 min | | Windows | native launcher `--verify-publish` and bake `--help` with bogus `DOTNET_ROOT*` | both exit 0 | <1 min | | Ubuntu/WSL | run the same helper natively from the Linux path to the worktree | Linux RID report and every row exit 0 | 35–90 min | `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 26 commands. It never launches App or Headless in connected mode and -never reads a credential. +plans 30 rows. It 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. ### Optional installed-DAT read-only row @@ -100,7 +103,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: 30 rows. +matrix size: 34 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 @@ -167,9 +170,12 @@ 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, and nonabsolute inputs. It writes fixed-timestamp sorted ZIPs, +root executables (including the co-deployed Bake CLI), and nonabsolute inputs. +It writes fixed-timestamp sorted ZIPs, the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only -server, and an A/B selector. It does not download or mutate payload sources. +server (with optional bounded `-MaximumRequests` smoke mode), and an atomic A/B +selector. Both generated helpers reject a `-Root` other than their own fixture +directory. The generator does not download or mutate payload sources. Start the Windows loopback server without a shell or visible helper window: diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index dbd662b3..0224a7f7 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -333,7 +333,10 @@ the process supervisor executes. (load, capability flag, teardown). - **Connected gates (user-driven):** execute the exact serial matrix in `docs/research/2026-08-14-campaign-la-test-script.md` only after its - connection-free automated preflight passes. Cover every launch mode against local ACE + connection-free automated preflight passes. The launcher uses one immutable + process-local config/data/cache path set for the whole matrix; the local feed + URI reaches only updater composition and is never persisted. Cover every + launch mode against local ACE (gui / guiSelect / headless), the character probe (fresh account → refresh → roster appears, and repeated probes leaving no stale ACE session), clean-profile first-run wizard end-to-end, staged-manifest diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs index a31625c1..7736fee5 100644 --- a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs @@ -11,8 +11,9 @@ public interface IReleaseManifestClient /// /// Strict, bounded reader for the pinned GitHub Releases manifest. Production -/// construction is HTTPS-only. The loopback HTTP allowance is available only -/// through an internal fixture factory and is never inferred from a URI. +/// construction is pinned and HTTPS-only. The explicitly named process-local +/// feed factory independently revalidates its URI and can admit HTTP only for +/// the loopback operator fixture; it cannot change the production constructor. /// Redirects are followed manually so every hop is checked before any bytes /// cross that hop. /// @@ -74,6 +75,49 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable CreateRedirectDisabledHandler(), timeout); + /// + /// Creates the explicit process-local feed seam used by the Campaign LA + /// isolated operator fixture. HTTPS stays HTTPS-only. HTTP is admitted + /// only for a loopback manifest, and never by the pinned production + /// constructor. Credential-bearing or mutable URI suffixes are rejected. + /// + public static ReleaseManifestClient CreateLocalUpdateFeedOverride( + Uri manifestUri, + TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(manifestUri); + if (!string.IsNullOrEmpty(manifestUri.UserInfo) + || !string.IsNullOrEmpty(manifestUri.Query) + || !string.IsNullOrEmpty(manifestUri.Fragment)) + { + throw new LauncherUpdateException( + "A process-local manifest URI cannot contain user information, " + + "a query, or a fragment."); + } + + bool allowLoopbackHttp = string.Equals( + manifestUri.Scheme, + Uri.UriSchemeHttp, + StringComparison.Ordinal) + && manifestUri.IsLoopback; + if (!string.Equals( + manifestUri.Scheme, + Uri.UriSchemeHttps, + StringComparison.Ordinal) + && !allowLoopbackHttp) + { + throw new LauncherUpdateException( + "A process-local manifest URI must use HTTPS " + + "(loopback HTTP is fixture-only)."); + } + + return new ReleaseManifestClient( + manifestUri, + allowLoopbackHttp, + CreateRedirectDisabledHandler(), + timeout); + } + internal static ReleaseManifestClient CreateForTransportTest( Uri manifestUri, bool allowLoopbackHttp, diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index 1b02f993..350260b2 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -28,10 +28,6 @@ - - - - diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs index c42f0de0..b348cf07 100644 --- a/src/AcDream.Launcher/App.axaml.cs +++ b/src/AcDream.Launcher/App.axaml.cs @@ -14,17 +14,33 @@ namespace AcDream.Launcher; public sealed partial class App : Application { + private readonly LauncherStartupOptions? _startupOptions; private LauncherOrchestrator? _orchestrator; private LauncherWindowViewModel? _viewModel; private LauncherUpdateComposition? _updateComposition; + public App() + { + } + + internal App(LauncherStartupOptions startupOptions) + { + _startupOptions = startupOptions + ?? throw new ArgumentNullException(nameof(startupOptions)); + } + + internal LauncherStartupOptions StartupOptions => _startupOptions + ?? throw new InvalidOperationException( + "Launcher startup options were not supplied by the composition root."); + public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - ApplicationPathSet paths = ApplicationPathSet.Resolve(); + LauncherStartupOptions startupOptions = StartupOptions; + ApplicationPathSet paths = startupOptions.Paths; LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths); string rid = LauncherRuntimeIdentity.DetectRid(); string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; @@ -57,7 +73,8 @@ public sealed partial class App : Application GetLauncherVersion(), AppContext.BaseDirectory, () => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive) - == true); + == true, + updateManifestUri: startupOptions.UpdateManifestUri); _updateComposition = updates; _orchestrator = new LauncherOrchestrator( diff --git a/src/AcDream.Launcher/LauncherStartupOptions.cs b/src/AcDream.Launcher/LauncherStartupOptions.cs index 1f1c9164..43b3429e 100644 --- a/src/AcDream.Launcher/LauncherStartupOptions.cs +++ b/src/AcDream.Launcher/LauncherStartupOptions.cs @@ -9,6 +9,7 @@ internal enum LauncherStartupMode VerifyPublish, SelfUpdateHelper, SelfUpdateConfirmation, + SelfUpdateDeferred, } /// @@ -18,6 +19,12 @@ 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( @@ -138,6 +145,13 @@ internal sealed class LauncherStartupOptions "The update manifest URI cannot contain user information."); } + if (!string.IsNullOrEmpty(parsed.Query) + || !string.IsNullOrEmpty(parsed.Fragment)) + { + throw new LauncherStartupOptionsException( + "The update manifest URI cannot contain a query or fragment."); + } + updateManifestUri = parsed; break; default: @@ -199,6 +213,14 @@ 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/src/AcDream.Launcher/LauncherUpdateComposition.cs b/src/AcDream.Launcher/LauncherUpdateComposition.cs index 3b395042..22af717f 100644 --- a/src/AcDream.Launcher/LauncherUpdateComposition.cs +++ b/src/AcDream.Launcher/LauncherUpdateComposition.cs @@ -22,12 +22,14 @@ internal sealed class LauncherUpdateComposition : IDisposable ClientVersionStore versions, LauncherExecutableSet executables, ILauncherUpdater updater, + Uri updateManifestUri, HttpClient? artifactClient, ReleaseManifestClient? manifestClient) { Versions = versions; Executables = executables; Updater = updater; + UpdateManifestUri = updateManifestUri; _artifactClient = artifactClient; _manifestClient = manifestClient; } @@ -38,17 +40,22 @@ internal sealed class LauncherUpdateComposition : IDisposable public ILauncherUpdater Updater { get; } + internal Uri UpdateManifestUri { get; } + public static LauncherUpdateComposition Create( ApplicationPathSet paths, string rid, LauncherVersion launcherVersion, string launcherTargetDirectory, Func hasRunningSessions, - Func? initialize = null) + Func? initialize = null, + Uri? updateManifestUri = null) { ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(launcherVersion); ArgumentNullException.ThrowIfNull(hasRunningSessions); + Uri manifestUri = updateManifestUri + ?? ReleaseManifestClient.ProductionManifestUri; var versions = new ClientVersionStore(paths); HttpClient? artifactClient = null; ReleaseManifestClient? manifestClient = null; @@ -69,7 +76,7 @@ internal sealed class LauncherUpdateComposition : IDisposable Timeout = TimeSpan.FromSeconds(15), }; artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); - manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15)); + manifestClient = CreateManifestClient(manifestUri); var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient); var updater = new LauncherUpdater( manifestClient, @@ -84,6 +91,7 @@ internal sealed class LauncherUpdateComposition : IDisposable versions, LauncherExecutableSet.FromCurrentVersionStore(versions), updater, + manifestUri, artifactClient, manifestClient); } @@ -106,6 +114,7 @@ internal sealed class LauncherUpdateComposition : IDisposable versions, LauncherExecutableSet.Unavailable(status), new UnavailableLauncherUpdater(status, resolution), + manifestUri, artifactClient: null, manifestClient: null); } @@ -117,6 +126,16 @@ internal sealed class LauncherUpdateComposition : IDisposable _artifactClient?.Dispose(); } + private static ReleaseManifestClient CreateManifestClient(Uri manifestUri) + { + ArgumentNullException.ThrowIfNull(manifestUri); + return manifestUri == ReleaseManifestClient.ProductionManifestUri + ? new ReleaseManifestClient(TimeSpan.FromSeconds(15)) + : ReleaseManifestClient.CreateLocalUpdateFeedOverride( + manifestUri, + TimeSpan.FromSeconds(15)); + } + private static bool IsStorageFailure(Exception exception) => exception is IOException or UnauthorizedAccessException diff --git a/src/AcDream.Launcher/Program.cs b/src/AcDream.Launcher/Program.cs index 4bce034a..bb6b7c1f 100644 --- a/src/AcDream.Launcher/Program.cs +++ b/src/AcDream.Launcher/Program.cs @@ -1,5 +1,4 @@ using AcDream.Launcher.Core.Updates; -using AcDream.Platform; using Avalonia; namespace AcDream.Launcher; @@ -9,19 +8,18 @@ internal static class Program [STAThread] public static int Main(string[] args) { - if (args is ["--verify-publish"]) - { - // A display-free execution probe for the packaged artifact. CI - // runs this with DOTNET_ROOT pointing at a missing directory; a - // framework-dependent publish cannot reach this return statement. - return 0; - } - try { - ApplicationPathSet paths = ApplicationPathSet.Resolve(); + LauncherStartupOptions options = LauncherStartupOptions.Parse(args); + if (options.Mode == LauncherStartupMode.VerifyPublish) + { + // A display-free execution probe for the packaged artifact. + // Parsing above deliberately never resolves user paths. + return 0; + } + using var httpClient = new HttpClient(); - var selfUpdates = new LauncherSelfUpdateManager(paths, httpClient); + var selfUpdates = new LauncherSelfUpdateManager(options.Paths, httpClient); string executable = Environment.ProcessPath ?? throw new InvalidOperationException( "The launcher executable path is unavailable."); @@ -37,8 +35,9 @@ internal static class Program return startup.ExitCode; } - return BuildAvaloniaApp().StartWithClassicDesktopLifetime( - startup.RemainingArguments); + RequireUnchangedPublicArguments(options, startup); + + return BuildAvaloniaApp(options).StartWithClassicDesktopLifetime([]); } catch (Exception ex) { @@ -47,7 +46,25 @@ internal static class Program } } - public static AppBuilder BuildAvaloniaApp() => - AppBuilder.Configure() + internal static AppBuilder BuildAvaloniaApp(LauncherStartupOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return AppBuilder.Configure(() => new App(options)) .UsePlatformDetect(); + } + + internal static void RequireUnchangedPublicArguments( + LauncherStartupOptions options, + SelfUpdateStartupResult startup) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(startup); + if (!startup.RemainingArguments.SequenceEqual( + options.PublicArguments, + StringComparer.Ordinal)) + { + throw new InvalidOperationException( + "The self-update bootstrap changed validated launcher arguments."); + } + } } diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs index 8e3f45ac..18a98d1e 100644 --- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs @@ -160,18 +160,23 @@ static async Task BootstrapProbeAsync(string[] arguments) static int CanonicalProbe(string[] arguments) { - if (arguments.Length != 1) + if (arguments.Length < 1) { return 2; } + string suffix = arguments.Length == 1 + ? string.Empty + : Environment.NewLine + + string.Join(Environment.NewLine, arguments[1..]); File.WriteAllText( Path.GetFullPath(arguments[0]), Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) + "|" + Path.GetFullPath( Environment.ProcessPath - ?? throw new InvalidOperationException("Process path is unavailable."))); + ?? throw new InvalidOperationException("Process path is unavailable.")) + + suffix); return 0; } diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs index e2c9b4b3..40fe89f3 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs @@ -279,25 +279,43 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing() { using var harness = new Harness(_root); + string[] publicArguments = + [ + "--config-dir", Path.Combine(_root, "config with spaces"), + "--data-dir", Path.Combine(_root, "data & literal"), + "--cache-dir", Path.Combine(_root, "cache"), + "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json", + ]; SelfUpdateStartupResult ordinary = await LauncherSelfUpdateBootstrap.HandleAsync( - ["--literal", "argument with spaces & metacharacters"], + publicArguments, harness.Manager, harness.Target, harness.LauncherPath); Assert.False(ordinary.ShouldExit); - Assert.Equal(["--literal", "argument with spaces & metacharacters"], - ordinary.RemainingArguments); + Assert.Equal(publicArguments, ordinary.RemainingArguments); + + SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync( + [LauncherSelfUpdateBootstrap.DeferredArgument, .. publicArguments], + harness.Manager, + harness.Target, + harness.LauncherPath); + Assert.False(deferred.ShouldExit); + Assert.Equal(publicArguments, deferred.RemainingArguments); _ = await harness.StageAsync(); SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target); SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync( - [LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId], + [ + LauncherSelfUpdateBootstrap.ConfirmArgument, + applied.TransactionId, + .. publicArguments, + ], harness.Manager, harness.Target, harness.LauncherPath); Assert.False(confirmation.ShouldExit); - Assert.Empty(confirmation.RemainingArguments); + Assert.Equal(publicArguments, confirmation.RemainingArguments); Assert.False(File.Exists(harness.Manager.PendingPlanPath)); Assert.False(harness.Manager.IsConfirmed(applied.TransactionId)); } diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs index 1c67efc3..5185fc7d 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs @@ -34,6 +34,13 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable string ready = Path.Combine(_root, "crash.ready"); string launched = Path.Combine(_root, "replacement.ready"); string helperPidPath = Path.Combine(_root, "helper.pid"); + string[] processLocalSuffix = + [ + "--config-dir", Path.Combine(_root, "isolated config"), + "--data-dir", Path.Combine(_root, "isolated data"), + "--cache-dir", Path.Combine(_root, "isolated cache"), + "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json", + ]; Directory.CreateDirectory(_root); string rid = LauncherRuntimeIdentity.DetectRid(); PreparedLauncher prepared = PrepareLauncherClosure(target, rid); @@ -76,7 +83,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable }; using Process canonical = StartProcess( prepared.CanonicalPath, - ["canonical-probe", launched], + ["canonical-probe", launched, .. processLocalSuffix], environment); await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); Assert.Equal(0, canonical.ExitCode); @@ -100,7 +107,12 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable SearchOption.TopDirectoryOnly)); Assert.Null(await manager.LoadPendingAsync()); - int replacementPid = ParsePid(await File.ReadAllTextAsync(launched)); + string launchMarker = await File.ReadAllTextAsync(launched); + Assert.EndsWith( + Environment.NewLine + string.Join(Environment.NewLine, processLocalSuffix), + launchMarker, + StringComparison.Ordinal); + int replacementPid = ParsePid(launchMarker); int helperPid = int.Parse( await File.ReadAllTextAsync(helperPidPath), System.Globalization.CultureInfo.InvariantCulture); diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs index b3d80e11..0208d60d 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs @@ -44,6 +44,26 @@ public sealed class LauncherVersionTests public sealed class ReleaseManifestClientTests { + [Theory] + [InlineData("https://updates.example.test/manifest.json")] + [InlineData("http://127.0.0.1:43119/manifest.json")] + [InlineData("http://localhost:43119/manifest.json")] + public void LocalUpdateFeedOverrideAcceptsOnlySecureOrLoopbackFeeds(string value) + { + using ReleaseManifestClient source = + ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value)); + } + + [Theory] + [InlineData("http://updates.example.test/manifest.json")] + [InlineData("file:///tmp/manifest.json")] + [InlineData("https://user:secret@updates.example.test/manifest.json")] + [InlineData("https://updates.example.test/manifest.json?token=secret")] + [InlineData("https://updates.example.test/manifest.json#fragment")] + public void LocalUpdateFeedOverrideRejectsRemoteHttpAndCredentialLikeUris(string value) => + Assert.Throws(() => + ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value))); + [Fact] public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed() { diff --git a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs index ff3bb24c..d7f223ce 100644 --- a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs @@ -146,6 +146,78 @@ public sealed class LauncherStartupOptionsTests Assert.Equal(helperOptions.UpdateManifestUri, confirmationOptions.UpdateManifestUri); } + [Fact] + public void DeferredSelfUpdateRestartRetainsIsolationWithoutResolvingDefaults() + { + string root = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream-la11-deferred")); + string[] suffix = + [ + "--config-dir", Path.Combine(root, "config"), + "--data-dir", Path.Combine(root, "data"), + "--cache-dir", Path.Combine(root, "cache"), + "--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); + } + + [Fact] + public void AvaloniaCompositionRetainsTheExactParsedOptionsAndPathSet() + { + string root = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream-la11-app-composition")); + LauncherStartupOptions options = LauncherStartupOptions.Parse( + [ + "--config-dir", Path.Combine(root, "config"), + "--data-dir", Path.Combine(root, "data"), + "--cache-dir", Path.Combine(root, "cache"), + "--update-manifest-uri", "https://updates.example.test/manifest.json", + ], + () => throw new InvalidOperationException( + "canonical path resolver was touched")); + + var app = new App(options); + + Assert.Same(options, app.StartupOptions); + Assert.Same(options.Paths, app.StartupOptions.Paths); + Assert.Equal( + new Uri("https://updates.example.test/manifest.json"), + app.StartupOptions.UpdateManifestUri); + } + + [Fact] + public void CompositionRejectsAnyBootstrapArgumentDrift() + { + var paths = new ApplicationPathSet("config", "data", "cache", null); + LauncherStartupOptions options = LauncherStartupOptions.Parse( + ["--update-manifest-uri", "https://updates.example.test/manifest.json"], + () => paths); + + Program.RequireUnchangedPublicArguments( + options, + new SelfUpdateStartupResult( + false, + 0, + options.PublicArguments.ToArray())); + Assert.Throws(() => + Program.RequireUnchangedPublicArguments( + options, + new SelfUpdateStartupResult( + false, + 0, + ["--update-manifest-uri", "https://other.example.test/manifest.json"]))); + } + public static TheoryData InvalidArguments() { string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11")); @@ -171,6 +243,10 @@ public sealed class LauncherStartupOptionsTests data.Add(["--update-manifest-uri", "file:///tmp/manifest.json"]); data.Add( ["--update-manifest-uri", "https://user:secret@example.test/manifest.json"]); + data.Add( + ["--update-manifest-uri", "https://example.test/manifest.json?token=secret"]); + data.Add( + ["--update-manifest-uri", "https://example.test/manifest.json#fragment"]); data.Add(["--update-manifest-uri", "not-a-uri"]); data.Add( [ diff --git a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs index 37365efe..d2f2ed98 100644 --- a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs @@ -62,4 +62,44 @@ public sealed class LauncherUpdateCompositionTests : IDisposable () => composition.Updater.CheckAsync()); Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal); } + + [Theory] + [InlineData("https://updates.example.test/manifest.json")] + [InlineData("http://127.0.0.1:43119/manifest.json")] + public void ProcessLocalManifestOverrideReachesOnlyUpdateComposition(string value) + { + Directory.CreateDirectory(_root); + var paths = new ApplicationPathSet( + Path.Combine(_root, "config"), + Path.Combine(_root, "data"), + Path.Combine(_root, "cache"), + null); + var manifestUri = new Uri(value); + + using LauncherUpdateComposition composition = LauncherUpdateComposition.Create( + paths, + LauncherRuntimeIdentity.DetectRid(), + LauncherVersion.Parse("1.0.0"), + _root, + () => false, + initialize: (_, _) => new ClientVersionResolution( + ClientVersionState.Missing, + "No client version is installed.", + null, + null, + null, + null), + updateManifestUri: manifestUri); + + Assert.Same(manifestUri, composition.UpdateManifestUri); + Assert.Equal( + Path.Combine(paths.DataDirectory, "app"), + composition.Versions.AppDirectory); + Assert.False(File.Exists( + Path.Combine(paths.ConfigDirectory, "launcher-profiles.json"))); + Assert.Empty(Directory.EnumerateFiles( + _root, + "*", + SearchOption.AllDirectories)); + } } diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1 index 98d129eb..52108e71 100644 --- a/tools/new-campaign-la-update-fixture.ps1 +++ b/tools/new-campaign-la-update-fixture.ps1 @@ -85,9 +85,11 @@ foreach ($release in @('A', 'B')) { Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe' Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe' Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe' + Require-PayloadFile "$release-launcher-win-x64" 'acdream-bake.exe' Require-PayloadFile "$release-client-linux-x64" 'AcDream.App' Require-PayloadFile "$release-client-linux-x64" 'acdream-headless' Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher' + Require-PayloadFile "$release-launcher-linux-x64" 'acdream-bake' } if (Test-Path -LiteralPath $OutputDirectory) { @@ -155,6 +157,7 @@ function New-DeterministicZip( $executable = $relative -ceq 'AcDream.App' -or $relative -ceq 'acdream-headless' -or $relative -ceq 'acdream-launcher' -or + $relative -ceq 'acdream-bake' -or $relative.EndsWith('.sh', [StringComparison]::Ordinal) $mode = if ($executable) { 0x81ED } else { 0x81A4 } $entry.ExternalAttributes = $mode -shl 16 @@ -239,16 +242,26 @@ $server = @' [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Root, - [Parameter(Mandatory = $true)][int]$Port + [Parameter(Mandatory = $true)][int]$Port, + [ValidateRange(0, 1000000)][int]$MaximumRequests = 0 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($PSScriptRoot)) $Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) +$pathComparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase +} else { [StringComparison]::Ordinal } +if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) { + throw '-Root must be the directory containing serve-fixture.ps1.' +} $prefix = "http://127.0.0.1:$Port/" $listener = [Net.HttpListener]::new() $listener.Prefixes.Add($prefix) $listener.Start() Write-Host "Campaign LA fixture listening on $prefix" +$servedRequests = 0 try { while ($listener.IsListening) { $context = $listener.GetContext() @@ -287,7 +300,13 @@ try { $context.Response.StatusCode = 500 Write-Error $_ } - finally { $context.Response.Close() } + finally { + $context.Response.Close() + $servedRequests++ + } + if ($MaximumRequests -gt 0 -and $servedRequests -ge $MaximumRequests) { + break + } } } finally { $listener.Close() } @@ -301,8 +320,24 @@ param( [string]$Root = $PSScriptRoot ) Set-StrictMode -Version Latest -$path = Join-Path ([IO.Path]::GetFullPath($Root)) 'active-release.txt' -Set-Content -LiteralPath $path -Value $Release -Encoding ascii -NoNewline +$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($PSScriptRoot)) +$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) +$pathComparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase +} else { [StringComparison]::Ordinal } +if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) { + throw '-Root must be the directory containing set-active-release.ps1.' +} +$path = Join-Path $Root 'active-release.txt' +$temporary = "$path.$([Guid]::NewGuid().ToString('N')).tmp" +try { + [IO.File]::WriteAllText($temporary, $Release, [Text.Encoding]::ASCII) + [IO.File]::Move($temporary, $path, $true) +} +finally { + if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } +} Write-Host "Campaign LA fixture active release: $Release" '@ $selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM diff --git a/tools/run-campaign-la-preflight.ps1 b/tools/run-campaign-la-preflight.ps1 index 0a676df1..13b376e1 100644 --- a/tools/run-campaign-la-preflight.ps1 +++ b/tools/run-campaign-la-preflight.ps1 @@ -68,16 +68,22 @@ $startedUtc = [DateTime]::UtcNow function Protect-Text([string]$Text) { if ($null -eq $Text) { return '' } $protected = $Text - foreach ($name in @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')) { - $value = [Environment]::GetEnvironmentVariable($name) - if (-not [string]::IsNullOrEmpty($value)) { - $protected = $protected.Replace($value, '', [StringComparison]::Ordinal) - } - } $protected = [Text.RegularExpressions.Regex]::Replace( $protected, '(?i)(--password|-password)(\s+|=)([^\s"'']+)', '$1$2') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)\b(password|passwd|secret|token|credential|api[_-]?key)(\s*[:=]\s*)([^\s,;]+)', + '$1$2') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)(https?://)[^/\s:@]+:[^@\s/]+@', + '$1@') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)([?&](?:token|secret|password|credential|api[_-]?key)=)[^&\s]+', + '$1') $protected = [Text.RegularExpressions.Regex]::Replace( $protected, '(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")', @@ -142,6 +148,11 @@ function Invoke-GateCommand( $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } + foreach ($key in @($startInfo.Environment.Keys)) { + if ($key.StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase)) { + $startInfo.Environment.Remove($key) + } + } foreach ($entry in $Environment.GetEnumerator()) { $startInfo.Environment[[string]$entry.Key] = [string]$entry.Value } @@ -258,6 +269,34 @@ try { '--', 'RunConfiguration.MaxCpuCount=1') } + $headlessValidationConfig = Join-Path $OutputDirectory 'headless-k0.json' + Add-InternalCheck 'portable-headless-write-empty-config' { + [IO.File]::WriteAllText( + $headlessValidationConfig, + '{"version":1,"sessions":[]}', + [Text.UTF8Encoding]::new($false)) + } + Invoke-DotNet 'portable-headless-help-no-connect' @( + 'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj', + '-c', 'Release', '--no-build', '--', '--help') + Invoke-DotNet 'portable-headless-validate-empty-no-connect' @( + 'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj', + '-c', 'Release', '--no-build', '--', + 'validate', '--config', $headlessValidationConfig) + Add-InternalCheck 'portable-headless-native-permission' { + if (-not $IsWindows) { + $headlessExecutable = Join-Path ` + $Repository 'src/AcDream.Headless/bin/Release/net10.0/acdream-headless' + if (-not (Test-Path -LiteralPath $headlessExecutable -PathType Leaf)) { + throw 'The native Headless build output is missing.' + } + $mode = [IO.File]::GetUnixFileMode($headlessExecutable) + if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) { + throw 'The native Headless build output is not executable.' + } + } + } + foreach ($rid in @('win-x64', 'linux-x64')) { $destination = Join-Path $publishDirectory $rid Invoke-DotNet "publish-launcher-$rid" @( @@ -382,7 +421,8 @@ finally { failures = @($failures) redaction = [ordered]@{ applied = $true - environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET') + inheritedAcdreamEnvironmentCleared = $true + inheritedEnvironmentValuesRead = $false credentialArgumentsAllowed = $false } artifacts = $artifacts diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1 index 834ff031..8f3b2c61 100644 --- a/tools/test-campaign-la-session-status.ps1 +++ b/tools/test-campaign-la-session-status.ps1 @@ -137,7 +137,7 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { } $eventName = Assert-String $root 'e' if (-not $exactFields.ContainsKey($eventName)) { - throw "event '$eventName' is not in the v1 vocabulary" + throw 'event name is not in the v1 vocabulary' } $expected = $exactFields[$eventName] if ($names.Count -ne $expected.Count -or @@ -221,7 +221,7 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { if ($code -ne 0) { throw "terminal exit code is $code, expected 0" } $expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' } if ($reason -cne $expectedReason) { - throw "terminal reason is '$reason', expected '$expectedReason'" + throw "terminal reason does not match mode '$Mode'" } $terminalSeen = $true } From accd01a00878643ad1b59be55aca161abf3ce817 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 01:08:41 +0200 Subject: [PATCH 3/4] 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') } From 9f9c116792d1a6298ec05757d6dafc8f948fe9ad Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 01:45:15 +0200 Subject: [PATCH 4/4] fix(launcher): harden LA11 gate evidence --- .../2026-08-14-campaign-la-test-script.md | 32 ++-- tools/CampaignLaProcessCorrelation.ps1 | 171 ++++++++++++++++-- tools/capture-campaign-la-session-process.ps1 | 12 +- tools/new-campaign-la-update-fixture.ps1 | 9 +- tools/test-campaign-la-gate-helpers.ps1 | 115 +++++++++--- tools/test-campaign-la-script-safety.ps1 | 128 ++++++++++++- tools/test-campaign-la-session-status.ps1 | 136 +++++++++++--- 7 files changed, 514 insertions(+), 89 deletions(-) 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 0377c261..cd8f68b0 100644 --- a/docs/research/2026-08-14-campaign-la-test-script.md +++ b/docs/research/2026-08-14-campaign-la-test-script.md @@ -180,8 +180,10 @@ The helper rejects nonempty output, invalid or non-monotonic versions, missing 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, +never its own output, and normalizes ZIP origin to Unix on both hosts so +Windows/Linux hashes are identical under multiple cultures while native Linux +extraction retains 0755 for App/Headless/Launcher/Bake and 0644 for ordinary +files. 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 @@ -233,8 +235,10 @@ session composer, and orchestrator must all use this one exact path set. 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: +path, records neither raw command line nor config contents, and must finish +while the child is still live. Its safe sidecar contains the normalized config +path, a sanitized command fingerprint, and PID plus an OS-native process-start +identity so later PID reuse cannot become a false leak: ```powershell $CapturePath = Join-Path $Evidence '-process.capture.json' @@ -262,8 +266,7 @@ $Status = Join-Path $WinCache "launcher/sessions/$($Capture.sessionId)/status.js pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` -StatusFile $Status ` -Mode '' ` - -ExpectedProcessId $Capture.processId ` - -SessionConfigPath $SessionConfig ` + -ProcessCapturePath $CapturePath ` -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` -ExpectedSessionId $Capture.sessionId ` -ReportPath (Join-Path $Evidence '-status.validation.json') @@ -273,14 +276,16 @@ 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, 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 +redaction, and that exact captured process instance is gone. Independent exact +config-path correlation uses Windows CIM or Linux `/proc/*/cmdline`; it never +globally scans a process name, treats a different start identity on a reused PID +as a different process, and is unaffected by unrelated same-name processes or +Linux's 15-character names. 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. +profile, raw `session.json`/`status.jsonl`, and raw process-capture sidecar +(which contains the absolute isolated path) local; never upload them. ## 5. Serial Windows user rows A–H @@ -429,8 +434,7 @@ 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') ` + -ProcessCapturePath (Join-Path $Evidence 'G-process.capture.json') ` -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` -ExpectNoEnteredWorld ` -ExpectedSessionId '' ` diff --git a/tools/CampaignLaProcessCorrelation.ps1 b/tools/CampaignLaProcessCorrelation.ps1 index 00718f13..25864a2d 100644 --- a/tools/CampaignLaProcessCorrelation.ps1 +++ b/tools/CampaignLaProcessCorrelation.ps1 @@ -1,31 +1,127 @@ Set-StrictMode -Version Latest +function Get-CampaignLaSha256([string]$Text) { + $bytes = [Text.Encoding]::UTF8.GetBytes($Text) + return [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() +} + +function Get-CampaignLaCommandLineFingerprint( + [string]$ExecutablePath, + [string]$ConfigArgument, + [string]$SessionConfigPath) { + if (-not [IO.Path]::IsPathFullyQualified($ExecutablePath) -or + -not [IO.Path]::IsPathFullyQualified($SessionConfigPath) -or + $ConfigArgument -cnotin @('--config', '--session-config')) { + throw 'Cannot fingerprint an incomplete launcher-child command line.' + } + $executable = [IO.Path]::GetFullPath($ExecutablePath) + $config = [IO.Path]::GetFullPath($SessionConfigPath) + # Only the executable and the recognized config argument are retained in + # this projection. Launcher credentials use stdin; unrelated argv is + # deliberately excluded so an accidental secret can never enter evidence. + return Get-CampaignLaSha256( + "campaign-la-child-command-v1`n$executable`n$ConfigArgument`n$config") +} + +function Get-CampaignLaLinuxProcessIdentity( + [string]$ProcessDirectory, + [string]$BootId) { + $stat = [IO.File]::ReadAllText((Join-Path $ProcessDirectory 'stat')) + $commandEnd = $stat.LastIndexOf(')') + if ($commandEnd -lt 2 -or $commandEnd + 2 -ge $stat.Length) { + throw 'Linux process stat record is malformed.' + } + # The tail begins at field 3 (state); field 22 (starttime) is index 19. + $tail = @($stat.Substring($commandEnd + 2).Split( + ' ', + [StringSplitOptions]::RemoveEmptyEntries)) + if ($tail.Count -le 19) { throw 'Linux process stat record has no starttime.' } + $startTicks = [uint64]::Parse( + $tail[19], + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture) + return "linux-proc-start-v1:$BootId`:$startTicks" +} + +function Get-CampaignLaProcessInstanceIdentity { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateRange(1, 2147483647)][int]$ProcessId) + + if ($IsWindows) { + $candidate = Get-CimInstance Win32_Process ` + -Filter "ProcessId=$ProcessId" -ErrorAction Stop + if ($null -eq $candidate) { return $null } + if ($null -eq $candidate.CreationDate) { + throw "Windows process $ProcessId has no creation time." + } + return "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)" + } + if ($IsLinux) { + $directory = "/proc/$ProcessId" + if (-not [IO.Directory]::Exists($directory)) { return $null } + try { + $bootId = [IO.File]::ReadAllText( + '/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant() + if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { + throw 'Linux boot id is malformed.' + } + return Get-CampaignLaLinuxProcessIdentity $directory $bootId + } + catch [IO.FileNotFoundException] { return $null } + catch [IO.DirectoryNotFoundException] { return $null } + catch [IO.IOException] { + if (-not [IO.Directory]::Exists($directory)) { return $null } + throw + } + } + throw 'Campaign LA process identity supports Windows and Linux only.' +} + function Get-CampaignLaSessionProcessCorrelations { [CmdletBinding()] param() - $matches = [Collections.Generic.List[object]]::new() + $correlations = [Collections.Generic.List[object]]::new() if ($IsWindows) { - $pattern = '(?i)(?:^|\s)(?:--config|--session-config)\s+(?:"([^"]+)"|(\S+))' + $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 } + $executablePath = [string]$candidate.ExecutablePath + if ([string]::IsNullOrWhiteSpace($commandLine) -or + -not [IO.Path]::IsPathFullyQualified($executablePath) -or + $null -eq $candidate.CreationDate) { + continue + } + $identity = "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)" foreach ($match in [Text.RegularExpressions.Regex]::Matches( $commandLine, $pattern)) { - $value = if ($match.Groups[1].Success) { - $match.Groups[1].Value - } else { $match.Groups[2].Value } + $argument = $match.Groups[1].Value.ToLowerInvariant() + $value = if ($match.Groups[2].Success) { + $match.Groups[2].Value + } else { $match.Groups[3].Value } if ([IO.Path]::IsPathFullyQualified($value)) { - $matches.Add([pscustomobject]@{ + $configPath = [IO.Path]::GetFullPath($value) + $correlations.Add([pscustomobject]@{ ProcessId = [int]$candidate.ProcessId - SessionConfigPath = [IO.Path]::GetFullPath($value) + ProcessInstanceIdentity = $identity + SessionConfigPath = $configPath + CommandLineFingerprintSha256 = + Get-CampaignLaCommandLineFingerprint ` + $executablePath $argument $configPath }) } } } } elseif ($IsLinux) { + $bootId = [IO.File]::ReadAllText('/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant() + if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { + throw 'Linux boot id is malformed.' + } foreach ($directory in [IO.Directory]::EnumerateDirectories('/proc')) { $leaf = [IO.Path]::GetFileName($directory) $processId = 0 @@ -37,24 +133,34 @@ function Get-CampaignLaSessionProcessCorrelations { continue } try { + $identityBefore = Get-CampaignLaLinuxProcessIdentity $directory $bootId $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)) + $identityAfter = Get-CampaignLaLinuxProcessIdentity $directory $bootId + if ($identityBefore -cne $identityAfter -or $arguments.Count -eq 0 -or + -not [IO.Path]::IsPathFullyQualified($arguments[0])) { + continue + } 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]@{ + $configPath = [IO.Path]::GetFullPath($arguments[$index + 1]) + $correlations.Add([pscustomobject]@{ ProcessId = $processId - SessionConfigPath = [IO.Path]::GetFullPath( - $arguments[$index + 1]) + ProcessInstanceIdentity = $identityBefore + SessionConfigPath = $configPath + CommandLineFingerprintSha256 = + Get-CampaignLaCommandLineFingerprint ` + $arguments[0] $arguments[$index] $configPath }) } } } catch [IO.IOException] { - # A process may exit between /proc enumeration and cmdline read. + # A process may exit between /proc enumeration and either read. } catch [UnauthorizedAccessException] { # Other-user processes cannot be the owner-readable gate child. @@ -65,7 +171,7 @@ function Get-CampaignLaSessionProcessCorrelations { throw 'Campaign LA process correlation supports Windows and Linux only.' } - return @($matches) + return @($correlations) } function Get-CampaignLaCorrelatedProcessIds { @@ -79,15 +185,48 @@ function Get-CampaignLaCorrelatedProcessIds { $comparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } - $matches = [Collections.Generic.HashSet[int]]::new() + $processIds = [Collections.Generic.HashSet[int]]::new() foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) { if ([string]::Equals( $candidate.SessionConfigPath, $SessionConfigPath, $comparison)) { - $null = $matches.Add([int]$candidate.ProcessId) + $null = $processIds.Add([int]$candidate.ProcessId) } } - return @($matches | Sort-Object) + return @($processIds | Sort-Object) +} + +function Test-CampaignLaCapturedProcessState { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][int]$ProcessId, + [Parameter(Mandatory = $true)][string]$ProcessInstanceIdentity, + [Parameter(Mandatory = $true)][string]$SessionConfigPath, + [AllowNull()][string]$CurrentProcessInstanceIdentity, + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()][object[]]$Correlations) + + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + $sameInstanceAlive = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and + $CurrentProcessInstanceIdentity -ceq $ProcessInstanceIdentity + $exactConfigPathAlive = $false + $pidReused = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and + $CurrentProcessInstanceIdentity -cne $ProcessInstanceIdentity + foreach ($candidate in $Correlations) { + if ([string]::Equals( + [string]$candidate.SessionConfigPath, + $SessionConfigPath, + $comparison)) { + $exactConfigPathAlive = $true + } + } + return [pscustomobject]@{ + SameInstanceAlive = $sameInstanceAlive + ExactConfigPathAlive = $exactConfigPathAlive + PidReused = $pidReused + } } diff --git a/tools/capture-campaign-la-session-process.ps1 b/tools/capture-campaign-la-session-process.ps1 index 448660d4..4b0fd74a 100644 --- a/tools/capture-campaign-la-session-process.ps1 +++ b/tools/capture-campaign-la-session-process.ps1 @@ -90,18 +90,26 @@ if ($correlations.Count -ne 1) { throw 'No live process uses the isolated session config.' } $SessionConfigPath = [IO.Path]::GetFullPath($correlations[0].SessionConfigPath) +$processIdentity = [string]$correlations[0].ProcessInstanceIdentity +$commandFingerprint = [string]$correlations[0].CommandLineFingerprintSha256 +if ($processIdentity -notmatch '^(windows-creation-v1:[0-9]{15,19}|linux-proc-start-v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9]+)$' -or + $commandFingerprint -notmatch '^[0-9a-f]{64}$') { + throw 'The correlated process instance evidence is malformed.' +} $directory = Split-Path -Parent $ReportPath if (-not [string]::IsNullOrEmpty($directory)) { $null = New-Item -ItemType Directory -Force -Path $directory } $report = [ordered]@{ - schemaVersion = 1 + schemaVersion = 2 kind = 'campaign-la-session-process-capture' processId = [int]$correlations[0].ProcessId + processInstanceIdentity = $processIdentity sessionId = [IO.Path]::GetFileName( [IO.Path]::GetDirectoryName($SessionConfigPath)) - sessionConfigFile = [IO.Path]::GetFileName($SessionConfigPath) + sessionConfigPath = $SessionConfigPath + commandLineFingerprintSha256 = $commandFingerprint capturedUtc = [DateTime]::UtcNow.ToString('O') } $report | ConvertTo-Json -Depth 3 | diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1 index 2e8723f8..d6609f47 100644 --- a/tools/new-campaign-la-update-fixture.ps1 +++ b/tools/new-campaign-la-update-fixture.ps1 @@ -208,10 +208,11 @@ function Set-DeterministicZipHostPlatform([string]$Path) { (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 + # ZipArchive stamps the creating host (FAT on Windows, Unix on Linux) + # in the upper byte of "version made by". Normalize to Unix so native + # extraction honors the explicit regular-file type and 0755/0644 mode + # bits already stored in ExternalAttributes. + $bytes[[int]$cursor + 5] = 3 $nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28) $extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30) $commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32) diff --git a/tools/test-campaign-la-gate-helpers.ps1 b/tools/test-campaign-la-gate-helpers.ps1 index 11756555..f1751988 100644 --- a/tools/test-campaign-la-gate-helpers.ps1 +++ b/tools/test-campaign-la-gate-helpers.ps1 @@ -28,6 +28,7 @@ if ([string]::IsNullOrWhiteSpace($pwsh)) { } $validator = Join-Path $Repository 'tools/test-campaign-la-session-status.ps1' $capture = Join-Path $Repository 'tools/capture-campaign-la-session-process.ps1' +. (Join-Path $Repository 'tools/CampaignLaProcessCorrelation.ps1') function Write-Profile([string]$Path, [string]$Secret) { $document = [ordered]@{ @@ -86,16 +87,14 @@ function Invoke-Validator( [string]$Status, [string]$Profile, [string]$Report, - [int]$ExpectedProcessId, - [bool]$ShouldPass, - [string]$SessionConfig = '') { + [string]$ProcessCapture, + [bool]$ShouldPass) { $arguments = [Collections.Generic.List[string]]::new() foreach ($value in @( '-NoProfile', '-File', $validator, '-StatusFile', $Status, '-Mode', 'gui', - '-ExpectedProcessId', $ExpectedProcessId.ToString( - [Globalization.CultureInfo]::InvariantCulture), + '-ProcessCapturePath', $ProcessCapture, '-CredentialProfilePath', $Profile, '-ExpectedPlugin', 'smoke', '-AllowPluginFailure', @@ -103,10 +102,6 @@ function Invoke-Validator( '-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 @@ -127,6 +122,30 @@ function Invoke-Validator( } } +function Write-ProcessCapture( + [string]$Path, + [int]$ProcessId, + [string]$ProcessInstanceIdentity, + [string]$SessionConfigPath, + [string]$CommandFingerprint = ('a' * 64)) { + $sessionId = [IO.Path]::GetFileName( + [IO.Path]::GetDirectoryName($SessionConfigPath)) + $document = [ordered]@{ + schemaVersion = 2 + kind = 'campaign-la-session-process-capture' + processId = $ProcessId + processInstanceIdentity = $ProcessInstanceIdentity + sessionId = $sessionId + sessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) + commandLineFingerprintSha256 = $CommandFingerprint + capturedUtc = [DateTime]::UtcNow.ToString('O') + } + [IO.File]::WriteAllText( + $Path, + ($document | ConvertTo-Json -Depth 4), + [Text.UTF8Encoding]::new($false)) +} + $quickInfo = [Diagnostics.ProcessStartInfo]::new($pwsh) $quickInfo.UseShellExecute = $false $quickInfo.ArgumentList.Add('-NoProfile') @@ -138,13 +157,33 @@ $goneProcessId = $quick.Id $quick.WaitForExit() $quick.Dispose() +$sessionRoot = Join-Path $OutputDirectory 'fixture-session' +$null = New-Item -ItemType Directory -Path $sessionRoot +$sessionConfig = Join-Path $sessionRoot 'session.json' +[IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false)) +$syntheticIdentity = if ($IsWindows) { + 'windows-creation-v1:638000000000000000' +} else { 'linux-proc-start-v1:00000000-0000-0000-0000-000000000001:1' } +$goneCapture = Join-Path $OutputDirectory 'gone-process.capture.json' +Write-ProcessCapture ` + $goneCapture $goneProcessId $syntheticIdentity $sessionConfig + $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 + $goneCapture $true + +$malformedCapture = Join-Path $OutputDirectory 'malformed-process.capture.json' +[IO.File]::WriteAllText( + $malformedCapture, + '{"schemaVersion":2}', + [Text.UTF8Encoding]::new($false)) +Invoke-Validator ` + $positiveStatus $profile (Join-Path $OutputDirectory 'malformed.validation.json') ` + $malformedCapture $false foreach ($reason in @('transport', 'reconnect', 'other')) { $events = @(New-GuiEvents) @@ -152,7 +191,7 @@ foreach ($reason in @('transport', 'reconnect', 'other')) { $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 + Invoke-Validator $path $profile $report $goneCapture $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." @@ -186,7 +225,7 @@ foreach ($case in $secretCases) { $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 + Invoke-Validator $path $caseProfile $report $goneCapture $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." @@ -212,12 +251,14 @@ if ($IsLinux) { [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' +$unrelatedSessionRoot = Join-Path $OutputDirectory 'unrelated-session' +$null = New-Item -ItemType Directory -Path $unrelatedSessionRoot +$unrelatedConfig = Join-Path $unrelatedSessionRoot 'session.json' +[IO.File]::WriteAllText($unrelatedConfig, '{}', [Text.UTF8Encoding]::new($false)) function Start-Fixture([string[]]$Arguments) { $start = [Diagnostics.ProcessStartInfo]::new($sameNameHost) @@ -230,7 +271,7 @@ function Start-Fixture([string[]]$Arguments) { $target = Start-Fixture @( 'hold-campaign-la-process', '--config', $sessionConfig, $targetReady, $targetRelease) $unrelated = Start-Fixture @( - 'hold-update-lease', 'session', (Join-Path $OutputDirectory 'unrelated-data'), + 'hold-campaign-la-process', '--config', $unrelatedConfig, $unrelatedReady, $unrelatedRelease) if ($null -eq $target -or $null -eq $unrelated) { throw 'Could not start process-correlation fixtures.' @@ -252,16 +293,19 @@ try { -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.' + if ([int]$captured.processId -ne $target.Id -or + [string]$captured.sessionConfigPath -cne $sessionConfig -or + [string]$captured.commandLineFingerprintSha256 -cnotmatch '^[0-9a-f]{64}$') { + throw 'Process capture did not return exact sanitized instance evidence.' } $liveReport = Join-Path $OutputDirectory 'live-pid.validation.json' Invoke-Validator ` - $positiveStatus $profile $liveReport $target.Id $false $sessionConfig + $positiveStatus $profile $liveReport $captureReport $false $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.' + if (-not ($liveResult.failures -match 'process instance.*remains alive') -or + $liveResult.capturedProcessInstanceExited) { + throw 'A live exact child instance was not rejected by the terminal validator.' } Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline @@ -269,7 +313,31 @@ try { Invoke-Validator ` $positiveStatus $profile ` (Join-Path $OutputDirectory 'unrelated-same-name.validation.json') ` - $target.Id $true $sessionConfig + $captureReport $true + + $reusedCapture = Join-Path $OutputDirectory 'reused-pid.capture.json' + $capturedIdentity = [string]$captured.processInstanceIdentity + $identitySeparator = $capturedIdentity.LastIndexOf(':') + $capturedStartValue = [uint64]::Parse( + $capturedIdentity.Substring($identitySeparator + 1), + [Globalization.CultureInfo]::InvariantCulture) + $reusedPriorIdentity = $capturedIdentity.Substring(0, $identitySeparator + 1) ` + + ($capturedStartValue + 1).ToString( + [Globalization.CultureInfo]::InvariantCulture) + Write-ProcessCapture ` + $reusedCapture ` + $unrelated.Id ` + $reusedPriorIdentity ` + $sessionConfig ` + ([string]$captured.commandLineFingerprintSha256) + $reusedReport = Join-Path $OutputDirectory 'reused-pid.validation.json' + Invoke-Validator $positiveStatus $profile $reusedReport $reusedCapture $true + $reusedResult = Get-Content -LiteralPath $reusedReport -Raw | ConvertFrom-Json + if (-not $reusedResult.capturedPidReused -or + -not $reusedResult.capturedProcessInstanceExited -or + -not $reusedResult.sessionConfigProcessExited) { + throw 'A reused PID was not distinguished from the exited captured instance.' + } } finally { Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline @@ -287,7 +355,10 @@ $summary = [ordered]@{ disconnectedReasonNegatives = 3 credentialStringFieldNegatives = $secretCases.Count exactPidCapture = $true - livePidRejected = $true + stableProcessInstanceCapture = $true + liveProcessInstanceRejected = $true + malformedProcessCaptureRejected = $true + injectedPidReuseIgnored = $true unrelatedSameNameIgnored = $true platform = if ($IsWindows) { 'windows' } else { 'linux' } } diff --git a/tools/test-campaign-la-script-safety.ps1 b/tools/test-campaign-la-script-safety.ps1 index 7124823c..d7f93c90 100644 --- a/tools/test-campaign-la-script-safety.ps1 +++ b/tools/test-campaign-la-script-safety.ps1 @@ -165,6 +165,84 @@ function Write-PayloadFile([string]$Root, [string]$Name, [string]$Content) { $null = New-Item -ItemType Directory -Force -Path $directory [IO.File]::WriteAllText($path, $Content, [Text.UTF8Encoding]::new($false)) } + +function Get-ZipUInt16([byte[]]$Bytes, [int]$Offset) { + return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8) +} +function Get-ZipUInt32([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 Test-ZipExecutableName([string]$Name) { + return $Name -cin @( + 'AcDream.App', 'acdream-headless', 'acdream-launcher', 'acdream-bake') +} +function Assert-ZipUnixMetadata([string]$Path) { + [byte[]]$bytes = [IO.File]::ReadAllBytes($Path) + $eocd = $bytes.Length - 22 + if ($eocd -lt 0 -or (Get-ZipUInt32 $bytes $eocd) -ne 0x06054b50 -or + (Get-ZipUInt16 $bytes ($eocd + 20)) -ne 0) { + throw "Fixture ZIP end record is invalid: $Path" + } + $entryCount = Get-ZipUInt16 $bytes ($eocd + 10) + $centralSize = Get-ZipUInt32 $bytes ($eocd + 12) + [uint64]$cursor = Get-ZipUInt32 $bytes ($eocd + 16) + $centralEnd = $cursor + $centralSize + if ($centralEnd -ne $eocd) { throw "Fixture ZIP central bounds are invalid: $Path" } + $rawModes = @{} + for ($index = 0; $index -lt $entryCount; $index++) { + if ($cursor + 46 -gt $centralEnd -or + (Get-ZipUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) { + throw "Fixture ZIP central entry is invalid: $Path" + } + if ($bytes[[int]$cursor + 5] -ne 3) { + throw "Fixture ZIP entry origin is not Unix: $Path" + } + $nameLength = Get-ZipUInt16 $bytes ([int]$cursor + 28) + $extraLength = Get-ZipUInt16 $bytes ([int]$cursor + 30) + $commentLength = Get-ZipUInt16 $bytes ([int]$cursor + 32) + $name = [Text.Encoding]::UTF8.GetString( + $bytes, + [int]$cursor + 46, + $nameLength) + $expectedMode = if (Test-ZipExecutableName $name) { 0x81ED } else { 0x81A4 } + $external = Get-ZipUInt32 $bytes ([int]$cursor + 38) + $expectedExternal = [uint32](([uint64]$expectedMode) -shl 16) + if ($external -ne $expectedExternal) { + throw "Fixture ZIP entry '$name' has wrong raw type/mode bits." + } + $rawModes[$name] = $expectedMode + $cursor += 46 + $nameLength + $extraLength + $commentLength + } + if ($cursor -ne $centralEnd) { throw "Fixture ZIP central length is invalid: $Path" } + + Add-Type -AssemblyName System.IO.Compression + $stream = [IO.File]::OpenRead($Path) + try { + $archive = [IO.Compression.ZipArchive]::new( + $stream, + [IO.Compression.ZipArchiveMode]::Read, + $false, + [Text.Encoding]::UTF8) + try { + if ($archive.Entries.Count -ne $rawModes.Count) { + throw "Fixture ZIP entry count changed through ZipArchive: $Path" + } + foreach ($entry in $archive.Entries) { + $mode = ($entry.ExternalAttributes -shr 16) -band 0xffff + if (-not $rawModes.ContainsKey($entry.FullName) -or + $mode -ne $rawModes[$entry.FullName]) { + throw "ZipArchive reports wrong type/mode for '$($entry.FullName)'." + } + } + } + finally { $archive.Dispose() } + } + finally { $stream.Dispose() } +} + $payloadRoot = Join-Path $OutputDirectory 'deterministic-payloads' $payloads = [ordered]@{ ClientWin = Join-Path $payloadRoot 'client-win' @@ -208,6 +286,9 @@ try { [Globalization.CultureInfo]::CurrentUICulture = $culture $destination = Join-Path $OutputDirectory "fixture-$cultureName" & $fixture -OutputDirectory $destination @fixtureParameters + foreach ($zip in @(Get-ChildItem -LiteralPath $destination -Filter '*.zip' -File -Recurse)) { + Assert-ZipUnixMetadata $zip.FullName + } $relativePaths = [string[]]@(Get-ChildItem -LiteralPath $destination -File -Recurse | Where-Object { $_.Name -ne 'fixture-report.json' } | ForEach-Object { @@ -235,9 +316,49 @@ $digestBytes = [Security.Cryptography.SHA256]::HashData( [Text.Encoding]::UTF8.GetBytes($firstInventory)) $deterministicDigest = [Convert]::ToHexString($digestBytes).ToLowerInvariant() $expectedCrossPlatformDigest = - '9c77b7204dd19e77fad62e572304d52e810afe2d0821c2ec57a692d27a0cc167' + 'cc58d5717de6686690b7f01213c9d52a99aef49447ff645e134f8c97ec8e3a76' if ($deterministicDigest -cne $expectedCrossPlatformDigest) { - throw 'Fixture artifact hashes differ from the pinned Windows/Linux contract.' + throw "Fixture artifact hashes differ from the pinned Windows/Linux contract: actual $deterministicDigest." +} + +$nativeExtractionModesValidated = $false +if ($IsLinux) { + $unzip = @(Get-Command unzip -CommandType Application -ErrorAction Stop)[0].Source + $extractClient = Join-Path $OutputDirectory 'native-extract-client' + $extractLauncher = Join-Path $OutputDirectory 'native-extract-launcher' + $null = New-Item -ItemType Directory -Path $extractClient + $null = New-Item -ItemType Directory -Path $extractLauncher + & $unzip -qq (Join-Path $OutputDirectory 'fixture-en-US/A/client-linux-x64.zip') ` + -d $extractClient + if ($LASTEXITCODE -ne 0) { throw 'Native client ZIP extraction failed.' } + & $unzip -qq (Join-Path $OutputDirectory 'fixture-en-US/A/launcher-linux-x64.zip') ` + -d $extractLauncher + if ($LASTEXITCODE -ne 0) { throw 'Native launcher ZIP extraction failed.' } + $mode755 = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor + [IO.UnixFileMode]::UserExecute -bor [IO.UnixFileMode]::GroupRead -bor + [IO.UnixFileMode]::GroupExecute -bor [IO.UnixFileMode]::OtherRead -bor + [IO.UnixFileMode]::OtherExecute + $mode644 = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor + [IO.UnixFileMode]::GroupRead -bor [IO.UnixFileMode]::OtherRead + foreach ($path in @( + (Join-Path $extractClient 'AcDream.App'), + (Join-Path $extractClient 'acdream-headless'), + (Join-Path $extractLauncher 'acdream-launcher'), + (Join-Path $extractLauncher 'acdream-bake'))) { + if ([IO.File]::GetUnixFileMode($path) -ne $mode755) { + throw "Native extraction did not retain mode 0755: $path" + } + } + foreach ($path in @( + (Join-Path $extractClient 'nested/I.txt'), + (Join-Path $extractClient 'campaign-la-fixture-release.txt'), + (Join-Path $extractLauncher 'nested/Z.txt'), + (Join-Path $extractLauncher 'campaign-la-fixture-release.txt'))) { + if ([IO.File]::GetUnixFileMode($path) -ne $mode644) { + throw "Native extraction did not retain mode 0644: $path" + } + } + $nativeExtractionModesValidated = $true } $summary = [ordered]@{ @@ -248,6 +369,9 @@ $summary = [ordered]@{ cultures = @('en-US', 'tr-TR', 'sv-SE') fixtureArtifactSetSha256 = $deterministicDigest crossPlatformExpectedSha256 = $expectedCrossPlatformDigest + zipOrigin = 'unix' + zipModesValidated = $true + nativeExtractionModesValidated = $nativeExtractionModesValidated } $summary | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1 index 1f93340a..ce89fcd2 100644 --- a/tools/test-campaign-la-session-status.ps1 +++ b/tools/test-campaign-la-session-status.ps1 @@ -14,10 +14,8 @@ 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]$ProcessCapturePath, [Parameter(Mandatory = $true)][string]$CredentialProfilePath, - [string]$SessionConfigPath, [string]$ExpectedSessionId, [string[]]$ExpectedPlugin = @(), [switch]$ExpectNoEnteredWorld, @@ -42,6 +40,87 @@ if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) { if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) { throw "Status file does not exist: $StatusFile" } +if (-not [IO.Path]::IsPathFullyQualified($ProcessCapturePath)) { + throw '-ProcessCapturePath must be absolute.' +} +$ProcessCapturePath = [IO.Path]::GetFullPath($ProcessCapturePath) +if (-not (Test-Path -LiteralPath $ProcessCapturePath -PathType Leaf)) { + throw "Process capture does not exist: $ProcessCapturePath" +} +$captureItem = Get-Item -LiteralPath $ProcessCapturePath -Force +if (($captureItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Process capture must not be a reparse point.' +} +$captureDocument = [Text.Json.JsonDocument]::Parse( + [IO.File]::ReadAllText($ProcessCapturePath)) +try { + $captureRoot = $captureDocument.RootElement + if ($captureRoot.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw 'Process capture root must be an object.' + } + $captureNames = @($captureRoot.EnumerateObject() | ForEach-Object { $_.Name }) + $expectedCaptureNames = @( + 'schemaVersion', 'kind', 'processId', 'processInstanceIdentity', + 'sessionId', 'sessionConfigPath', 'commandLineFingerprintSha256', + 'capturedUtc') + if ([string]::Join("`n", $captureNames) -cne + [string]::Join("`n", $expectedCaptureNames)) { + throw 'Process capture fields/order do not match schema v2.' + } + if ($captureRoot.GetProperty('schemaVersion').GetInt32() -ne 2 -or + $captureRoot.GetProperty('kind').GetString() -cne + 'campaign-la-session-process-capture') { + throw 'Process capture schema/kind is invalid.' + } + $capturedProcessId = $captureRoot.GetProperty('processId').GetInt32() + if ($capturedProcessId -le 0) { throw 'Process capture PID is invalid.' } + $capturedProcessIdentity = $captureRoot.GetProperty( + 'processInstanceIdentity').GetString() + $expectedIdentityPattern = if ($IsWindows) { + '^windows-creation-v1:[0-9]{15,19}$' + } else { '^linux-proc-start-v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9]+$' } + if ($capturedProcessIdentity -notmatch $expectedIdentityPattern) { + throw 'Process capture instance identity is invalid for this platform.' + } + $capturedSessionId = $captureRoot.GetProperty('sessionId').GetString() + if ([string]::IsNullOrWhiteSpace($capturedSessionId) -or + $capturedSessionId.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0) { + throw 'Process capture session id is invalid.' + } + $capturedSessionConfigPath = $captureRoot.GetProperty( + 'sessionConfigPath').GetString() + if (-not [IO.Path]::IsPathFullyQualified($capturedSessionConfigPath)) { + throw 'Process capture session-config path is not absolute.' + } + $normalizedCapturedConfigPath = [IO.Path]::GetFullPath( + $capturedSessionConfigPath) + if ($capturedSessionConfigPath -cne $normalizedCapturedConfigPath -or + [IO.Path]::GetFileName($capturedSessionConfigPath) -cne 'session.json' -or + [IO.Path]::GetFileName([IO.Path]::GetDirectoryName( + $capturedSessionConfigPath)) -cne $capturedSessionId) { + throw 'Process capture session-config path is not the exact normalized session path.' + } + $capturedCommandFingerprint = $captureRoot.GetProperty( + 'commandLineFingerprintSha256').GetString() + if ($capturedCommandFingerprint -cnotmatch '^[0-9a-f]{64}$') { + throw 'Process capture command-line fingerprint is invalid.' + } + $capturedUtcText = $captureRoot.GetProperty('capturedUtc').GetString() + $capturedUtc = [DateTimeOffset]::MinValue + if (-not [DateTimeOffset]::TryParseExact( + $capturedUtcText, + 'O', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::RoundtripKind, + [ref]$capturedUtc) -or $capturedUtc.Offset -ne [TimeSpan]::Zero) { + throw 'Process capture timestamp is not exact UTC round-trip form.' + } +} +finally { $captureDocument.Dispose() } +if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and + $ExpectedSessionId -cne $capturedSessionId) { + throw 'Process capture session id does not match -ExpectedSessionId.' +} if (-not [IO.Path]::IsPathFullyQualified($CredentialProfilePath)) { throw '-CredentialProfilePath must be absolute.' } @@ -82,12 +161,6 @@ elseif ($IsWindows) { } } 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" } @@ -402,27 +475,29 @@ for ($index = 0; $index -lt $eventNames.Count; $index++) { } $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) +$capturedState = $null do { - $expectedProcess = Get-Process -Id $ExpectedProcessId -ErrorAction SilentlyContinue - if ($null -eq $expectedProcess) { break } + $currentProcessIdentity = Get-CampaignLaProcessInstanceIdentity ` + -ProcessId $capturedProcessId + $correlations = @(Get-CampaignLaSessionProcessCorrelations) + $capturedState = Test-CampaignLaCapturedProcessState ` + -ProcessId $capturedProcessId ` + -ProcessInstanceIdentity $capturedProcessIdentity ` + -SessionConfigPath $capturedSessionConfigPath ` + -CurrentProcessInstanceIdentity $currentProcessIdentity ` + -Correlations $correlations + if (-not $capturedState.SameInstanceAlive -and + -not $capturedState.ExactConfigPathAlive) { + break + } Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $deadline) -if ($null -ne $expectedProcess) { - $failures.Add("expected launcher child PID $ExpectedProcessId remains alive") +if ($capturedState.SameInstanceAlive) { + $failures.Add( + "captured launcher child process instance PID $capturedProcessId remains alive") } - -$pathCorrelationChecked = -not [string]::IsNullOrWhiteSpace($SessionConfigPath) -if ($pathCorrelationChecked) { - $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) - do { - $correlated = @(Get-CampaignLaCorrelatedProcessIds $SessionConfigPath) - if ($correlated.Count -eq 0) { break } - Start-Sleep -Milliseconds 100 - } while ([DateTime]::UtcNow -lt $deadline) - if ($correlated.Count -gt 0) { - $failures.Add( - "session-config-correlated launcher child PID(s) remain: $($correlated -join ',')") - } +if ($capturedState.ExactConfigPathAlive) { + $failures.Add('a launcher child remains correlated to the exact session-config path') } $reportDirectory = Split-Path -Parent $ReportPath @@ -442,9 +517,12 @@ $report = [ordered]@{ eventNames = @($eventNames) loadedPluginCount = $loadedPlugins.Count terminalObserved = $terminalSeen - expectedProcessId = $ExpectedProcessId - processExited = ($null -eq $expectedProcess) - sessionConfigCorrelationChecked = $pathCorrelationChecked + capturedProcessId = $capturedProcessId + capturedProcessInstanceExited = (-not $capturedState.SameInstanceAlive) + capturedPidReused = [bool]$capturedState.PidReused + sessionConfigCorrelationChecked = $true + sessionConfigProcessExited = (-not $capturedState.ExactConfigPathAlive) + processCaptureSha256 = (Get-FileHash -LiteralPath $ProcessCapturePath -Algorithm SHA256).Hash.ToLowerInvariant() credentialPermissionsValidated = $true forbiddenCredentialValueCount = $forbiddenValues.Count failures = @($failures)