merge: Campaign LA LA11 - automated closeout review-closed
# Conflicts: # docs/plans/2026-08-14-launcher-campaign.md
This commit is contained in:
commit
d39f3098d5
39 changed files with 6097 additions and 204 deletions
|
|
@ -28,6 +28,8 @@
|
||||||
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
<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.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.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.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
|
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
|
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -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
|
## #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
|
**Severity:** MODERATE (a hard-killed `AcDream.Headless` leaves the ACE
|
||||||
account session stuck for several minutes — a documented project landmine;
|
account session stuck for several minutes — a documented project landmine;
|
||||||
see CLAUDE.md "Logout-before-reconnect")
|
see CLAUDE.md "Logout-before-reconnect")
|
||||||
**Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3)
|
**Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3)
|
||||||
**Component:** Launcher.Core / process supervision
|
**Component:** Launcher.Core / process supervision
|
||||||
|
|
||||||
**Description.** `LauncherProcessSupervisor.Stop` now attempts a graceful
|
**Implementation checkpoint.** `LauncherProcessSupervisor.Stop` attempts
|
||||||
stop signal (`ILauncherChildProcess.TryRequestGracefulStop`) BEFORE
|
`ILauncherChildProcess.TryRequestGracefulStop` before `CloseMainWindow` and
|
||||||
`CloseMainWindow`. On Linux this sends `SIGINT` via a `libc` P/Invoke
|
the timeout/kill fallback. Linux retains its K4-proven targeted `SIGINT`.
|
||||||
(`kill(pid, 2)`), which the K4-proven headless host already turns into an
|
On Windows, console-capable launcher specs now use a narrow no-shell
|
||||||
ACE-confirmed graceful logout. On Windows there is no equivalent today for a
|
`CreateProcessW` seam with `CREATE_NEW_PROCESS_GROUP`, a suspended start, and
|
||||||
console process with no message-pump window: `CloseMainWindow` is a no-op
|
an explicit inherited-handle list that preserves only redirected stdin plus
|
||||||
for a console host (there is no `HWND` to target), and
|
stdout/stderr. A consoleless Avalonia parent briefly allocates and hides a
|
||||||
`GenerateConsoleCtrlEvent` cannot usefully target an arbitrary child process
|
console for the creation transaction, detaches after the new group inherits
|
||||||
today — Windows delivers console control events to every process attached
|
it, and later attaches only long enough to send
|
||||||
to the SAME console as the calling process, so an unscoped call would also
|
`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)`. Each such
|
||||||
signal the launcher itself (and anything else sharing that console), not
|
child is therefore both the root of its own process group and, for the normal
|
||||||
just the intended child. `TryRequestGracefulStop` therefore returns `false`
|
Explorer-launched case, attached to its own console. Graphical children opt
|
||||||
on Windows unconditionally, and `Stop` degrades straight to `CloseMainWindow`
|
out and retain the ordinary `Process`/`WM_CLOSE` path.
|
||||||
(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.
|
|
||||||
|
|
||||||
**Known fix direction (not yet implemented).** Spawn the Windows child with
|
Two real Windows fixture gates cover both a console parent and a consoleless
|
||||||
the `CREATE_NEW_PROCESS_GROUP` creation flag (available via a native
|
WinExe parent. They prove exact complex argv, redirected stdin, receipt of a
|
||||||
`CreateProcess` call or by setting it on the `ProcessStartInfo`/`Process`
|
targeted CTRL_BREAK marker, exit code 0 before timeout, no supervisor `Kill`,
|
||||||
plumbing in `SystemChildProcess`) so the child gets its own console process
|
and a sibling process group that remains running until it receives its own
|
||||||
group, detached from the launcher's own group. Then
|
targeted break. Safe-handle cleanup, early-failure termination, and the
|
||||||
`TryRequestGracefulStop` on Windows calls
|
Linux SIGINT gate remain covered by the Launcher.Core suite.
|
||||||
`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."
|
|
||||||
|
|
||||||
**Acceptance for closing this issue:** `SystemChildProcess` spawns Windows
|
**Acceptance for closing this issue:** automated process-group and targeted-
|
||||||
children with `CREATE_NEW_PROCESS_GROUP`; `TryRequestGracefulStop` sends
|
signal coverage is complete. Keep the issue IN-PROGRESS until the LA11 live
|
||||||
`CTRL_BREAK_EVENT` to that child's process group on Windows; a live
|
connected row proves `AcDream.Headless` exits gracefully and ACE clears the
|
||||||
connected gate proves `AcDream.Headless` exits gracefully (ACE clears the
|
session immediately (not after the ~3-minute stale-session window) when
|
||||||
session immediately, not after the ~3-minute stale-session window) when
|
stopped through `LauncherProcessSupervisor.Stop` on Windows, matching the
|
||||||
stopped via `LauncherProcessSupervisor.Stop` on Windows, matching the
|
|
||||||
Linux SIGINT behavior.
|
Linux SIGINT behavior.
|
||||||
|
|
||||||
## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click
|
## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,10 @@ src/
|
||||||
|
|
||||||
AcDream.Launcher.Core/ BCL-only launcher state/orchestration owner
|
AcDream.Launcher.Core/ BCL-only launcher state/orchestration owner
|
||||||
Profiles/ -> sole credential/profile document + CRUD 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
|
Status/ -> incremental host-status parsing/tailing
|
||||||
Orchestration/ -> immutable UI snapshots, typed actions,
|
Orchestration/ -> immutable UI snapshots, typed actions,
|
||||||
capability gates, and running-session lifetime
|
capability gates, and running-session lifetime
|
||||||
|
|
@ -350,6 +353,14 @@ src/
|
||||||
-> references Platform only; no Avalonia or game-host dependency
|
-> references Platform only; no Avalonia or game-host dependency
|
||||||
|
|
||||||
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
|
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,
|
ViewModels/ -> thin MVVM projection over Launcher.Core,
|
||||||
including the first-run DAT/bake wizard and
|
including the first-run DAT/bake wizard and
|
||||||
nonfatal startup/manual update state, actions,
|
nonfatal startup/manual update state, actions,
|
||||||
|
|
|
||||||
|
|
@ -697,7 +697,10 @@ forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions.
|
||||||
|
|
||||||
## LA11 — closeout
|
## 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
|
covering: all three launch modes vs local ACE, probe round-trip ×2 (no
|
||||||
lingering session), char-select visual matrix + delete flow, login-commands
|
lingering session), char-select visual matrix + delete flow, login-commands
|
||||||
+ plugin behavior on both hosts, add-server/add-account purely in UI,
|
+ 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. |
|
| 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. |
|
| 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 LA0–LA10 gate: 13,972+5 skip. |
|
| 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 LA0–LA10 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 A–I 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. |
|
||||||
|
|
|
||||||
631
docs/research/2026-08-14-campaign-la-test-script.md
Normal file
631
docs/research/2026-08-14-campaign-la-test-script.md
Normal 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 A–I are deliberately manual and serial:
|
||||||
|
they use real retail DATs, a local ACE server, user-entered credentials, and
|
||||||
|
visual judgment that automation cannot supply.
|
||||||
|
|
||||||
|
## 1. Safety boundary and required inputs
|
||||||
|
|
||||||
|
Use placeholders throughout; never paste a password into a terminal, this
|
||||||
|
document, a screenshot, or a gate report.
|
||||||
|
|
||||||
|
- `<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 A–H. Ubuntu x64 with PowerShell 7 and a Linux
|
||||||
|
desktop/WSLg is required for row I. The Avalonia launcher is supported on
|
||||||
|
Linux; `gui` and `guiSelect` **client** actions must remain disabled with the
|
||||||
|
Modern Runtime Slice-L explanation.
|
||||||
|
|
||||||
|
Close every unrelated `AcDream.App`, `acdream-headless`, and acdream launcher
|
||||||
|
before starting. Do not run another acdream gate in parallel. All generated
|
||||||
|
files must stay below one new gate directory; the canonical `%APPDATA%`,
|
||||||
|
`%LOCALAPPDATA%`, and XDG acdream roots are out of scope.
|
||||||
|
|
||||||
|
## 2. Automated preflight — no UI, connection, credential, or bake
|
||||||
|
|
||||||
|
In PowerShell 7 on Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$Repo = [IO.Path]::GetFullPath('<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 | 5–15 min |
|
||||||
|
| Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 20–60 min |
|
||||||
|
| Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 1–4 min |
|
||||||
|
| Windows | canonical portable project build/test closure plus Headless `--help` and empty-config `validate` from `headless-portability.yml` | every project/CLI row exits 0 | 10–25 min |
|
||||||
|
| Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 3–10 min |
|
||||||
|
| Windows | native launcher `--verify-publish` and bake `--help` with bogus `DOTNET_ROOT*` | both exit 0 | <1 min |
|
||||||
|
| Ubuntu/WSL | run the same helper natively from the Linux path to the worktree | Linux RID report and every row exit 0 | 35–90 min |
|
||||||
|
|
||||||
|
`report.json` records the tested HEAD/dirty state, OS/RID, exact commands,
|
||||||
|
durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight
|
||||||
|
plans 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 D–F. The validator enforces exact
|
||||||
|
v1 fields **and property order**, one session id, UTC monotonic timestamps,
|
||||||
|
mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login
|
||||||
|
command failure, exact terminal `disconnected.reason == stopped`, credential
|
||||||
|
redaction, and that exact captured 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 A–H
|
||||||
|
|
||||||
|
### A — isolated first run, real DAT bake, and release-A client baseline
|
||||||
|
|
||||||
|
1. Confirm the launcher opens First-run setup and all launch buttons are
|
||||||
|
unavailable. Save a redacted screenshot as `A-first-run-required.png`.
|
||||||
|
2. Enter `<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 30–180 minutes. Confirm every phase reaches **Completed** and
|
||||||
|
the status says `Client content installed and verified. Launch is enabled.`
|
||||||
|
4. Open **Check for updates**. Confirm available release A, click **Install
|
||||||
|
client**, and wait for `Client update installed and activated.` Do not stage
|
||||||
|
launcher A; the test launcher already has version A.
|
||||||
|
5. Confirm these exact isolated artifacts exist and no `.previous-install`
|
||||||
|
remains after success:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$RequiredA = @(
|
||||||
|
(Join-Path $WinData 'install.json'),
|
||||||
|
(Join-Path $WinData 'pak/acdream.pak'),
|
||||||
|
(Join-Path $WinData 'app/current.json'))
|
||||||
|
foreach ($Path in $RequiredA) {
|
||||||
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing $Path" }
|
||||||
|
}
|
||||||
|
$RequiredA | ForEach-Object {
|
||||||
|
$Item = Get-Item -LiteralPath $_
|
||||||
|
[ordered]@{
|
||||||
|
name = $Item.Name
|
||||||
|
size = $Item.Length
|
||||||
|
sha256 = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
}
|
||||||
|
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'A-install-hashes.json')
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not copy `install.json` into shared evidence because it records the local DAT
|
||||||
|
path. Expected time: 45–200 minutes including bake.
|
||||||
|
|
||||||
|
### B — server/account CRUD entirely through the UI
|
||||||
|
|
||||||
|
1. Add `<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: 10–15 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 1–3 immediately, producing a different session id,
|
||||||
|
`C-probe-2-status.validation.json`, and `C-probe-2-ace-cleared.png`.
|
||||||
|
5. Confirm Refresh is re-enabled and no `acdream-headless` process remains.
|
||||||
|
|
||||||
|
Expected time: 5–10 minutes. A stale ACE account or timeout is a gate failure;
|
||||||
|
do not wait three minutes and call the next attempt a pass.
|
||||||
|
|
||||||
|
### D — `guiSelect`, retail character screen, plugin, and login command
|
||||||
|
|
||||||
|
1. Select one non-disposable roster character. Set its mode to `guiSelect`,
|
||||||
|
Plugins to exactly `acdream.smoke`, and its one login command to
|
||||||
|
`/tell <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: 5–10 minutes.
|
||||||
|
|
||||||
|
### E — direct `gui`, plugin, and login command
|
||||||
|
|
||||||
|
1. Change the same character to `gui`, retain `acdream.smoke`, and change the
|
||||||
|
command nonce to `LA11-E-<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: 5–10 minutes.
|
||||||
|
|
||||||
|
### F — headless, plugin/login command, and connected #397 acceptance
|
||||||
|
|
||||||
|
1. Change the same character to `headless`, retain `acdream.smoke`, and use
|
||||||
|
`LA11-F-<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: 5–10 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: 5–10 minutes.
|
||||||
|
|
||||||
|
### H — local A→B client update, active-session refusal, rollback, self-update
|
||||||
|
|
||||||
|
1. Record release A's `app/current.json`. Start one headless session and wait
|
||||||
|
for `enteredWorld`.
|
||||||
|
2. Switch the fixture atomically to B:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File (Join-Path $Fixture 'set-active-release.ps1') `
|
||||||
|
-Release B -Root $Fixture
|
||||||
|
if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionB) {
|
||||||
|
throw 'Stop: fixture did not switch to B.'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Open **Check for updates** and **Check again**. While the session is active,
|
||||||
|
confirm Install client, Rollback client, and Stage launcher are disabled or
|
||||||
|
refuse without changing `app/current.json`. Save `H-active-refusal.png`.
|
||||||
|
4. Stop the headless session and validate its graceful status. Install client
|
||||||
|
B. Confirm `app/current.json` names B, A is previous, all installed-file
|
||||||
|
hashes verify, and new sessions resolve from the B directory.
|
||||||
|
5. Click **Rollback client**. Confirm A becomes current and B becomes previous.
|
||||||
|
Check again and install B once more, leaving B current. Save sanitized copies
|
||||||
|
of the three pointer states as `H-pointer-a.json`, `H-pointer-b.json`, and
|
||||||
|
`H-pointer-rollback-a.json`; they contain no credentials.
|
||||||
|
6. Click **Stage launcher**. Confirm restart is required, then close the
|
||||||
|
launcher normally. The copied helper must apply B and restart the launcher
|
||||||
|
with the same config/data/cache/feed suffix.
|
||||||
|
7. Confirm profiles, install record, and update state still come from the
|
||||||
|
isolated roots; `campaign-la-fixture-release.txt` beside the relaunched
|
||||||
|
executable says `release=B`; `launcher-update/pending.json` is gone; and no
|
||||||
|
transaction backup remains. Check again and confirm launcher B is current.
|
||||||
|
Save `H-self-update-confirmed.png` and a hash-only post-state inventory.
|
||||||
|
|
||||||
|
Never edit a manifest to force this row and never point the launcher at a
|
||||||
|
non-loopback HTTP endpoint. Expected time: 15–30 minutes.
|
||||||
|
|
||||||
|
## 6. Row I — native Ubuntu/WSL launcher, XDG-shaped isolated roots
|
||||||
|
|
||||||
|
Stop the Windows launcher and fixture server only after every Windows session
|
||||||
|
is terminal:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
if (-not $FixtureServer.HasExited) {
|
||||||
|
$FixtureServer.Kill()
|
||||||
|
$FixtureServer.WaitForExit()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In a native Ubuntu/WSL PowerShell 7 terminal, set Linux paths. The repository
|
||||||
|
and fixture may be read from a mounted Windows path, but roots must live on the
|
||||||
|
Linux filesystem. Run the generated server natively so its `127.0.0.1` URLs
|
||||||
|
cannot escape the Linux environment:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$RepoLinux = [IO.Path]::GetFullPath('<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: 60–220 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
|
||||||
|
A–I as `pass`, `fail`, or `notApplicable`, a short redacted note per row, and
|
||||||
|
the user's overall verdict. Row I is not applicable only when no native
|
||||||
|
Ubuntu/WSL desktop is available; it blocks Campaign LA shipping under the
|
||||||
|
current Linux requirement, so it cannot be silently omitted.
|
||||||
|
|
||||||
|
Cleanup is graceful-first and serial:
|
||||||
|
|
||||||
|
1. Restore `<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: 3–7 hours, primarily the two real DAT bakes and full serial
|
||||||
|
test suites. A failure stops the current row; restore/stop/collect evidence,
|
||||||
|
then diagnose before advancing. Do not mark LA11, Campaign LA, or #397 shipped
|
||||||
|
until the user accepts the complete applicable matrix.
|
||||||
|
|
@ -331,7 +331,12 @@ the process supervisor executes.
|
||||||
Campaign V); visuals settle at the user gate.
|
Campaign V); visuals settle at the user gate.
|
||||||
- **Headless plugin host:** fixture plugin in the Headless suite
|
- **Headless plugin host:** fixture plugin in the Headless suite
|
||||||
(load, capability flag, teardown).
|
(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 →
|
(gui / guiSelect / headless), the character probe (fresh account →
|
||||||
refresh → roster appears, and repeated probes leaving no stale ACE
|
refresh → roster appears, and repeated probes leaving no stale ACE
|
||||||
session), clean-profile first-run wizard end-to-end, staged-manifest
|
session), clean-profile first-run wizard end-to-end, staged-manifest
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,10 @@ public interface ILauncherChildProcess : IDisposable
|
||||||
/// documented project landmine; see CLAUDE.md
|
/// documented project landmine; see CLAUDE.md
|
||||||
/// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved
|
/// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved
|
||||||
/// the headless host's SIGINT handler produces an ACE-confirmed
|
/// the headless host's SIGINT handler produces an ACE-confirmed
|
||||||
/// graceful logout). On Windows there is no reliable cross-console
|
/// graceful logout). On Windows, console-capable children are started
|
||||||
/// mechanism for an arbitrary no-window child process today — see
|
/// as distinct process-group leaders and receive a targeted
|
||||||
/// <c>docs/ISSUES.md</c> for the tracked gap and fix direction; this
|
/// CTRL_BREAK_EVENT. Returns true only when the signal was actually
|
||||||
/// returns false there. Returns true only when the signal was
|
/// delivered; never throws.
|
||||||
/// actually delivered; never throws.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool TryRequestGracefulStop();
|
bool TryRequestGracefulStop();
|
||||||
|
|
||||||
|
|
@ -72,7 +71,9 @@ public interface ILauncherChildProcessFactory
|
||||||
public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory
|
public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory
|
||||||
{
|
{
|
||||||
public ILauncherChildProcess Create(LauncherProcessSpec spec) =>
|
public ILauncherChildProcess Create(LauncherProcessSpec spec) =>
|
||||||
new SystemChildProcess(spec);
|
OperatingSystem.IsWindows() && spec.SupportsConsoleGracefulStop
|
||||||
|
? new WindowsSystemChildProcess(spec)
|
||||||
|
: new SystemChildProcess(spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed partial class SystemChildProcess : ILauncherChildProcess
|
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 static partial int kill(int pid, int sig);
|
||||||
|
|
||||||
private readonly Process _process;
|
private readonly Process _process;
|
||||||
|
private readonly bool _supportsConsoleGracefulStop;
|
||||||
private bool _raisingEnabled;
|
private bool _raisingEnabled;
|
||||||
|
|
||||||
internal SystemChildProcess(LauncherProcessSpec spec)
|
internal SystemChildProcess(LauncherProcessSpec spec)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(spec);
|
ArgumentNullException.ThrowIfNull(spec);
|
||||||
|
_supportsConsoleGracefulStop = spec.SupportsConsoleGracefulStop;
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
var startInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
|
|
@ -130,11 +133,11 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess
|
||||||
|
|
||||||
public bool TryRequestGracefulStop()
|
public bool TryRequestGracefulStop()
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsLinux())
|
if (!OperatingSystem.IsLinux() || !_supportsConsoleGracefulStop)
|
||||||
{
|
{
|
||||||
// No reliable cross-console mechanism exists for an
|
// Windows console-capable children use
|
||||||
// arbitrary no-window Windows child process — tracked gap,
|
// WindowsSystemChildProcess. Graphical/non-console children
|
||||||
// see docs/ISSUES.md.
|
// deliberately retain the Process/WM_CLOSE path.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,13 @@ namespace AcDream.Launcher.Core.Launching;
|
||||||
/// Deliberately carries no credential field — the password is a separate
|
/// Deliberately carries no credential field — the password is a separate
|
||||||
/// transient parameter to <see cref="LauncherProcessSupervisor.Start"/>
|
/// transient parameter to <see cref="LauncherProcessSupervisor.Start"/>
|
||||||
/// that flows only to the child's stdin, never into this spec, an
|
/// 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>
|
/// </summary>
|
||||||
public sealed record LauncherProcessSpec(
|
public sealed record LauncherProcessSpec(
|
||||||
string ExecutablePath,
|
string ExecutablePath,
|
||||||
IReadOnlyList<string> Arguments,
|
IReadOnlyList<string> Arguments,
|
||||||
string? WorkingDirectory = null);
|
string? WorkingDirectory = null,
|
||||||
|
bool SupportsConsoleGracefulStop = true);
|
||||||
|
|
|
||||||
|
|
@ -171,8 +171,8 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Requests a graceful stop — first
|
/// Requests a graceful stop — first
|
||||||
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/> (SIGINT
|
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/> (SIGINT
|
||||||
/// on Linux; a no-op on Windows today, see
|
/// on Linux; targeted CTRL_BREAK_EVENT for supported Windows console
|
||||||
/// <see cref="ILauncherChildProcess.TryRequestGracefulStop"/>'s docs),
|
/// children),
|
||||||
/// then <see cref="ILauncherChildProcess.CloseMainWindow"/> — falling
|
/// then <see cref="ILauncherChildProcess.CloseMainWindow"/> — falling
|
||||||
/// back to <see cref="ILauncherChildProcess.Kill"/> if the process has
|
/// back to <see cref="ILauncherChildProcess.Kill"/> if the process has
|
||||||
/// not exited within <paramref name="timeout"/>. A no-op if
|
/// not exited within <paramref name="timeout"/>. A no-op if
|
||||||
|
|
|
||||||
843
src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs
Normal file
843
src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -104,7 +104,8 @@ public sealed class LauncherExecutableSet
|
||||||
: new LauncherProcessSpec(
|
: new LauncherProcessSpec(
|
||||||
paths.GraphicalHostPath,
|
paths.GraphicalHostPath,
|
||||||
["--session-config", configFilePath],
|
["--session-config", configFilePath],
|
||||||
paths.WorkingDirectory);
|
paths.WorkingDirectory,
|
||||||
|
SupportsConsoleGracefulStop: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
|
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,9 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
{
|
{
|
||||||
public const string HelperArgument = "--acdream-self-update-helper-v1";
|
public const string HelperArgument = "--acdream-self-update-helper-v1";
|
||||||
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
|
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
|
||||||
internal const string DeferredArgument = "--acdream-self-update-deferred-v1";
|
internal const int UpdateLeaseBusyExitCode = 73;
|
||||||
internal const int DeferredLeaseExitCode = 73;
|
private const string InternalArgumentPrefix = "--acdream-self-update-";
|
||||||
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
|
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
|
||||||
private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5);
|
|
||||||
|
|
||||||
public static async Task<SelfUpdateStartupResult> HandleAsync(
|
public static async Task<SelfUpdateStartupResult> HandleAsync(
|
||||||
string[] args,
|
string[] args,
|
||||||
|
|
@ -33,12 +32,6 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
Path.GetFullPath(launcherBaseDirectory));
|
Path.GetFullPath(launcherBaseDirectory));
|
||||||
string executable = Path.GetFullPath(currentExecutablePath);
|
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
|
if (args.Length > 0
|
||||||
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
|
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
|
|
@ -55,6 +48,8 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
|
|
||||||
int exitCode = await RunHelperAsync(
|
int exitCode = await RunHelperAsync(
|
||||||
manager,
|
manager,
|
||||||
|
baseDirectory,
|
||||||
|
executable,
|
||||||
parentPid,
|
parentPid,
|
||||||
args[2],
|
args[2],
|
||||||
args[3],
|
args[3],
|
||||||
|
|
@ -72,26 +67,72 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
return new SelfUpdateStartupResult(true, 64, []);
|
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(
|
await manager.ConfirmAsync(
|
||||||
args[1],
|
args[1],
|
||||||
baseDirectory,
|
baseDirectory,
|
||||||
executable,
|
executable,
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
await FinishConfirmedCleanupAsync(
|
// The helper that owns the exclusive lease observes this durable
|
||||||
manager,
|
// receipt and performs authoritative completion. A later ordinary
|
||||||
baseDirectory,
|
// startup also completes it if that helper crashes after receipt.
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
return new SelfUpdateStartupResult(false, 0, args[2..]);
|
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(
|
if (!manager.Barrier.TryAcquireExclusive(
|
||||||
out UpdateSessionBarrier.ExclusiveLease? startupLease))
|
out UpdateSessionBarrier.ExclusiveLease? startupLease))
|
||||||
{
|
{
|
||||||
// A running session or another launcher is staging. Reading the
|
if (!manager.Barrier.TryAcquireSession(
|
||||||
// plan is safe, but cleanup or starting a competing helper is not.
|
out UpdateSessionBarrier.SessionLease? sharedLease))
|
||||||
return new SelfUpdateStartupResult(false, 0, args);
|
{
|
||||||
|
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
|
using (UpdateSessionBarrier.ExclusiveLease lease = startupLease
|
||||||
|
|
@ -108,11 +149,7 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
return new SelfUpdateStartupResult(false, 0, args);
|
return new SelfUpdateStartupResult(false, 0, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
|
ValidateCanonicalStartup(plan, baseDirectory, executable);
|
||||||
{
|
|
||||||
throw new LauncherUpdateException(
|
|
||||||
"The pending self-update targets a different launcher directory.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
|
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
|
||||||
{
|
{
|
||||||
|
|
@ -138,13 +175,40 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
return new SelfUpdateStartupResult(false, 0, args);
|
return new SelfUpdateStartupResult(false, 0, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
string expectedExecutable = ClientVersionStore.ResolveContained(
|
if (plan.State is SelfUpdatePlanState.Applying
|
||||||
baseDirectory,
|
or SelfUpdatePlanState.RolledBack)
|
||||||
GetLauncherFileName(plan.Rid));
|
{
|
||||||
if (!PathsEqual(executable, expectedExecutable))
|
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(
|
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);
|
string helperPath = manager.GetStagedLauncherPath(plan);
|
||||||
|
|
@ -173,6 +237,8 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
|
|
||||||
private static async Task<int> RunHelperAsync(
|
private static async Task<int> RunHelperAsync(
|
||||||
LauncherSelfUpdateManager manager,
|
LauncherSelfUpdateManager manager,
|
||||||
|
string helperBaseDirectory,
|
||||||
|
string currentExecutablePath,
|
||||||
int parentPid,
|
int parentPid,
|
||||||
string targetDirectory,
|
string targetDirectory,
|
||||||
string transactionId,
|
string transactionId,
|
||||||
|
|
@ -182,10 +248,11 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
|
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
|
||||||
.ConfigureAwait(false)
|
.ConfigureAwait(false)
|
||||||
?? throw new LauncherUpdateException("The helper found no pending self-update.");
|
?? 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(
|
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))
|
if (!PathsEqual(plan.TargetDirectory, targetDirectory))
|
||||||
|
|
@ -194,6 +261,15 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
"The helper target does not match the pending self-update.");
|
"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(
|
string launcherPath = ClientVersionStore.ResolveContained(
|
||||||
targetDirectory,
|
targetDirectory,
|
||||||
GetLauncherFileName(plan.Rid));
|
GetLauncherFileName(plan.Rid));
|
||||||
|
|
@ -215,9 +291,10 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
{
|
{
|
||||||
// Do not restart the canonical launcher: it would immediately see
|
// Do not restart the canonical launcher: it would immediately see
|
||||||
// the same staged plan and create an unbounded helper loop.
|
// the same staged plan and create an unbounded helper loop.
|
||||||
return DeferredLeaseExitCode;
|
return UpdateLeaseBusyExitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ProcessStartInfo? restoredStart = null;
|
||||||
using (UpdateSessionBarrier.ExclusiveLease lease = updateLease
|
using (UpdateSessionBarrier.ExclusiveLease lease = updateLease
|
||||||
?? throw new InvalidOperationException("Exclusive update lease is missing."))
|
?? throw new InvalidOperationException("Exclusive update lease is missing."))
|
||||||
{
|
{
|
||||||
|
|
@ -225,7 +302,8 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
.ConfigureAwait(false)
|
.ConfigureAwait(false)
|
||||||
?? throw new LauncherUpdateException(
|
?? throw new LauncherUpdateException(
|
||||||
"The helper found no pending self-update after acquiring the lease.");
|
"The helper found no pending self-update after acquiring the lease.");
|
||||||
if (!string.Equals(
|
if (plan.State != SelfUpdatePlanState.Staged
|
||||||
|
|| !string.Equals(
|
||||||
plan.TransactionId,
|
plan.TransactionId,
|
||||||
transactionId,
|
transactionId,
|
||||||
StringComparison.Ordinal)
|
StringComparison.Ordinal)
|
||||||
|
|
@ -315,78 +393,59 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
return 75;
|
return 75;
|
||||||
}
|
}
|
||||||
|
|
||||||
var restored = new ProcessStartInfo(launcherPath)
|
restoredStart = new ProcessStartInfo(launcherPath)
|
||||||
{
|
{
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
WorkingDirectory = Path.GetFullPath(targetDirectory),
|
WorkingDirectory = Path.GetFullPath(targetDirectory),
|
||||||
};
|
};
|
||||||
restored.ArgumentList.Add(DeferredArgument);
|
|
||||||
foreach (string argument in publicArguments)
|
foreach (string argument in publicArguments)
|
||||||
{
|
{
|
||||||
restored.ArgumentList.Add(argument);
|
restoredStart.ArgumentList.Add(argument);
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = Process.Start(restored);
|
|
||||||
return 74;
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
replacement?.Dispose();
|
replacement?.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task FinishConfirmedCleanupAsync(
|
// Release the helper's exclusive barrier before restarting the
|
||||||
LauncherSelfUpdateManager manager,
|
// restored canonical launcher. It will observe the durable RolledBack
|
||||||
string targetDirectory,
|
// receipt through the ordinary startup path, re-verify it, finalize
|
||||||
CancellationToken cancellationToken)
|
// recovery, and continue with no privileged bypass argument.
|
||||||
{
|
if (restoredStart is null || Process.Start(restoredStart) is null)
|
||||||
DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout;
|
|
||||||
do
|
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
return 75;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
while (DateTimeOffset.UtcNow < deadline);
|
|
||||||
|
return 74;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetLauncherFileName(string rid) =>
|
private static string GetLauncherFileName(string rid) =>
|
||||||
"acdream-launcher"
|
"acdream-launcher"
|
||||||
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
|
+ (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(
|
private static async Task WaitForParentExitAsync(
|
||||||
int parentPid,
|
int parentPid,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,39 @@ public sealed class LauncherSelfUpdateManager
|
||||||
SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId));
|
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(
|
public async Task<SelfUpdatePlan> RollbackAwaitingConfirmationAsync(
|
||||||
string expectedTargetDirectory,
|
string expectedTargetDirectory,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ public interface IReleaseManifestClient
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Strict, bounded reader for the pinned GitHub Releases manifest. Production
|
/// Strict, bounded reader for the pinned GitHub Releases manifest. Production
|
||||||
/// construction is HTTPS-only. The loopback HTTP allowance is available only
|
/// construction is pinned and HTTPS-only. The explicitly named process-local
|
||||||
/// through an internal fixture factory and is never inferred from a URI.
|
/// 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
|
/// Redirects are followed manually so every hop is checked before any bytes
|
||||||
/// cross that hop.
|
/// cross that hop.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -74,6 +75,49 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||||
CreateRedirectDisabledHandler(),
|
CreateRedirectDisabledHandler(),
|
||||||
timeout);
|
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(
|
internal static ReleaseManifestClient CreateForTransportTest(
|
||||||
Uri manifestUri,
|
Uri manifestUri,
|
||||||
bool allowLoopbackHttp,
|
bool allowLoopbackHttp,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,43 @@ public sealed class UpdateSessionBarrier
|
||||||
return new SessionLease(stream);
|
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()
|
public ExclusiveLease AcquireExclusive()
|
||||||
{
|
{
|
||||||
FileStream stream = Open(
|
FileStream stream = Open(
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,33 @@ namespace AcDream.Launcher;
|
||||||
|
|
||||||
public sealed partial class App : Application
|
public sealed partial class App : Application
|
||||||
{
|
{
|
||||||
|
private readonly LauncherStartupOptions? _startupOptions;
|
||||||
private LauncherOrchestrator? _orchestrator;
|
private LauncherOrchestrator? _orchestrator;
|
||||||
private LauncherWindowViewModel? _viewModel;
|
private LauncherWindowViewModel? _viewModel;
|
||||||
private LauncherUpdateComposition? _updateComposition;
|
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 Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
ApplicationPathSet paths = ApplicationPathSet.Resolve();
|
LauncherStartupOptions startupOptions = StartupOptions;
|
||||||
|
ApplicationPathSet paths = startupOptions.Paths;
|
||||||
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
|
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
|
||||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||||
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||||
|
|
@ -57,7 +73,8 @@ public sealed partial class App : Application
|
||||||
GetLauncherVersion(),
|
GetLauncherVersion(),
|
||||||
AppContext.BaseDirectory,
|
AppContext.BaseDirectory,
|
||||||
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
|
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
|
||||||
== true);
|
== true,
|
||||||
|
updateManifestUri: startupOptions.UpdateManifestUri);
|
||||||
_updateComposition = updates;
|
_updateComposition = updates;
|
||||||
|
|
||||||
_orchestrator = new LauncherOrchestrator(
|
_orchestrator = new LauncherOrchestrator(
|
||||||
|
|
|
||||||
255
src/AcDream.Launcher/LauncherStartupOptions.cs
Normal file
255
src/AcDream.Launcher/LauncherStartupOptions.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,12 +22,14 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
||||||
ClientVersionStore versions,
|
ClientVersionStore versions,
|
||||||
LauncherExecutableSet executables,
|
LauncherExecutableSet executables,
|
||||||
ILauncherUpdater updater,
|
ILauncherUpdater updater,
|
||||||
|
Uri updateManifestUri,
|
||||||
HttpClient? artifactClient,
|
HttpClient? artifactClient,
|
||||||
ReleaseManifestClient? manifestClient)
|
ReleaseManifestClient? manifestClient)
|
||||||
{
|
{
|
||||||
Versions = versions;
|
Versions = versions;
|
||||||
Executables = executables;
|
Executables = executables;
|
||||||
Updater = updater;
|
Updater = updater;
|
||||||
|
UpdateManifestUri = updateManifestUri;
|
||||||
_artifactClient = artifactClient;
|
_artifactClient = artifactClient;
|
||||||
_manifestClient = manifestClient;
|
_manifestClient = manifestClient;
|
||||||
}
|
}
|
||||||
|
|
@ -38,17 +40,22 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
||||||
|
|
||||||
public ILauncherUpdater Updater { get; }
|
public ILauncherUpdater Updater { get; }
|
||||||
|
|
||||||
|
internal Uri UpdateManifestUri { get; }
|
||||||
|
|
||||||
public static LauncherUpdateComposition Create(
|
public static LauncherUpdateComposition Create(
|
||||||
ApplicationPathSet paths,
|
ApplicationPathSet paths,
|
||||||
string rid,
|
string rid,
|
||||||
LauncherVersion launcherVersion,
|
LauncherVersion launcherVersion,
|
||||||
string launcherTargetDirectory,
|
string launcherTargetDirectory,
|
||||||
Func<bool> hasRunningSessions,
|
Func<bool> hasRunningSessions,
|
||||||
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null)
|
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null,
|
||||||
|
Uri? updateManifestUri = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
ArgumentNullException.ThrowIfNull(launcherVersion);
|
ArgumentNullException.ThrowIfNull(launcherVersion);
|
||||||
ArgumentNullException.ThrowIfNull(hasRunningSessions);
|
ArgumentNullException.ThrowIfNull(hasRunningSessions);
|
||||||
|
Uri manifestUri = updateManifestUri
|
||||||
|
?? ReleaseManifestClient.ProductionManifestUri;
|
||||||
var versions = new ClientVersionStore(paths);
|
var versions = new ClientVersionStore(paths);
|
||||||
HttpClient? artifactClient = null;
|
HttpClient? artifactClient = null;
|
||||||
ReleaseManifestClient? manifestClient = null;
|
ReleaseManifestClient? manifestClient = null;
|
||||||
|
|
@ -69,7 +76,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
||||||
Timeout = TimeSpan.FromSeconds(15),
|
Timeout = TimeSpan.FromSeconds(15),
|
||||||
};
|
};
|
||||||
artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
|
artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
|
||||||
manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15));
|
manifestClient = CreateManifestClient(manifestUri);
|
||||||
var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient);
|
var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient);
|
||||||
var updater = new LauncherUpdater(
|
var updater = new LauncherUpdater(
|
||||||
manifestClient,
|
manifestClient,
|
||||||
|
|
@ -84,6 +91,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
||||||
versions,
|
versions,
|
||||||
LauncherExecutableSet.FromCurrentVersionStore(versions),
|
LauncherExecutableSet.FromCurrentVersionStore(versions),
|
||||||
updater,
|
updater,
|
||||||
|
manifestUri,
|
||||||
artifactClient,
|
artifactClient,
|
||||||
manifestClient);
|
manifestClient);
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +114,7 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
||||||
versions,
|
versions,
|
||||||
LauncherExecutableSet.Unavailable(status),
|
LauncherExecutableSet.Unavailable(status),
|
||||||
new UnavailableLauncherUpdater(status, resolution),
|
new UnavailableLauncherUpdater(status, resolution),
|
||||||
|
manifestUri,
|
||||||
artifactClient: null,
|
artifactClient: null,
|
||||||
manifestClient: null);
|
manifestClient: null);
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +126,16 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
||||||
_artifactClient?.Dispose();
|
_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
|
private static bool IsStorageFailure(Exception exception) => exception is
|
||||||
IOException
|
IOException
|
||||||
or UnauthorizedAccessException
|
or UnauthorizedAccessException
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using AcDream.Launcher.Core.Updates;
|
using AcDream.Launcher.Core.Updates;
|
||||||
using AcDream.Platform;
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
|
||||||
namespace AcDream.Launcher;
|
namespace AcDream.Launcher;
|
||||||
|
|
@ -9,19 +8,18 @@ internal static class Program
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static int Main(string[] args)
|
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
|
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();
|
using var httpClient = new HttpClient();
|
||||||
var selfUpdates = new LauncherSelfUpdateManager(paths, httpClient);
|
var selfUpdates = new LauncherSelfUpdateManager(options.Paths, httpClient);
|
||||||
string executable = Environment.ProcessPath
|
string executable = Environment.ProcessPath
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"The launcher executable path is unavailable.");
|
"The launcher executable path is unavailable.");
|
||||||
|
|
@ -37,8 +35,9 @@ internal static class Program
|
||||||
return startup.ExitCode;
|
return startup.ExitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(
|
RequireUnchangedPublicArguments(options, startup);
|
||||||
startup.RemainingArguments);
|
|
||||||
|
return BuildAvaloniaApp(options).StartWithClassicDesktopLifetime([]);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -47,7 +46,25 @@ internal static class Program
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AppBuilder BuildAvaloniaApp() =>
|
internal static AppBuilder BuildAvaloniaApp(LauncherStartupOptions options)
|
||||||
AppBuilder.Configure<App>()
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
return AppBuilder.Configure(() => new App(options))
|
||||||
.UsePlatformDetect();
|
.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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,7 @@ if (!string.IsNullOrWhiteSpace(selfUpdateData)
|
||||||
SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync(
|
SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
effectiveArgs,
|
effectiveArgs,
|
||||||
manager,
|
manager,
|
||||||
Path.GetFullPath(selfUpdateTarget),
|
Path.GetFullPath(AppContext.BaseDirectory),
|
||||||
Path.GetFullPath(
|
Path.GetFullPath(
|
||||||
Environment.ProcessPath
|
Environment.ProcessPath
|
||||||
?? throw new InvalidOperationException("Process path is unavailable.")));
|
?? throw new InvalidOperationException("Process path is unavailable.")));
|
||||||
|
|
@ -53,6 +53,8 @@ return effectiveArgs.FirstOrDefault() switch
|
||||||
"stage-self-update" => await StageSelfUpdateAsync(effectiveArgs[1..]),
|
"stage-self-update" => await StageSelfUpdateAsync(effectiveArgs[1..]),
|
||||||
"bootstrap-probe" => await BootstrapProbeAsync(effectiveArgs[1..]),
|
"bootstrap-probe" => await BootstrapProbeAsync(effectiveArgs[1..]),
|
||||||
"canonical-probe" => CanonicalProbe(effectiveArgs[1..]),
|
"canonical-probe" => CanonicalProbe(effectiveArgs[1..]),
|
||||||
|
"hold-campaign-la-process" =>
|
||||||
|
await HoldCampaignLaProcessAsync(effectiveArgs[1..]),
|
||||||
_ => 2,
|
_ => 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -60,7 +62,7 @@ static bool IsBootstrapInvocation(string[] arguments) =>
|
||||||
arguments.Length > 0
|
arguments.Length > 0
|
||||||
&& arguments[0] is LauncherSelfUpdateBootstrap.HelperArgument
|
&& arguments[0] is LauncherSelfUpdateBootstrap.HelperArgument
|
||||||
or LauncherSelfUpdateBootstrap.ConfirmArgument
|
or LauncherSelfUpdateBootstrap.ConfirmArgument
|
||||||
or LauncherSelfUpdateBootstrap.DeferredArgument
|
or "--acdream-self-update-deferred-v1"
|
||||||
or "canonical-probe";
|
or "canonical-probe";
|
||||||
|
|
||||||
static ApplicationPathSet Paths(string dataDirectory)
|
static ApplicationPathSet Paths(string dataDirectory)
|
||||||
|
|
@ -160,18 +162,51 @@ static async Task<int> BootstrapProbeAsync(string[] arguments)
|
||||||
|
|
||||||
static int CanonicalProbe(string[] arguments)
|
static int CanonicalProbe(string[] arguments)
|
||||||
{
|
{
|
||||||
if (arguments.Length != 1)
|
if (arguments.Length < 1)
|
||||||
{
|
{
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string suffix = arguments.Length == 1
|
||||||
|
? string.Empty
|
||||||
|
: Environment.NewLine
|
||||||
|
+ string.Join(Environment.NewLine, arguments[1..]);
|
||||||
File.WriteAllText(
|
File.WriteAllText(
|
||||||
Path.GetFullPath(arguments[0]),
|
Path.GetFullPath(arguments[0]),
|
||||||
Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||||
+ "|"
|
+ "|"
|
||||||
+ Path.GetFullPath(
|
+ Path.GetFullPath(
|
||||||
Environment.ProcessPath
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,5 +25,17 @@
|
||||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||||
</ProjectReference>
|
</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>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using AcDream.Launcher.Core.Launching;
|
using AcDream.Launcher.Core.Launching;
|
||||||
|
|
||||||
|
|
@ -6,6 +8,27 @@ namespace AcDream.Launcher.Core.Tests.Launching;
|
||||||
|
|
||||||
public sealed class LauncherProcessSupervisorTests
|
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]
|
[Fact]
|
||||||
public void StartWritesPasswordThenClosesStdinAndTransitionsToRunning()
|
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]
|
[Fact]
|
||||||
public void StartKillsAndDisposesTheChildWhenFeedingStdinThrowsAfterTheProcessHasStarted()
|
public void StartKillsAndDisposesTheChildWhenFeedingStdinThrowsAfterTheProcessHasStarted()
|
||||||
{
|
{
|
||||||
|
|
@ -371,6 +546,22 @@ public sealed class LauncherProcessSupervisorTests
|
||||||
private static LauncherProcessSpec Spec() =>
|
private static LauncherProcessSpec Spec() =>
|
||||||
new("fake-host", ["--session-config", "session.json"]);
|
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() =>
|
private static string FindDotnetExecutable() =>
|
||||||
// PATH-based resolution: .NET Core's Process.Start searches PATH
|
// PATH-based resolution: .NET Core's Process.Start searches PATH
|
||||||
// for a bare filename when UseShellExecute is false, on both
|
// 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`.
|
// because this test is itself running under `dotnet test`.
|
||||||
OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet";
|
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(
|
private sealed class FakeChildProcessFactory(
|
||||||
bool exitsWithinStopTimeout,
|
bool exitsWithinStopTimeout,
|
||||||
bool exitDuringStart = false,
|
bool exitDuringStart = false,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,16 @@ public sealed class LauncherExecutableSetTests : IDisposable
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
headless,
|
headless,
|
||||||
set.CreateProbeSpec("session.json").ExecutablePath);
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -279,27 +279,244 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
||||||
public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing()
|
public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing()
|
||||||
{
|
{
|
||||||
using var harness = new Harness(_root);
|
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(
|
SelfUpdateStartupResult ordinary = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
["--literal", "argument with spaces & metacharacters"],
|
publicArguments,
|
||||||
harness.Manager,
|
harness.Manager,
|
||||||
harness.Target,
|
harness.Target,
|
||||||
harness.LauncherPath);
|
harness.LauncherPath);
|
||||||
Assert.False(ordinary.ShouldExit);
|
Assert.False(ordinary.ShouldExit);
|
||||||
Assert.Equal(["--literal", "argument with spaces & metacharacters"],
|
Assert.Equal(publicArguments, ordinary.RemainingArguments);
|
||||||
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();
|
_ = await harness.StageAsync();
|
||||||
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||||
SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
|
SelfUpdateStartupResult confirmation;
|
||||||
[LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId],
|
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.Manager,
|
||||||
harness.Target,
|
harness.Target,
|
||||||
harness.LauncherPath);
|
harness.LauncherPath);
|
||||||
|
|
||||||
Assert.False(confirmation.ShouldExit);
|
Assert.False(result.ShouldExit);
|
||||||
Assert.Empty(confirmation.RemainingArguments);
|
Assert.Equal(["ordinary"], result.RemainingArguments);
|
||||||
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
|
Assert.Null(await harness.Manager.LoadPendingAsync());
|
||||||
Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
|
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
|
private sealed class Harness : IDisposable
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,20 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task KilledAfterCanonicalReplaceCanInvokeCanonicalAndConvergeAutomatically()
|
public async Task KilledApplyingPlanRecoversPriorAndContinuesCanonicalWithoutRetryLoop()
|
||||||
{
|
{
|
||||||
string data = Path.Combine(_root, "data");
|
string data = Path.Combine(_root, "data");
|
||||||
string target = Path.Combine(_root, "launcher");
|
string target = Path.Combine(_root, "launcher");
|
||||||
string ready = Path.Combine(_root, "crash.ready");
|
string ready = Path.Combine(_root, "crash.ready");
|
||||||
string launched = Path.Combine(_root, "replacement.ready");
|
string launched = Path.Combine(_root, "replacement.ready");
|
||||||
string helperPidPath = Path.Combine(_root, "helper.pid");
|
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);
|
Directory.CreateDirectory(_root);
|
||||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||||
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
|
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
|
||||||
|
|
@ -76,7 +83,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||||
};
|
};
|
||||||
using Process canonical = StartProcess(
|
using Process canonical = StartProcess(
|
||||||
prepared.CanonicalPath,
|
prepared.CanonicalPath,
|
||||||
["canonical-probe", launched],
|
["canonical-probe", launched, .. processLocalSuffix],
|
||||||
environment);
|
environment);
|
||||||
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
||||||
Assert.Equal(0, canonical.ExitCode);
|
Assert.Equal(0, canonical.ExitCode);
|
||||||
|
|
@ -87,25 +94,35 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||||
"The self-update journal did not converge.");
|
"The self-update journal did not converge.");
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
prepared.NewCanonicalHash,
|
oldHash,
|
||||||
await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath));
|
await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath));
|
||||||
Assert.True(File.Exists(Path.Combine(
|
Assert.False(File.Exists(Path.Combine(
|
||||||
target,
|
target,
|
||||||
LauncherSelfUpdateManager.InstallRecordFileName)));
|
LauncherSelfUpdateManager.InstallRecordFileName)));
|
||||||
Assert.False(Directory.Exists(manager.GetTransactionDirectory(
|
Assert.False(Directory.Exists(manager.GetTransactionDirectory(
|
||||||
plan.TransactionId)));
|
plan.TransactionId)));
|
||||||
|
using (UpdateSessionBarrier.ExclusiveLease cleanupLease =
|
||||||
|
manager.Barrier.AcquireExclusive())
|
||||||
|
{
|
||||||
|
Assert.True(manager.CleanupOwnedResidueUnderLease(
|
||||||
|
pending: null,
|
||||||
|
target,
|
||||||
|
cleanupLease));
|
||||||
|
}
|
||||||
Assert.Empty(Directory.EnumerateDirectories(
|
Assert.Empty(Directory.EnumerateDirectories(
|
||||||
target,
|
target,
|
||||||
".acdream-self-update-*",
|
".acdream-self-update-*",
|
||||||
SearchOption.TopDirectoryOnly));
|
SearchOption.TopDirectoryOnly));
|
||||||
Assert.Null(await manager.LoadPendingAsync());
|
Assert.Null(await manager.LoadPendingAsync());
|
||||||
|
|
||||||
int replacementPid = ParsePid(await File.ReadAllTextAsync(launched));
|
string launchMarker = await File.ReadAllTextAsync(launched);
|
||||||
int helperPid = int.Parse(
|
Assert.EndsWith(
|
||||||
await File.ReadAllTextAsync(helperPidPath),
|
Environment.NewLine + string.Join(Environment.NewLine, processLocalSuffix),
|
||||||
System.Globalization.CultureInfo.InvariantCulture);
|
launchMarker,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
int replacementPid = ParsePid(launchMarker);
|
||||||
await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10));
|
await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10));
|
||||||
await WaitForProcessExitAsync(helperPid, TimeSpan.FromSeconds(10));
|
Assert.False(File.Exists(helperPidPath));
|
||||||
if (OperatingSystem.IsLinux())
|
if (OperatingSystem.IsLinux())
|
||||||
{
|
{
|
||||||
Assert.True(
|
Assert.True(
|
||||||
|
|
@ -144,13 +161,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||||
["canonical-probe", launched],
|
["canonical-probe", launched],
|
||||||
BootstrapEnvironment(crashed, helperPidPath));
|
BootstrapEnvironment(crashed, helperPidPath));
|
||||||
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
||||||
Assert.Equal(0, canonical.ExitCode);
|
Assert.NotEqual(0, canonical.ExitCode);
|
||||||
await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20));
|
Assert.False(File.Exists(helperPidPath));
|
||||||
await WaitForProcessExitAsync(
|
|
||||||
int.Parse(
|
|
||||||
await File.ReadAllTextAsync(helperPidPath),
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture),
|
|
||||||
TimeSpan.FromSeconds(20));
|
|
||||||
|
|
||||||
Assert.False(File.Exists(launched));
|
Assert.False(File.Exists(launched));
|
||||||
SelfUpdatePlan preserved = Assert.IsType<SelfUpdatePlan>(
|
SelfUpdatePlan preserved = Assert.IsType<SelfUpdatePlan>(
|
||||||
|
|
@ -192,13 +204,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||||
["canonical-probe", launched],
|
["canonical-probe", launched],
|
||||||
BootstrapEnvironment(crashed, helperPidPath));
|
BootstrapEnvironment(crashed, helperPidPath));
|
||||||
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
||||||
Assert.Equal(0, canonical.ExitCode);
|
Assert.NotEqual(0, canonical.ExitCode);
|
||||||
await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20));
|
Assert.False(File.Exists(helperPidPath));
|
||||||
await WaitForProcessExitAsync(
|
|
||||||
int.Parse(
|
|
||||||
await File.ReadAllTextAsync(helperPidPath),
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture),
|
|
||||||
TimeSpan.FromSeconds(20));
|
|
||||||
|
|
||||||
Assert.False(File.Exists(launched));
|
Assert.False(File.Exists(launched));
|
||||||
Assert.True(File.Exists(outsideCanonical));
|
Assert.True(File.Exists(outsideCanonical));
|
||||||
|
|
@ -285,8 +292,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||||
["bootstrap-probe", data, target, canonical, resultPath]);
|
["bootstrap-probe", data, target, canonical, resultPath]);
|
||||||
await startup.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
await startup.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
Assert.Equal(0, startup.ExitCode);
|
Assert.NotEqual(0, startup.ExitCode);
|
||||||
Assert.Equal("ordinary", await File.ReadAllTextAsync(resultPath));
|
Assert.False(File.Exists(resultPath));
|
||||||
Assert.True(Directory.Exists(transaction));
|
Assert.True(Directory.Exists(transaction));
|
||||||
Assert.False(File.Exists(observer.PendingPlanPath));
|
Assert.False(File.Exists(observer.PendingPlanPath));
|
||||||
|
|
||||||
|
|
@ -316,9 +323,9 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||||
string helperPid = Path.Combine(_root, "helper.pid");
|
string helperPid = Path.Combine(_root, "helper.pid");
|
||||||
Directory.CreateDirectory(target);
|
Directory.CreateDirectory(target);
|
||||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||||
string canonical = Path.Combine(target, LauncherName(rid));
|
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
|
||||||
await File.WriteAllTextAsync(canonical, "old-launcher");
|
string canonical = prepared.CanonicalPath;
|
||||||
byte[] archive = UpdateTestData.LauncherZip(rid, "new-launcher");
|
byte[] archive = prepared.NewArchive;
|
||||||
using var server = new LocalHttpFixture();
|
using var server = new LocalHttpFixture();
|
||||||
server.Add("launcher.zip", archive);
|
server.Add("launcher.zip", archive);
|
||||||
using var http = new HttpClient();
|
using var http = new HttpClient();
|
||||||
|
|
@ -342,7 +349,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||||
[HelperPidEnvironment] = helperPid,
|
[HelperPidEnvironment] = helperPid,
|
||||||
};
|
};
|
||||||
|
|
||||||
using Process helper = StartFixture(
|
using Process helper = StartProcess(
|
||||||
|
manager.GetStagedLauncherPath(plan),
|
||||||
[
|
[
|
||||||
LauncherSelfUpdateBootstrap.HelperArgument,
|
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||||
int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
|
@ -353,16 +361,151 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||||
], environment);
|
], environment);
|
||||||
await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
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.True(File.Exists(helperPid));
|
||||||
Assert.False(File.Exists(unexpectedLaunch));
|
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>(
|
SelfUpdatePlan deferred = Assert.IsType<SelfUpdatePlan>(
|
||||||
await manager.LoadPendingAsync());
|
await manager.LoadPendingAsync());
|
||||||
Assert.Equal(SelfUpdatePlanState.Staged, deferred.State);
|
Assert.Equal(SelfUpdatePlanState.Staged, deferred.State);
|
||||||
Assert.Equal(plan.TransactionId, deferred.TransactionId);
|
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)
|
private PreparedLauncher PrepareLauncherClosure(string target, string rid)
|
||||||
{
|
{
|
||||||
string fixtureDirectory = GetFixtureDirectory();
|
string fixtureDirectory = GetFixtureDirectory();
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,26 @@ public sealed class LauncherVersionTests
|
||||||
|
|
||||||
public sealed class ReleaseManifestClientTests
|
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]
|
[Fact]
|
||||||
public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed()
|
public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
253
tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
Normal file
253
tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,4 +62,44 @@ public sealed class LauncherUpdateCompositionTests : IDisposable
|
||||||
() => composition.Updater.CheckAsync());
|
() => composition.Updater.CheckAsync());
|
||||||
Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
232
tools/CampaignLaProcessCorrelation.ps1
Normal file
232
tools/CampaignLaProcessCorrelation.ps1
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
117
tools/capture-campaign-la-session-process.ps1
Normal file
117
tools/capture-campaign-la-session-process.ps1
Normal 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"
|
||||||
500
tools/new-campaign-la-update-fixture.ps1
Normal file
500
tools/new-campaign-la-update-fixture.ps1
Normal 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"
|
||||||
530
tools/run-campaign-la-preflight.ps1
Normal file
530
tools/run-campaign-la-preflight.ps1
Normal 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 }
|
||||||
|
}
|
||||||
367
tools/test-campaign-la-gate-helpers.ps1
Normal file
367
tools/test-campaign-la-gate-helpers.ps1
Normal 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"
|
||||||
378
tools/test-campaign-la-script-safety.ps1
Normal file
378
tools/test-campaign-la-script-safety.ps1
Normal 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"
|
||||||
536
tools/test-campaign-la-session-status.ps1
Normal file
536
tools/test-campaign-la-session-status.ps1
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue