merge: Campaign LA LA11 - automated closeout review-closed

# Conflicts:
#	docs/plans/2026-08-14-launcher-campaign.md
This commit is contained in:
Erik 2026-08-15 02:07:39 +02:00
commit d39f3098d5
39 changed files with 6097 additions and 204 deletions

View file

@ -28,6 +28,8 @@
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />

View file

@ -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

View file

@ -322,7 +322,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
@ -350,6 +353,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,

View file

@ -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 | **DONE + MERGED 2026-08-14** | `6cfab727`, `aeac874d`, `1dd5706e`, merge `fe63ce18` | Initial retail/architecture review found 4 issues; first narrow re-review left 2 retry-transaction/order gaps; final narrow re-review PASS | Installed DAT enum table 5 proves `0x10000005 -> 0x21000004`, root `0x1000039A`, exact flat list/buttons/templates/dialog assets, and no viewport. Runtime remains the only selection owner; row sizing, modal priority/retry, restore ordering, reset/disposal, and explicit live-DAT skip/probe are covered. Branch full suite 13,796+5 skip; LA11 owns physical visual/live-ACE acceptance. |
| LA9 | **DONE + MERGED 2026-08-14** | `ff6ebb6a`, `3f688951`, `208a70ac`, merge `2198a0cc` | Initial integrity review found 5 issues; narrow re-review left one orphan-child publication race; final narrow re-review PASS | First-run installer validates four DATs, consumes strict v1 Bake JSONL, preserves/reverifies SHA+size+tool-version records, and co-publishes self-contained launcher+Bake. Cross-process install/publish locks plus durable nonce prevent post-recovery mutation across real parent-only hard kills on Windows/Linux. Branch full suite 13,799+4 skip; real retail-DAT bake remains LA11. |
| LA10 | **DONE + MERGED 2026-08-14** | `2d2a5b50`, `1955ca8a`, `09d84387`, merge `da4fb3de` | Initial architecture/security review found 10 crash, trust, integrity, cleanup, and lifecycle issues; first narrow re-review left one rollback-source P1; final narrow re-review PASS | Production feeds and redirects are HTTPS-only, fixture loopback trust is explicit, downloads and archives are bounded and verified, version activation and rollback are atomic, active sessions hold the cross-process update lease, and schema-v3 self-update recovery verifies every prior/replacement file before apply, rollback, or restart. Real Windows/Linux process tests cover kill boundaries, staging races, lease deferral, corrupt backups, junctions/symlinks, and fail-closed recovery. Branch gates: Core 302/302 and Launcher 29/29 on Windows/WSL, full Release 13,945+4 skip, win/linux self-contained publishes. Integrated LA0LA10 gate: 13,972+5 skip. |
| LA11 | — | | | |
| LA11 | **AUTOMATED CLOSEOUT REVIEW-CLOSED 2026-08-15 — USER GATE PENDING** | `f881e5b4`, `134edabe`, `accd01a0`, `9f9c1167`; merge pending | Initial dual-lens review found 7 startup/evidence/safety issues; first narrow re-review left 2 PID-reuse/ZIP-mode gaps; final narrow re-review PASS | Strict isolated roots and process-local feed override compose one exact launcher path graph. Windows targeted CTRL_BREAK is group-isolated and preserves stdin; exact-PID/start-identity status validation, credential-value scanning, deterministic Unix-mode A/B fixtures, Windows/native-Linux helper safety, and the exact AI operator script are implemented. Clean branch preflight passed 32/32 with 13,985 tests + 4 skips. No connected/UI/real-DAT row has run; campaign shipment and #397 closure remain pending the user gate. |

View file

@ -0,0 +1,631 @@
# 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 AI 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.
- `<ABSOLUTE_REPOSITORY_ROOT>`: a clean Campaign LA worktree at the exact commit
under test.
- `<ABSOLUTE_RETAIL_DAT_DIRECTORY>`: a read-only source containing
`client_portal.dat`, `client_cell_1.dat`, `client_highres.dat`, and
`client_local_English.dat`.
- `<ACE_PORT>`, `<LA11_SERVER>`, and `<LA11_ACCOUNT>`: 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.
- `<OBSERVER_CHARACTER>`: a second user-controlled character that can observe a
private `/tell` from each play mode.
- `<DISPOSABLE_CHARACTER>`: 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 AH. 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('<ABSOLUTE_REPOSITORY_ROOT>')
$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 `
-AllowedOutputRoot $Gate `
-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 | 515 min |
| Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 2060 min |
| Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 14 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 | 1025 min |
| Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 310 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 | 3590 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 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.
### 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 `
-AllowedOutputRoot $Gate `
-OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') `
-IncludeInstalledDat `
-InstalledDatDirectory '<ABSOLUTE_RETAIL_DAT_DIRECTORY>'
```
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: 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
`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 (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 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
directory. The generator 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, start this gate-only PID watcher immediately before
clicking Refresh/Play. It correlates only the unique isolated session-config
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 '<ROW>-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 '<probe|guiSelect|gui|headless>' `
-ProcessCapturePath $CapturePath `
-CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
-ExpectedSessionId $Capture.sessionId `
-ReportPath (Join-Path $Evidence '<ROW>-status.validation.json')
```
Add `-ExpectedPlugin acdream.smoke` to rows DF. 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 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, 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 AH
### 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 `<ABSOLUTE_RETAIL_DAT_DIRECTORY>` 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 30180 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: 45200 minutes including bake.
### B — server/account CRUD entirely through the UI
1. Add `<LA11_TEMP_SERVER>` at `127.0.0.1:<UNUSED_LOCAL_PORT>`, edit its name
and port, then remove it. Confirm Cancel/Escape makes no mutation.
2. Add `<LA11_SERVER>` at `127.0.0.1:<ACE_PORT>`.
3. Under it add `<LA11_TEMP_ACCOUNT>` 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 `<LA11_ACCOUNT>` 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: 1015 minutes.
### C — live character probe twice, no stale ACE session
1. Select `<LA11_ACCOUNT>`, 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 13 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: 510 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 <OBSERVER_CHARACTER>, LA11-D-<UNIQUE_NONSECRET_NONCE>`. 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: 510 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-<UNIQUE_NONSECRET_NONCE>`.
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: 510 minutes.
### F — headless, plugin/login command, and connected #397 acceptance
1. Change the same character to `headless`, retain `acdream.smoke`, and use
`LA11-F-<UNIQUE_NONSECRET_NONCE>`.
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: 510 minutes.
### G — disposable delete and restore
1. Launch `guiSelect` for `<DISPOSABLE_CHARACTER>`. 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/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
release. Validate with:
```powershell
pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
-StatusFile (Join-Path $WinCache 'launcher/sessions/<SESSION_ID>/status.jsonl') `
-Mode guiSelect `
-ProcessCapturePath (Join-Path $Evidence 'G-process.capture.json') `
-CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
-ExpectNoEnteredWorld `
-ExpectedSessionId '<SESSION_ID>' `
-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: 510 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: 1530 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('<ABSOLUTE_LINUX_REPOSITORY_PATH>')
$FixtureLinux = [IO.Path]::GetFullPath('<ABSOLUTE_LINUX_FIXTURE_PATH>')
$PayloadsLinux = [IO.Path]::GetFullPath('<ABSOLUTE_LINUX_PAYLOADS_PATH>')
$LinuxGate = [IO.Path]::GetFullPath('<NEW_ABSOLUTE_LINUX_GATE_ROOT>')
$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 `<ABSOLUTE_LINUX_RETAIL_DAT_DIRECTORY>`;
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`, 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.
5. **Headless:** configure `acdream.smoke` and
`/tell <OBSERVER_CHARACTER>, LA11-I-<UNIQUE_NONSECRET_NONCE>`, 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: 60220 minutes, dominated by the real bake.
## 7. Evidence, redaction, verdict, and cleanup
Expected evidence tree:
```text
logs/campaign-la-user-gate-<timestamp>/
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/*-process.capture.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
AI 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 `<DISPOSABLE_CHARACTER>` 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: 37 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.

View file

@ -331,7 +331,12 @@ 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. 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

View file

@ -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
/// <c>docs/ISSUES.md</c> 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.
/// </summary>
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;
}

View file

@ -7,9 +7,13 @@ namespace AcDream.Launcher.Core.Launching;
/// Deliberately carries no credential field — the password is a separate
/// transient parameter to <see cref="LauncherProcessSupervisor.Start"/>
/// 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
/// <paramref name="SupportsConsoleGracefulStop"/> 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.
/// </summary>
public sealed record LauncherProcessSpec(
string ExecutablePath,
IReadOnlyList<string> Arguments,
string? WorkingDirectory = null);
string? WorkingDirectory = null,
bool SupportsConsoleGracefulStop = true);

View file

@ -171,8 +171,8 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
/// <summary>
/// Requests a graceful stop — first
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/> (SIGINT
/// on Linux; a no-op on Windows today, see
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/>'s docs),
/// on Linux; targeted CTRL_BREAK_EVENT for supported Windows console
/// children),
/// then <see cref="ILauncherChildProcess.CloseMainWindow"/> — falling
/// back to <see cref="ILauncherChildProcess.Kill"/> if the process has
/// not exited within <paramref name="timeout"/>. A no-op if

View file

@ -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;
/// <summary>
/// Windows launcher child created without a shell as a true console process-
/// group leader. The native start is deliberately narrow: it exists only
/// because <see cref="ProcessStartInfo"/> does not expose
/// CREATE_NEW_PROCESS_GROUP while the launcher must retain redirected stdin.
/// </summary>
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);
}
}
/// <summary>
/// A process can be attached to only one console. Child creation and targeted
/// control-event attachment therefore share one process-wide gate.
/// </summary>
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<StartupInfoEx>(),
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<string> 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<SecurityAttributes>(),
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);
}

View file

@ -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)

View file

@ -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<SelfUpdateStartupResult> 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<int> 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)

View file

@ -483,6 +483,39 @@ public sealed class LauncherSelfUpdateManager
SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId));
}
/// <summary>
/// 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.
/// </summary>
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<SelfUpdatePlan> RollbackAwaitingConfirmationAsync(
string expectedTargetDirectory,
CancellationToken cancellationToken = default)

View file

@ -11,8 +11,9 @@ public interface IReleaseManifestClient
/// <summary>
/// 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.
/// </summary>
@ -74,6 +75,49 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
CreateRedirectDisabledHandler(),
timeout);
/// <summary>
/// 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.
/// </summary>
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,

View file

@ -28,6 +28,43 @@ public sealed class UpdateSessionBarrier
return new SessionLease(stream);
}
/// <summary>
/// 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.
/// </summary>
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(

View file

@ -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(

View file

@ -0,0 +1,255 @@
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
namespace AcDream.Launcher;
internal enum LauncherStartupMode
{
Desktop,
VerifyPublish,
SelfUpdateHelper,
SelfUpdateConfirmation,
}
/// <summary>
/// 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.
/// </summary>
internal sealed class LauncherStartupOptions
{
private readonly IReadOnlyList<string> _publicArguments;
private LauncherStartupOptions(
LauncherStartupMode mode,
ApplicationPathSet paths,
Uri updateManifestUri,
IReadOnlyList<string> publicArguments)
{
Mode = mode;
Paths = paths;
UpdateManifestUri = updateManifestUri;
_publicArguments = Array.AsReadOnly(publicArguments.ToArray());
}
internal LauncherStartupMode Mode { get; }
internal ApplicationPathSet Paths { get; }
internal Uri UpdateManifestUri { get; }
/// <summary>
/// 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.
/// </summary>
internal IReadOnlyList<string> PublicArguments => _publicArguments;
internal static LauncherStartupOptions Parse(
IReadOnlyList<string> arguments,
Func<ApplicationPathSet>? 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.");
}
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:
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<string> 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)
{
}
}

View file

@ -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<bool> hasRunningSessions,
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null)
Func<ClientVersionStore, string, ClientVersionResolution>? 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

View file

@ -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<App>()
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.");
}
}
}

View file

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

View file

@ -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;
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -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);
}

View file

@ -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)
@ -160,18 +162,51 @@ static async Task<int> 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;
}
static async Task<int> 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;
}

View file

@ -25,5 +25,17 @@
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
<!-- Build ordering only. The Windows CTRL_BREAK gate launches this
console fixture in two independent native process groups. -->
<ProjectReference Include="..\AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild\AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
<!-- Build ordering only. This WinExe fixture starts with no console and
proves the production Avalonia-parent CTRL_BREAK path. -->
<ProjectReference Include="..\AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent\AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
</ItemGroup>
</Project>

View file

@ -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<WindowsSystemChildProcess>(console);
Assert.IsType<SystemChildProcess>(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<string> 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,

View file

@ -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]

View file

@ -279,27 +279,244 @@ 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(
["--acdream-self-update-deferred-v1", .. publicArguments],
harness.Manager,
harness.Target,
harness.LauncherPath);
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],
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<LauncherUpdateException>(() =>
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<LauncherUpdateException>(() =>
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<LauncherUpdateException>(() =>
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.Empty(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<SelfUpdatePlan>(
await harness.Manager.LoadPendingAsync());
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
LauncherSelfUpdateBootstrap.HandleAsync(
[
LauncherSelfUpdateBootstrap.HelperArgument,
int.MaxValue.ToString(
System.Globalization.CultureInfo.InvariantCulture),
harness.Target,
staged.TransactionId,
],
harness.Manager,
harness.Target,
harness.LauncherPath));
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
LauncherSelfUpdateBootstrap.HandleAsync(
[LauncherSelfUpdateBootstrap.ConfirmArgument, staged.TransactionId],
harness.Manager,
harness.Target,
harness.LauncherPath));
SelfUpdatePlan awaiting = await harness.Manager.ApplyPendingAsync(harness.Target);
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
LauncherSelfUpdateBootstrap.HandleAsync(
[LauncherSelfUpdateBootstrap.ConfirmArgument, awaiting.TransactionId],
harness.Manager,
harness.Target,
Path.Combine(harness.Target, "spoof-launcher")));
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
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<LauncherUpdateException>(() =>
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<LauncherUpdateException>(() =>
LauncherSelfUpdateBootstrap.HandleAsync(
[LauncherSelfUpdateBootstrap.ConfirmArgument, rolledBack.TransactionId],
harness.Manager,
harness.Target,
harness.LauncherPath));
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
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<JsonObject>(JsonNode.Parse(
await File.ReadAllTextAsync(path)));
plan["state"] = state;
await File.WriteAllTextAsync(path, plan.ToJsonString());
}
private sealed class Harness : IDisposable

View file

@ -27,13 +27,20 @@ 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");
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);
@ -87,25 +94,35 @@ 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-*",
SearchOption.TopDirectoryOnly));
Assert.Null(await manager.LoadPendingAsync());
int replacementPid = ParsePid(await File.ReadAllTextAsync(launched));
int helperPid = int.Parse(
await File.ReadAllTextAsync(helperPidPath),
System.Globalization.CultureInfo.InvariantCulture);
string launchMarker = await File.ReadAllTextAsync(launched);
Assert.EndsWith(
Environment.NewLine + string.Join(Environment.NewLine, processLocalSuffix),
launchMarker,
StringComparison.Ordinal);
int replacementPid = ParsePid(launchMarker);
await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10));
await WaitForProcessExitAsync(helperPid, TimeSpan.FromSeconds(10));
Assert.False(File.Exists(helperPidPath));
if (OperatingSystem.IsLinux())
{
Assert.True(
@ -144,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<SelfUpdatePlan>(
@ -192,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));
@ -285,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));
@ -316,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();
@ -342,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),
@ -353,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<SelfUpdatePlan>(
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<SelfUpdatePlan>(await manager.LoadPendingAsync());
var environment = new Dictionary<string, string>
{
[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<string, string> 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();

View file

@ -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<LauncherUpdateException>(() =>
ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value)));
[Fact]
public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed()
{

View file

@ -0,0 +1,253 @@
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<LauncherStartupOptionsException>(() =>
LauncherStartupOptions.Parse(
["--verify-publish", "--cache-dir", Path.GetTempPath()]));
}
[Theory]
[MemberData(nameof(InvalidArguments))]
public void RejectsInvalidPublicArguments(string[] arguments)
{
Assert.Throws<LauncherStartupOptionsException>(() =>
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);
}
[Fact]
public void LegacyDeferredSelfUpdatePrefixIsRejectedAsUntrustedInput()
{
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",
];
Assert.Throws<LauncherStartupOptionsException>(() =>
LauncherStartupOptions.Parse(
["--acdream-self-update-deferred-v1", .. suffix],
() => throw new InvalidOperationException(
"canonical path resolver was touched")));
}
[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<InvalidOperationException>(() =>
Program.RequireUnchangedPublicArguments(
options,
new SelfUpdateStartupResult(
false,
0,
["--update-manifest-uri", "https://other.example.test/manifest.json"])));
}
public static TheoryData<string[]> InvalidArguments()
{
string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11"));
var data = new TheoryData<string[]>();
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", "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(
[
"--update-manifest-uri", "https://example.test/a",
"--update-manifest-uri", "https://example.test/b",
]);
return data;
}
}

View file

@ -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));
}
}

View file

@ -0,0 +1,232 @@
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()
$correlations = [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
$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)) {
$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)) {
$configPath = [IO.Path]::GetFullPath($value)
$correlations.Add([pscustomobject]@{
ProcessId = [int]$candidate.ProcessId
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
if (-not [int]::TryParse(
$leaf,
[Globalization.NumberStyles]::None,
[Globalization.CultureInfo]::InvariantCulture,
[ref]$processId)) {
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])) {
$configPath = [IO.Path]::GetFullPath($arguments[$index + 1])
$correlations.Add([pscustomobject]@{
ProcessId = $processId
ProcessInstanceIdentity = $identityBefore
SessionConfigPath = $configPath
CommandLineFingerprintSha256 =
Get-CampaignLaCommandLineFingerprint `
$arguments[0] $arguments[$index] $configPath
})
}
}
}
catch [IO.IOException] {
# A process may exit between /proc enumeration and either 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 @($correlations)
}
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 }
$processIds = [Collections.Generic.HashSet[int]]::new()
foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) {
if ([string]::Equals(
$candidate.SessionConfigPath,
$SessionConfigPath,
$comparison)) {
$null = $processIds.Add([int]$candidate.ProcessId)
}
}
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
}
}

View file

@ -0,0 +1,117 @@
<#
.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)
$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 = 2
kind = 'campaign-la-session-process-capture'
processId = [int]$correlations[0].ProcessId
processInstanceIdentity = $processIdentity
sessionId = [IO.Path]::GetFileName(
[IO.Path]::GetDirectoryName($SessionConfigPath))
sessionConfigPath = $SessionConfigPath
commandLineFingerprintSha256 = $commandFingerprint
capturedUtc = [DateTime]::UtcNow.ToString('O')
}
$report | ConvertTo-Json -Depth 3 |
Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM
Write-Host "Campaign LA process capture: $ReportPath"

View file

@ -0,0 +1,500 @@
<#
.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))
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
$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"
}
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) {
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-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) {
if (@(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0) {
throw '-OutputDirectory must not already contain files.'
}
}
else { $null = New-Item -ItemType Directory -Path $OutputDirectory }
Assert-NoReparseAncestry $OutputDirectory 'Output directory'
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 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 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)
$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,
[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 {
$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)"
}
}
[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) -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
$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
$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() }
Set-DeterministicZipHostPlatform $Destination
}
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"
}
}
[IO.File]::WriteAllText(
(Join-Path $releaseRoot 'manifest.json'),
($manifest | ConvertTo-Json -Depth 8 -Compress),
[Text.UTF8Encoding]::new($false))
}
[IO.File]::WriteAllText(
(Join-Path $OutputDirectory 'active-release.txt'),
'A',
[Text.Encoding]::ASCII)
$server = @'
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Root,
[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()
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()
$servedRequests++
}
if ($MaximumRequests -gt 0 -and $servedRequests -ge $MaximumRequests) {
break
}
}
}
finally { $listener.Close() }
'@
[IO.File]::WriteAllText(
(Join-Path $OutputDirectory 'serve-fixture.ps1'),
$server.Replace("`r`n", "`n"),
[Text.UTF8Encoding]::new($false))
$selector = @'
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][ValidateSet('A', 'B')][string]$Release,
[string]$Root = $PSScriptRoot
)
Set-StrictMode -Version Latest
$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"
'@
[IO.File]::WriteAllText(
(Join-Path $OutputDirectory 'set-active-release.ps1'),
$selector.Replace("`r`n", "`n"),
[Text.UTF8Encoding]::new($false))
$inventoryPaths = [string[]]@(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
Where-Object { $_.Name -ne 'fixture-report.json' } |
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 = $_
size = $item.Length
sha256 = (Get-FileHash -LiteralPath $fullPath -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"

View file

@ -0,0 +1,530 @@
<#
.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-<timestamp>
directory. Use -DryRun to emit the complete command matrix without
executing it.
#>
[CmdletBinding()]
param(
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
[Parameter(Mandatory = $true)][string]$AllowedOutputRoot,
[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"
}
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)) {
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 $AllowedOutputRoot "campaign-la-preflight-$stamp"
}
elseif (-not [IO.Path]::IsPathFullyQualified($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 -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
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)(--password|-password)(\s+|=)([^\s"'']+)',
'$1$2<redacted>')
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)\b(password|passwd|secret|token|credential|api[_-]?key)(\s*[:=]\s*)([^\s,;]+)',
'$1$2<redacted>')
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)(https?://)[^/\s:@]+:[^@\s/]+@',
'$1<redacted>@')
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)([?&](?:token|secret|password|credential|api[_-]?key)=)[^&\s]+',
'$1<redacted>')
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")',
'$1<redacted>$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 ($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
}
$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 = '<internal contract check>'; 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 = '<internal contract check>'
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-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')
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')
}
$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" @(
'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) {
[string[]]$artifactPaths = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } |
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 = $_
size = $item.Length
sha256 = (Get-FileHash -LiteralPath $fullPath -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
allowedOutputRoot = $AllowedOutputRoot
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
inheritedAcdreamEnvironmentCleared = $true
inheritedEnvironmentValuesRead = $false
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 }
}

View file

@ -0,0 +1,367 @@
<#
.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'
. (Join-Path $Repository 'tools/CampaignLaProcessCorrelation.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,
[string]$ProcessCapture,
[bool]$ShouldPass) {
$arguments = [Collections.Generic.List[string]]::new()
foreach ($value in @(
'-NoProfile', '-File', $validator,
'-StatusFile', $Status,
'-Mode', 'gui',
'-ProcessCapturePath', $ProcessCapture,
'-CredentialProfilePath', $Profile,
'-ExpectedPlugin', 'smoke',
'-AllowPluginFailure',
'-AllowLoginCommandFailure',
'-ReportPath', $Report)) {
$arguments.Add($value)
}
$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"
}
}
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')
$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()
$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') `
$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)
$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 $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."
}
}
$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 $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."
}
}
$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))
}
$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)
$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-campaign-la-process', '--config', $unrelatedConfig,
$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 -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 $captureReport $false
$liveResult = Get-Content -LiteralPath $liveReport -Raw | ConvertFrom-Json
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
$target.WaitForExit()
Invoke-Validator `
$positiveStatus $profile `
(Join-Path $OutputDirectory 'unrelated-same-name.validation.json') `
$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
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
stableProcessInstanceCapture = $true
liveProcessInstanceRejected = $true
malformedProcessCaptureRejected = $true
injectedPidReuseIgnored = $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"

View file

@ -0,0 +1,378 @@
<#
.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))
}
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'
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
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 {
[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 =
'cc58d5717de6686690b7f01213c9d52a99aef49447ff645e134f8c97ec8e3a76'
if ($deterministicDigest -cne $expectedCrossPlatformDigest) {
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]@{
schemaVersion = 1
kind = 'campaign-la-script-safety-tests'
success = $true
negativeCases = $negativeCount
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
Write-Host "Campaign LA script safety tests: $OutputDirectory"

View file

@ -0,0 +1,536 @@
<#
.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,
[Parameter(Mandatory = $true)][string]$ProcessCapturePath,
[Parameter(Mandatory = $true)][string]$CredentialProfilePath,
[string]$ExpectedSessionId,
[string[]]$ExpectedPlugin = @(),
[switch]$ExpectNoEnteredWorld,
[switch]$AllowPluginFailure,
[switch]$AllowLoginCommandFailure,
[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.'
}
. (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($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.'
}
$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 ([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
$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()
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
}
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'
}
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) {
throw 'object contains duplicate fields'
}
$eventName = Assert-String $root 'e'
if (-not $exactFields.ContainsKey($eventName)) {
throw 'event name 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' {
$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'
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 does not match mode '$Mode'"
}
$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")
}
}
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
$capturedState = $null
do {
$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 ($capturedState.SameInstanceAlive) {
$failures.Add(
"captured launcher child process instance PID $capturedProcessId remains alive")
}
if ($capturedState.ExactConfigPathAlive) {
$failures.Add('a launcher child remains correlated to the exact session-config path')
}
$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
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)
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
}