fix(launcher): harden Campaign LA11 gate evidence
This commit is contained in:
parent
134edabed2
commit
accd01a008
16 changed files with 1820 additions and 210 deletions
|
|
@ -53,6 +53,7 @@ New-Item -ItemType Directory -Path $Gate | Out-Null
|
||||||
|
|
||||||
pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') `
|
pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') `
|
||||||
-Repository $Repo `
|
-Repository $Repo `
|
||||||
|
-AllowedOutputRoot $Gate `
|
||||||
-OutputDirectory $Preflight
|
-OutputDirectory $Preflight
|
||||||
|
|
||||||
$Report = Get-Content -LiteralPath (Join-Path $Preflight 'report.json') -Raw |
|
$Report = Get-Content -LiteralPath (Join-Path $Preflight 'report.json') -Raw |
|
||||||
|
|
@ -79,8 +80,13 @@ The expected matrix is:
|
||||||
|
|
||||||
`report.json` records the tested HEAD/dirty state, OS/RID, exact commands,
|
`report.json` records the tested HEAD/dirty state, OS/RID, exact commands,
|
||||||
durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight
|
durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight
|
||||||
plans 30 rows. It never launches App or Headless in connected mode and
|
plans 32 rows, including the connection-free PID/status/redaction and script-
|
||||||
never reads a credential. Every child starts with all inherited `ACDREAM_*`
|
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,
|
variables removed, so a developer shell cannot accidentally enable live,
|
||||||
installed-DAT, fixture-regeneration, or diagnostic gates. Only the optional
|
installed-DAT, fixture-regeneration, or diagnostic gates. Only the optional
|
||||||
row below adds the two named DAT variables back for its three exact tests.
|
row below adds the two named DAT variables back for its three exact tests.
|
||||||
|
|
@ -93,6 +99,7 @@ the DAT directory may be read by tests:
|
||||||
```powershell
|
```powershell
|
||||||
pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') `
|
pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') `
|
||||||
-Repository $Repo `
|
-Repository $Repo `
|
||||||
|
-AllowedOutputRoot $Gate `
|
||||||
-OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') `
|
-OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') `
|
||||||
-IncludeInstalledDat `
|
-IncludeInstalledDat `
|
||||||
-InstalledDatDirectory '<ABSOLUTE_RETAIL_DAT_DIRECTORY>'
|
-InstalledDatDirectory '<ABSOLUTE_RETAIL_DAT_DIRECTORY>'
|
||||||
|
|
@ -103,7 +110,7 @@ The mandatory installed-DAT result is
|
||||||
`ACDREAM_DAT_DIR` set inside the child environment. The helper reads the TRX
|
`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 fails if the test skipped or did anything other than pass. The action-map
|
||||||
and portal-asset probes are additional coverage, never substitutes. Expected
|
and portal-asset probes are additional coverage, never substitutes. Expected
|
||||||
matrix size: 34 rows.
|
matrix size: 36 rows.
|
||||||
|
|
||||||
On Ubuntu/WSL, invoke the same script with native `pwsh`, a Linux repository
|
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
|
path, and a Linux output path. Do not treat a Windows-hosted run over
|
||||||
|
|
@ -170,8 +177,11 @@ pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
The helper rejects nonempty output, invalid or non-monotonic versions, missing
|
The helper rejects nonempty output, invalid or non-monotonic versions, missing
|
||||||
root executables (including the co-deployed Bake CLI), and nonabsolute inputs.
|
root executables (including the co-deployed Bake CLI), nonabsolute inputs,
|
||||||
It writes fixed-timestamp sorted ZIPs,
|
output/source overlap in either direction, and any reparse point in source or
|
||||||
|
output ancestry. It enumerates normalized relative paths with ordinal ordering,
|
||||||
|
never its own output, and normalizes ZIP host metadata so Windows/Linux hashes
|
||||||
|
are identical under multiple cultures. It writes fixed-timestamp sorted ZIPs,
|
||||||
the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only
|
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
|
server (with optional bounded `-MaximumRequests` smoke mode), and an atomic A/B
|
||||||
selector. Both generated helpers reject a `-Root` other than their own fixture
|
selector. Both generated helpers reject a `-Root` other than their own fixture
|
||||||
|
|
@ -221,24 +231,56 @@ must preserve the same validated suffix through helper and confirmation
|
||||||
restarts. The launcher, profiles, installer, current-version store, updater,
|
restarts. The launcher, profiles, installer, current-version store, updater,
|
||||||
session composer, and orchestrator must all use this one exact path set.
|
session composer, and orchestrator must all use this one exact path set.
|
||||||
|
|
||||||
For every play/probe row, copy the session id shown in the launcher's Sessions
|
For every play/probe row, start this gate-only PID watcher immediately before
|
||||||
list into `<SESSION_ID>`, then run:
|
clicking Refresh/Play. It correlates only the unique isolated session-config
|
||||||
|
path, records neither command line nor config contents, and must finish while
|
||||||
|
the child is still live:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$Status = Join-Path $WinCache 'launcher/sessions/<SESSION_ID>/status.jsonl'
|
$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') `
|
pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
|
||||||
-StatusFile $Status `
|
-StatusFile $Status `
|
||||||
-Mode '<probe|guiSelect|gui|headless>' `
|
-Mode '<probe|guiSelect|gui|headless>' `
|
||||||
-ExpectedSessionId '<SESSION_ID>' `
|
-ExpectedProcessId $Capture.processId `
|
||||||
|
-SessionConfigPath $SessionConfig `
|
||||||
|
-CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
|
||||||
|
-ExpectedSessionId $Capture.sessionId `
|
||||||
-ReportPath (Join-Path $Evidence '<ROW>-status.validation.json')
|
-ReportPath (Join-Path $Evidence '<ROW>-status.validation.json')
|
||||||
```
|
```
|
||||||
|
|
||||||
Add `-ExpectedPlugin acdream.smoke` to rows D–F. The validator enforces exact
|
Add `-ExpectedPlugin acdream.smoke` to rows D–F. The validator enforces exact
|
||||||
v1 fields **and property order**, one session id, UTC monotonic timestamps,
|
v1 fields **and property order**, one session id, UTC monotonic timestamps,
|
||||||
mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login
|
mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login
|
||||||
command failure, credential redaction, and no surviving App/Headless process.
|
command failure, exact terminal `disconnected.reason == stopped`, credential
|
||||||
Its report contains event names and a hash, not account, character, command, or
|
redaction, and that exact captured PID is gone. Optional config-path correlation
|
||||||
error payloads. Keep raw `session.json`/`status.jsonl` local; never upload them.
|
uses Windows CIM or Linux `/proc/*/cmdline`; it never globally scans a process
|
||||||
|
name, so unrelated same-name processes and Linux's 15-character names do not
|
||||||
|
affect the result. The validator verifies owner-only profile access, reads only
|
||||||
|
password/secret fields in memory, recursively checks every allowed status
|
||||||
|
string (including command/error text), and reports only the forbidden-value
|
||||||
|
count and status hash—never credential content or a credential hash. Keep the
|
||||||
|
profile and raw `session.json`/`status.jsonl` local; never upload them.
|
||||||
|
|
||||||
## 5. Serial Windows user rows A–H
|
## 5. Serial Windows user rows A–H
|
||||||
|
|
||||||
|
|
@ -374,8 +416,10 @@ called. This connected row proves the actual ACE graceful-logout half. Issue
|
||||||
2. Confirm ordinary selection enables Enter/Delete and disables Restore. Click
|
2. Confirm ordinary selection enables Enter/Delete and disables Restore. Click
|
||||||
Delete, inspect the retail confirmation dialog, cancel once, and confirm no
|
Delete, inspect the retail confirmation dialog, cancel once, and confirm no
|
||||||
state change.
|
state change.
|
||||||
3. Delete again and confirm. Verify the wait dialog, greyed roster row/countdown,
|
3. Delete again and confirm. Verify the wait dialog, greyed/pending-delete
|
||||||
disabled Enter/Delete, and enabled Restore. Save `G-deleted.png`.
|
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
|
4. Click Restore and confirm the same GUID returns to ordinary state with
|
||||||
Enter/Delete enabled and Restore disabled. Save `G-restored.png`.
|
Enter/Delete enabled and Restore disabled. Save `G-restored.png`.
|
||||||
5. Close through launcher **Stop**, confirm graceful terminal status and ACE
|
5. Close through launcher **Stop**, confirm graceful terminal status and ACE
|
||||||
|
|
@ -385,6 +429,9 @@ called. This connected row proves the actual ACE graceful-logout half. Issue
|
||||||
pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
|
pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
|
||||||
-StatusFile (Join-Path $WinCache 'launcher/sessions/<SESSION_ID>/status.jsonl') `
|
-StatusFile (Join-Path $WinCache 'launcher/sessions/<SESSION_ID>/status.jsonl') `
|
||||||
-Mode guiSelect `
|
-Mode guiSelect `
|
||||||
|
-ExpectedProcessId '<CAPTURED_CHILD_PID>' `
|
||||||
|
-SessionConfigPath (Join-Path $WinCache 'launcher/sessions/<SESSION_ID>/session.json') `
|
||||||
|
-CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
|
||||||
-ExpectNoEnteredWorld `
|
-ExpectNoEnteredWorld `
|
||||||
-ExpectedSessionId '<SESSION_ID>' `
|
-ExpectedSessionId '<SESSION_ID>' `
|
||||||
-ExpectedPlugin acdream.smoke `
|
-ExpectedPlugin acdream.smoke `
|
||||||
|
|
@ -489,7 +536,9 @@ Complete this exact serial matrix:
|
||||||
`stat -c '%a' "$LinuxConfig/launcher-profiles.json"`; the exact result must
|
`stat -c '%a' "$LinuxConfig/launcher-profiles.json"`; the exact result must
|
||||||
be `600`.
|
be `600`.
|
||||||
3. **Probe twice:** run Refresh twice, validate both status streams in `probe`
|
3. **Probe twice:** run Refresh twice, validate both status streams in `probe`
|
||||||
mode with native `pwsh`, and confirm ACE clears the account after each.
|
mode with native `pwsh`, using the same pre-action watcher and exact PID,
|
||||||
|
Linux session-config path, and `$LinuxConfig/launcher-profiles.json`; confirm
|
||||||
|
ACE clears the account after each.
|
||||||
4. **Platform posture:** confirm GUI and GUI-select client buttons are disabled
|
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
|
and show the explicit Modern Runtime Slice-L message. Do not bypass this
|
||||||
disablement and do not claim a Linux graphical-client gate.
|
disablement and do not claim a Linux graphical-client gate.
|
||||||
|
|
@ -521,6 +570,7 @@ logs/campaign-la-user-gate-<timestamp>/
|
||||||
evidence/A-install-hashes.json
|
evidence/A-install-hashes.json
|
||||||
evidence/B-*.png
|
evidence/B-*.png
|
||||||
evidence/C-probe-{1,2}-status.validation.json
|
evidence/C-probe-{1,2}-status.validation.json
|
||||||
|
evidence/*-process.capture.json
|
||||||
evidence/D-*.png + D-status.validation.json
|
evidence/D-*.png + D-status.validation.json
|
||||||
evidence/E-*.png + E-status.validation.json
|
evidence/E-*.png + E-status.validation.json
|
||||||
evidence/F-*.png + F-status.validation.json
|
evidence/F-*.png + F-status.validation.json
|
||||||
|
|
|
||||||
|
|
@ -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,28 +67,74 @@ 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))
|
||||||
|
{
|
||||||
|
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);
|
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
|
||||||
?? throw new InvalidOperationException("Exclusive startup lease is missing."))
|
?? throw new InvalidOperationException("Exclusive startup lease is missing."))
|
||||||
{
|
{
|
||||||
|
|
@ -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
|
||||||
|
or SelfUpdatePlanState.RolledBack)
|
||||||
|
{
|
||||||
|
if (plan.State == SelfUpdatePlanState.Applying)
|
||||||
|
{
|
||||||
|
plan = await manager.RecoverApplyingAsync(
|
||||||
baseDirectory,
|
baseDirectory,
|
||||||
GetLauncherFileName(plan.Rid));
|
cancellationToken)
|
||||||
if (!PathsEqual(executable, expectedExecutable))
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.State != SelfUpdatePlanState.RolledBack)
|
||||||
{
|
{
|
||||||
throw new LauncherUpdateException(
|
throw new LauncherUpdateException(
|
||||||
"Self-update can start only from the published acdream-launcher executable.");
|
"The interrupted self-update did not produce a rollback receipt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.CompleteRolledBackAsync(
|
||||||
|
plan.TransactionId,
|
||||||
|
baseDirectory,
|
||||||
|
lease,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
_ = manager.CleanupOwnedResidueUnderLease(
|
||||||
|
pending: null,
|
||||||
|
baseDirectory,
|
||||||
|
lease);
|
||||||
|
return new SelfUpdateStartupResult(false, 0, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.State != SelfUpdatePlanState.Staged)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"Self-update 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Release the helper's exclusive barrier before restarting the
|
||||||
|
// restored canonical launcher. It will observe the durable RolledBack
|
||||||
|
// receipt through the ordinary startup path, re-verify it, finalize
|
||||||
|
// recovery, and continue with no privileged bypass argument.
|
||||||
|
if (restoredStart is null || Process.Start(restoredStart) is null)
|
||||||
|
{
|
||||||
|
return 75;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task FinishConfirmedCleanupAsync(
|
return 74;
|
||||||
LauncherSelfUpdateManager manager,
|
|
||||||
string targetDirectory,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout;
|
|
||||||
do
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
if (manager.Barrier.TryAcquireExclusive(
|
|
||||||
out UpdateSessionBarrier.ExclusiveLease? lease))
|
|
||||||
{
|
|
||||||
using (UpdateSessionBarrier.ExclusiveLease acquiredLease = lease
|
|
||||||
?? throw new InvalidOperationException(
|
|
||||||
"Exclusive cleanup lease is missing."))
|
|
||||||
{
|
|
||||||
SelfUpdatePlan? pending = await manager.LoadPendingAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
if (pending is
|
|
||||||
{
|
|
||||||
State: SelfUpdatePlanState.AwaitingConfirmation,
|
|
||||||
}
|
|
||||||
&& manager.IsConfirmed(pending.TransactionId))
|
|
||||||
{
|
|
||||||
await manager.CompleteConfirmedAsync(
|
|
||||||
pending.TransactionId,
|
|
||||||
targetDirectory,
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
pending = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manager.CleanupOwnedResidueUnderLease(
|
|
||||||
pending,
|
|
||||||
targetDirectory,
|
|
||||||
acquiredLease))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
while (DateTimeOffset.UtcNow < deadline);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ internal enum LauncherStartupMode
|
||||||
VerifyPublish,
|
VerifyPublish,
|
||||||
SelfUpdateHelper,
|
SelfUpdateHelper,
|
||||||
SelfUpdateConfirmation,
|
SelfUpdateConfirmation,
|
||||||
SelfUpdateDeferred,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -19,12 +18,6 @@ internal enum LauncherStartupMode
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class LauncherStartupOptions
|
internal sealed class LauncherStartupOptions
|
||||||
{
|
{
|
||||||
// This prefix is consumed only after LauncherSelfUpdateBootstrap has
|
|
||||||
// already decided to continue after a recovered rollback. Keep it local
|
|
||||||
// so the process-level bootstrap can remain internal to Launcher.Core.
|
|
||||||
private const string DeferredSelfUpdateArgument =
|
|
||||||
"--acdream-self-update-deferred-v1";
|
|
||||||
|
|
||||||
private readonly IReadOnlyList<string> _publicArguments;
|
private readonly IReadOnlyList<string> _publicArguments;
|
||||||
|
|
||||||
private LauncherStartupOptions(
|
private LauncherStartupOptions(
|
||||||
|
|
@ -213,14 +206,6 @@ internal sealed class LauncherStartupOptions
|
||||||
arguments.Count >= 2 ? 2 : arguments.Count);
|
arguments.Count >= 2 ? 2 : arguments.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(
|
|
||||||
arguments[0],
|
|
||||||
DeferredSelfUpdateArgument,
|
|
||||||
StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
return (LauncherStartupMode.SelfUpdateDeferred, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (LauncherStartupMode.Desktop, 0);
|
return (LauncherStartupMode.Desktop, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -180,6 +182,34 @@ static int CanonicalProbe(string[] arguments)
|
||||||
return 0;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
static async Task<int> HoldUpdateLeaseAsync(string[] arguments)
|
static async Task<int> HoldUpdateLeaseAsync(string[] arguments)
|
||||||
{
|
{
|
||||||
if (arguments.Length != 4
|
if (arguments.Length != 4
|
||||||
|
|
|
||||||
|
|
@ -295,16 +295,21 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
||||||
Assert.Equal(publicArguments, ordinary.RemainingArguments);
|
Assert.Equal(publicArguments, ordinary.RemainingArguments);
|
||||||
|
|
||||||
SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync(
|
SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
[LauncherSelfUpdateBootstrap.DeferredArgument, .. publicArguments],
|
["--acdream-self-update-deferred-v1", .. publicArguments],
|
||||||
harness.Manager,
|
harness.Manager,
|
||||||
harness.Target,
|
harness.Target,
|
||||||
harness.LauncherPath);
|
harness.LauncherPath);
|
||||||
Assert.False(deferred.ShouldExit);
|
Assert.True(deferred.ShouldExit);
|
||||||
Assert.Equal(publicArguments, deferred.RemainingArguments);
|
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;
|
||||||
|
using (UpdateSessionBarrier.ExclusiveLease helperLease =
|
||||||
|
harness.Manager.Barrier.AcquireExclusive())
|
||||||
|
{
|
||||||
|
confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
[
|
[
|
||||||
LauncherSelfUpdateBootstrap.ConfirmArgument,
|
LauncherSelfUpdateBootstrap.ConfirmArgument,
|
||||||
applied.TransactionId,
|
applied.TransactionId,
|
||||||
|
|
@ -313,11 +318,205 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
||||||
harness.Manager,
|
harness.Manager,
|
||||||
harness.Target,
|
harness.Target,
|
||||||
harness.LauncherPath);
|
harness.LauncherPath);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.False(confirmation.ShouldExit);
|
Assert.False(confirmation.ShouldExit);
|
||||||
Assert.Equal(publicArguments, confirmation.RemainingArguments);
|
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));
|
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
|
||||||
Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContendedOrdinaryStartupAllowsOnlyNoPlanOrValidatedStagedPlan()
|
||||||
|
{
|
||||||
|
using var harness = new Harness(_root);
|
||||||
|
using (UpdateSessionBarrier.SessionLease session =
|
||||||
|
harness.Manager.Barrier.AcquireSession())
|
||||||
|
{
|
||||||
|
SelfUpdateStartupResult empty = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
["ordinary"],
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath);
|
||||||
|
Assert.False(empty.ShouldExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = await harness.StageAsync();
|
||||||
|
using (UpdateSessionBarrier.SessionLease session =
|
||||||
|
harness.Manager.Barrier.AcquireSession())
|
||||||
|
{
|
||||||
|
SelfUpdateStartupResult staged = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
["ordinary"],
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath);
|
||||||
|
Assert.False(staged.ShouldExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
SelfUpdatePlan awaiting = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||||
|
using (UpdateSessionBarrier.SessionLease session =
|
||||||
|
harness.Manager.Barrier.AcquireSession())
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||||
|
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
["ordinary"],
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
SelfUpdatePlan rolledBack = await harness.Manager
|
||||||
|
.RollbackAwaitingConfirmationAsync(harness.Target);
|
||||||
|
using (UpdateSessionBarrier.SessionLease session =
|
||||||
|
harness.Manager.Barrier.AcquireSession())
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||||
|
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
["ordinary"],
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
await SetPlanStateAsync(harness.Manager.PendingPlanPath, "applying");
|
||||||
|
using (UpdateSessionBarrier.SessionLease session =
|
||||||
|
harness.Manager.Barrier.AcquireSession())
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||||
|
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
["ordinary"],
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, awaiting.State);
|
||||||
|
Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OrdinaryStartupRecoversApplyingAndFinalizesVerifiedRollback()
|
||||||
|
{
|
||||||
|
using var harness = new Harness(_root);
|
||||||
|
_ = await harness.StageAsync();
|
||||||
|
_ = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||||
|
await SetPlanStateAsync(harness.Manager.PendingPlanPath, "applying");
|
||||||
|
|
||||||
|
SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
["ordinary"],
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath);
|
||||||
|
|
||||||
|
Assert.False(result.ShouldExit);
|
||||||
|
Assert.Equal(["ordinary"], result.RemainingArguments);
|
||||||
|
Assert.Null(await harness.Manager.LoadPendingAsync());
|
||||||
|
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||||
|
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InternalPrefixSpoofsCannotCrossPlanStateOrExecutableTrust()
|
||||||
|
{
|
||||||
|
using var harness = new Harness(_root);
|
||||||
|
_ = await harness.StageAsync();
|
||||||
|
SelfUpdatePlan staged = Assert.IsType<SelfUpdatePlan>(
|
||||||
|
await harness.Manager.LoadPendingAsync());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||||
|
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
[
|
||||||
|
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||||
|
int.MaxValue.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
harness.Target,
|
||||||
|
staged.TransactionId,
|
||||||
|
],
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath));
|
||||||
|
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||||
|
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
[LauncherSelfUpdateBootstrap.ConfirmArgument, staged.TransactionId],
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath));
|
||||||
|
|
||||||
|
SelfUpdatePlan awaiting = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||||
|
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||||
|
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
[LauncherSelfUpdateBootstrap.ConfirmArgument, awaiting.TransactionId],
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
Path.Combine(harness.Target, "spoof-launcher")));
|
||||||
|
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||||
|
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
[
|
||||||
|
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||||
|
int.MaxValue.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
harness.Target,
|
||||||
|
awaiting.TransactionId,
|
||||||
|
],
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath));
|
||||||
|
|
||||||
|
SelfUpdatePlan rolledBack = await harness.Manager
|
||||||
|
.RollbackAwaitingConfirmationAsync(harness.Target);
|
||||||
|
foreach (string prefix in new[]
|
||||||
|
{
|
||||||
|
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||||
|
LauncherSelfUpdateBootstrap.ConfirmArgument,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
string[] arguments = prefix == LauncherSelfUpdateBootstrap.HelperArgument
|
||||||
|
? [prefix, int.MaxValue.ToString(), harness.Target, rolledBack.TransactionId]
|
||||||
|
: [prefix, rolledBack.TransactionId];
|
||||||
|
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||||
|
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
arguments,
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
["--acdream-self-update-deferred-v1"],
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath);
|
||||||
|
Assert.True(deferred.ShouldExit);
|
||||||
|
Assert.Equal(64, deferred.ExitCode);
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(harness.Manager.PendingPlanPath, "{ambiguous");
|
||||||
|
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||||
|
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
[LauncherSelfUpdateBootstrap.ConfirmArgument, rolledBack.TransactionId],
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath));
|
||||||
|
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||||
|
LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
[
|
||||||
|
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||||
|
int.MaxValue.ToString(),
|
||||||
|
harness.Target,
|
||||||
|
rolledBack.TransactionId,
|
||||||
|
],
|
||||||
|
harness.Manager,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SetPlanStateAsync(string path, string state)
|
||||||
|
{
|
||||||
|
JsonObject plan = Assert.IsType<JsonObject>(JsonNode.Parse(
|
||||||
|
await File.ReadAllTextAsync(path)));
|
||||||
|
plan["state"] = state;
|
||||||
|
await File.WriteAllTextAsync(path, plan.ToJsonString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class Harness : IDisposable
|
private sealed class Harness : IDisposable
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ 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");
|
||||||
|
|
@ -94,13 +94,21 @@ 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-*",
|
||||||
|
|
@ -113,11 +121,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||||
launchMarker,
|
launchMarker,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
int replacementPid = ParsePid(launchMarker);
|
int replacementPid = ParsePid(launchMarker);
|
||||||
int helperPid = int.Parse(
|
|
||||||
await File.ReadAllTextAsync(helperPidPath),
|
|
||||||
System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
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(
|
||||||
|
|
@ -156,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>(
|
||||||
|
|
@ -204,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));
|
||||||
|
|
@ -297,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));
|
||||||
|
|
||||||
|
|
@ -328,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();
|
||||||
|
|
@ -354,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),
|
||||||
|
|
@ -365,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();
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ public sealed class LauncherStartupOptionsTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DeferredSelfUpdateRestartRetainsIsolationWithoutResolvingDefaults()
|
public void LegacyDeferredSelfUpdatePrefixIsRejectedAsUntrustedInput()
|
||||||
{
|
{
|
||||||
string root = Path.GetFullPath(
|
string root = Path.GetFullPath(
|
||||||
Path.Combine(Path.GetTempPath(), "acdream-la11-deferred"));
|
Path.Combine(Path.GetTempPath(), "acdream-la11-deferred"));
|
||||||
|
|
@ -159,16 +159,11 @@ public sealed class LauncherStartupOptionsTests
|
||||||
"--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
|
"--update-manifest-uri", "http://127.0.0.1:43119/manifest.json",
|
||||||
];
|
];
|
||||||
|
|
||||||
LauncherStartupOptions options = LauncherStartupOptions.Parse(
|
Assert.Throws<LauncherStartupOptionsException>(() =>
|
||||||
|
LauncherStartupOptions.Parse(
|
||||||
["--acdream-self-update-deferred-v1", .. suffix],
|
["--acdream-self-update-deferred-v1", .. suffix],
|
||||||
() => throw new InvalidOperationException(
|
() => throw new InvalidOperationException(
|
||||||
"canonical path resolver was touched"));
|
"canonical path resolver was touched")));
|
||||||
|
|
||||||
Assert.Equal(LauncherStartupMode.SelfUpdateDeferred, options.Mode);
|
|
||||||
Assert.Equal(suffix, options.PublicArguments);
|
|
||||||
Assert.Equal(Path.Combine(root, "config"), options.Paths.ConfigDirectory);
|
|
||||||
Assert.Equal(Path.Combine(root, "data"), options.Paths.DataDirectory);
|
|
||||||
Assert.Equal(Path.Combine(root, "cache"), options.Paths.CacheDirectory);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
93
tools/CampaignLaProcessCorrelation.ps1
Normal file
93
tools/CampaignLaProcessCorrelation.ps1
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
|
function Get-CampaignLaSessionProcessCorrelations {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
$matches = [Collections.Generic.List[object]]::new()
|
||||||
|
if ($IsWindows) {
|
||||||
|
$pattern = '(?i)(?:^|\s)(?:--config|--session-config)\s+(?:"([^"]+)"|(\S+))'
|
||||||
|
foreach ($candidate in @(Get-CimInstance Win32_Process -ErrorAction Stop)) {
|
||||||
|
$commandLine = [string]$candidate.CommandLine
|
||||||
|
if ([string]::IsNullOrWhiteSpace($commandLine)) { continue }
|
||||||
|
foreach ($match in [Text.RegularExpressions.Regex]::Matches(
|
||||||
|
$commandLine,
|
||||||
|
$pattern)) {
|
||||||
|
$value = if ($match.Groups[1].Success) {
|
||||||
|
$match.Groups[1].Value
|
||||||
|
} else { $match.Groups[2].Value }
|
||||||
|
if ([IO.Path]::IsPathFullyQualified($value)) {
|
||||||
|
$matches.Add([pscustomobject]@{
|
||||||
|
ProcessId = [int]$candidate.ProcessId
|
||||||
|
SessionConfigPath = [IO.Path]::GetFullPath($value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($IsLinux) {
|
||||||
|
foreach ($directory in [IO.Directory]::EnumerateDirectories('/proc')) {
|
||||||
|
$leaf = [IO.Path]::GetFileName($directory)
|
||||||
|
$processId = 0
|
||||||
|
if (-not [int]::TryParse(
|
||||||
|
$leaf,
|
||||||
|
[Globalization.NumberStyles]::None,
|
||||||
|
[Globalization.CultureInfo]::InvariantCulture,
|
||||||
|
[ref]$processId)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$bytes = [IO.File]::ReadAllBytes((Join-Path $directory 'cmdline'))
|
||||||
|
if ($bytes.Length -eq 0) { continue }
|
||||||
|
$arguments = @([Text.Encoding]::UTF8.GetString($bytes).Split(
|
||||||
|
[char]0,
|
||||||
|
[StringSplitOptions]::RemoveEmptyEntries))
|
||||||
|
for ($index = 0; $index + 1 -lt $arguments.Count; $index++) {
|
||||||
|
if ($arguments[$index] -cin @('--config', '--session-config') -and
|
||||||
|
[IO.Path]::IsPathFullyQualified($arguments[$index + 1])) {
|
||||||
|
$matches.Add([pscustomobject]@{
|
||||||
|
ProcessId = $processId
|
||||||
|
SessionConfigPath = [IO.Path]::GetFullPath(
|
||||||
|
$arguments[$index + 1])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch [IO.IOException] {
|
||||||
|
# A process may exit between /proc enumeration and cmdline read.
|
||||||
|
}
|
||||||
|
catch [UnauthorizedAccessException] {
|
||||||
|
# Other-user processes cannot be the owner-readable gate child.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw 'Campaign LA process correlation supports Windows and Linux only.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return @($matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-CampaignLaCorrelatedProcessIds {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param([Parameter(Mandatory = $true)][string]$SessionConfigPath)
|
||||||
|
|
||||||
|
if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) {
|
||||||
|
throw 'Session-config correlation requires an absolute path.'
|
||||||
|
}
|
||||||
|
$SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath)
|
||||||
|
$comparison = if ($IsWindows) {
|
||||||
|
[StringComparison]::OrdinalIgnoreCase
|
||||||
|
} else { [StringComparison]::Ordinal }
|
||||||
|
$matches = [Collections.Generic.HashSet[int]]::new()
|
||||||
|
foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) {
|
||||||
|
if ([string]::Equals(
|
||||||
|
$candidate.SessionConfigPath,
|
||||||
|
$SessionConfigPath,
|
||||||
|
$comparison)) {
|
||||||
|
$null = $matches.Add([int]$candidate.ProcessId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return @($matches | Sort-Object)
|
||||||
|
}
|
||||||
109
tools/capture-campaign-la-session-process.ps1
Normal file
109
tools/capture-campaign-la-session-process.ps1
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Captures one launcher child PID by its unique isolated session-config path.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Writes a sanitized gate-only sidecar. It never reads the session-config
|
||||||
|
contents and records no command line, account, character, or credential.
|
||||||
|
#>
|
||||||
|
[CmdletBinding(DefaultParameterSetName = 'Path')]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true, ParameterSetName = 'Path')]
|
||||||
|
[string]$SessionConfigPath,
|
||||||
|
[Parameter(Mandatory = $true, ParameterSetName = 'Directory')]
|
||||||
|
[string]$SessionsDirectory,
|
||||||
|
[Parameter(ParameterSetName = 'Directory')]
|
||||||
|
[DateTimeOffset]$CreatedAfterUtc = [DateTimeOffset]::MinValue,
|
||||||
|
[Parameter(Mandatory = $true)][string]$ReportPath,
|
||||||
|
[ValidateRange(1, 60)][int]$WaitSeconds = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||||
|
throw 'Campaign LA PID capture requires PowerShell 7 or newer.'
|
||||||
|
}
|
||||||
|
. (Join-Path $PSScriptRoot 'CampaignLaProcessCorrelation.ps1')
|
||||||
|
|
||||||
|
if ($PSCmdlet.ParameterSetName -eq 'Path') {
|
||||||
|
if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) {
|
||||||
|
throw '-SessionConfigPath must be absolute.'
|
||||||
|
}
|
||||||
|
$SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath)
|
||||||
|
if (-not (Test-Path -LiteralPath $SessionConfigPath -PathType Leaf)) {
|
||||||
|
throw "Session config does not exist: $SessionConfigPath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (-not [IO.Path]::IsPathFullyQualified($SessionsDirectory)) {
|
||||||
|
throw '-SessionsDirectory must be absolute.'
|
||||||
|
}
|
||||||
|
$SessionsDirectory = [IO.Path]::TrimEndingDirectorySeparator(
|
||||||
|
[IO.Path]::GetFullPath($SessionsDirectory))
|
||||||
|
if (-not (Test-Path -LiteralPath $SessionsDirectory -PathType Container)) {
|
||||||
|
throw "Sessions directory does not exist: $SessionsDirectory"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not [IO.Path]::IsPathFullyQualified($ReportPath)) {
|
||||||
|
throw '-ReportPath must be absolute.'
|
||||||
|
}
|
||||||
|
$ReportPath = [IO.Path]::GetFullPath($ReportPath)
|
||||||
|
if (Test-Path -LiteralPath $ReportPath) {
|
||||||
|
throw '-ReportPath must be fresh.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$deadline = [DateTime]::UtcNow.AddSeconds($WaitSeconds)
|
||||||
|
do {
|
||||||
|
if ($PSCmdlet.ParameterSetName -eq 'Path') {
|
||||||
|
$correlations = @(Get-CampaignLaSessionProcessCorrelations |
|
||||||
|
Where-Object {
|
||||||
|
$comparison = if ($IsWindows) {
|
||||||
|
[StringComparison]::OrdinalIgnoreCase
|
||||||
|
} else { [StringComparison]::Ordinal }
|
||||||
|
[string]::Equals(
|
||||||
|
$_.SessionConfigPath,
|
||||||
|
$SessionConfigPath,
|
||||||
|
$comparison)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$comparison = if ($IsWindows) {
|
||||||
|
[StringComparison]::OrdinalIgnoreCase
|
||||||
|
} else { [StringComparison]::Ordinal }
|
||||||
|
$prefix = $SessionsDirectory + [IO.Path]::DirectorySeparatorChar
|
||||||
|
$correlations = @(Get-CampaignLaSessionProcessCorrelations |
|
||||||
|
Where-Object {
|
||||||
|
$_.SessionConfigPath.StartsWith($prefix, $comparison) -and
|
||||||
|
[IO.Path]::GetFileName($_.SessionConfigPath) -ceq 'session.json' -and
|
||||||
|
(Test-Path -LiteralPath $_.SessionConfigPath -PathType Leaf) -and
|
||||||
|
(Get-Item -LiteralPath $_.SessionConfigPath).LastWriteTimeUtc -ge
|
||||||
|
$CreatedAfterUtc.UtcDateTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ($correlations.Count -eq 1) { break }
|
||||||
|
if ($correlations.Count -gt 1) {
|
||||||
|
throw "More than one process uses the isolated session config."
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 100
|
||||||
|
} while ([DateTime]::UtcNow -lt $deadline)
|
||||||
|
if ($correlations.Count -ne 1) {
|
||||||
|
throw 'No live process uses the isolated session config.'
|
||||||
|
}
|
||||||
|
$SessionConfigPath = [IO.Path]::GetFullPath($correlations[0].SessionConfigPath)
|
||||||
|
|
||||||
|
$directory = Split-Path -Parent $ReportPath
|
||||||
|
if (-not [string]::IsNullOrEmpty($directory)) {
|
||||||
|
$null = New-Item -ItemType Directory -Force -Path $directory
|
||||||
|
}
|
||||||
|
$report = [ordered]@{
|
||||||
|
schemaVersion = 1
|
||||||
|
kind = 'campaign-la-session-process-capture'
|
||||||
|
processId = [int]$correlations[0].ProcessId
|
||||||
|
sessionId = [IO.Path]::GetFileName(
|
||||||
|
[IO.Path]::GetDirectoryName($SessionConfigPath))
|
||||||
|
sessionConfigFile = [IO.Path]::GetFileName($SessionConfigPath)
|
||||||
|
capturedUtc = [DateTime]::UtcNow.ToString('O')
|
||||||
|
}
|
||||||
|
$report | ConvertTo-Json -Depth 3 |
|
||||||
|
Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM
|
||||||
|
Write-Host "Campaign LA process capture: $ReportPath"
|
||||||
|
|
@ -37,6 +37,35 @@ if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
|
||||||
}
|
}
|
||||||
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
|
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
|
||||||
[IO.Path]::GetFullPath($OutputDirectory))
|
[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.' }
|
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-]+)*)?$'
|
$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
|
if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or
|
||||||
|
|
@ -73,6 +102,11 @@ foreach ($key in @($sources.Keys)) {
|
||||||
if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) {
|
if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) {
|
||||||
throw "Payload source '$key' does not exist: $source"
|
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) {
|
function Require-PayloadFile([string]$Key, [string]$Name) {
|
||||||
|
|
@ -98,6 +132,7 @@ if (Test-Path -LiteralPath $OutputDirectory) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else { $null = New-Item -ItemType Directory -Path $OutputDirectory }
|
else { $null = New-Item -ItemType Directory -Path $OutputDirectory }
|
||||||
|
Assert-NoReparseAncestry $OutputDirectory 'Output directory'
|
||||||
|
|
||||||
if ($DryRun) {
|
if ($DryRun) {
|
||||||
$plan = [ordered]@{
|
$plan = [ordered]@{
|
||||||
|
|
@ -121,6 +156,73 @@ Add-Type -AssemblyName System.IO.Compression
|
||||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
$fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
|
$fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
|
||||||
|
|
||||||
|
function Get-LittleEndianUInt16([byte[]]$Bytes, [int]$Offset) {
|
||||||
|
return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-LittleEndianUInt32([byte[]]$Bytes, [int]$Offset) {
|
||||||
|
return [uint32]([uint32]$Bytes[$Offset] -bor
|
||||||
|
([uint32]$Bytes[$Offset + 1] -shl 8) -bor
|
||||||
|
([uint32]$Bytes[$Offset + 2] -shl 16) -bor
|
||||||
|
([uint32]$Bytes[$Offset + 3] -shl 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-DeterministicZipHostPlatform([string]$Path) {
|
||||||
|
[byte[]]$bytes = [IO.File]::ReadAllBytes($Path)
|
||||||
|
$minimumEocdSize = 22
|
||||||
|
if ($bytes.Length -lt $minimumEocdSize) {
|
||||||
|
throw "Generated ZIP is too short: $Path"
|
||||||
|
}
|
||||||
|
|
||||||
|
$eocd = -1
|
||||||
|
$minimumOffset = [Math]::Max(0, $bytes.Length - 65557)
|
||||||
|
for ($offset = $bytes.Length - $minimumEocdSize; $offset -ge $minimumOffset; $offset--) {
|
||||||
|
if ((Get-LittleEndianUInt32 $bytes $offset) -eq 0x06054b50) {
|
||||||
|
$commentLength = Get-LittleEndianUInt16 $bytes ($offset + 20)
|
||||||
|
if ($offset + $minimumEocdSize + $commentLength -eq $bytes.Length) {
|
||||||
|
$eocd = $offset
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($eocd -lt 0) { throw "Generated ZIP has no valid end record: $Path" }
|
||||||
|
if ((Get-LittleEndianUInt16 $bytes ($eocd + 4)) -ne 0 -or
|
||||||
|
(Get-LittleEndianUInt16 $bytes ($eocd + 6)) -ne 0) {
|
||||||
|
throw "Generated ZIP unexpectedly spans multiple disks: $Path"
|
||||||
|
}
|
||||||
|
|
||||||
|
$entriesOnDisk = Get-LittleEndianUInt16 $bytes ($eocd + 8)
|
||||||
|
$entryCount = Get-LittleEndianUInt16 $bytes ($eocd + 10)
|
||||||
|
if ($entriesOnDisk -ne $entryCount) {
|
||||||
|
throw "Generated ZIP central-directory count is inconsistent: $Path"
|
||||||
|
}
|
||||||
|
$centralSize = Get-LittleEndianUInt32 $bytes ($eocd + 12)
|
||||||
|
$centralOffset = Get-LittleEndianUInt32 $bytes ($eocd + 16)
|
||||||
|
if ([uint64]$centralOffset + [uint64]$centralSize -ne [uint64]$eocd) {
|
||||||
|
throw "Generated ZIP central-directory bounds are inconsistent: $Path"
|
||||||
|
}
|
||||||
|
|
||||||
|
[uint64]$cursor = $centralOffset
|
||||||
|
for ($index = 0; $index -lt $entryCount; $index++) {
|
||||||
|
if ($cursor + 46 -gt $eocd -or
|
||||||
|
(Get-LittleEndianUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) {
|
||||||
|
throw "Generated ZIP central-directory entry is invalid: $Path"
|
||||||
|
}
|
||||||
|
# ZipArchive intentionally stamps the creating host (FAT on Windows,
|
||||||
|
# Unix on Linux) in the upper byte of "version made by". Normalize it
|
||||||
|
# to FAT; permissions are already explicit in ExternalAttributes.
|
||||||
|
$bytes[[int]$cursor + 5] = 0
|
||||||
|
$nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28)
|
||||||
|
$extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30)
|
||||||
|
$commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32)
|
||||||
|
$cursor += 46 + $nameLength + $extraLength + $commentLength
|
||||||
|
}
|
||||||
|
if ($cursor -ne $eocd) {
|
||||||
|
throw "Generated ZIP central-directory length is inconsistent: $Path"
|
||||||
|
}
|
||||||
|
[IO.File]::WriteAllBytes($Path, $bytes)
|
||||||
|
}
|
||||||
|
|
||||||
function New-DeterministicZip(
|
function New-DeterministicZip(
|
||||||
[string]$SourceDirectory,
|
[string]$SourceDirectory,
|
||||||
[string]$Destination,
|
[string]$Destination,
|
||||||
|
|
@ -141,17 +243,30 @@ function New-DeterministicZip(
|
||||||
$true,
|
$true,
|
||||||
[Text.Encoding]::UTF8)
|
[Text.Encoding]::UTF8)
|
||||||
try {
|
try {
|
||||||
$files = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Recurse |
|
$allEntries = @(Get-ChildItem -LiteralPath $SourceDirectory -Force -Recurse)
|
||||||
Sort-Object { [IO.Path]::GetRelativePath($SourceDirectory, $_.FullName).Replace('\', '/') })
|
foreach ($item in $allEntries) {
|
||||||
foreach ($file in $files) {
|
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||||
if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
throw "Payload contains a reparse point: $($item.FullName)"
|
||||||
throw "Payload contains a reparse point: $($file.FullName)"
|
|
||||||
}
|
}
|
||||||
$relative = [IO.Path]::GetRelativePath($SourceDirectory, $file.FullName).Replace('\', '/')
|
}
|
||||||
|
[string[]]$files = @($allEntries |
|
||||||
|
Where-Object { -not $_.PSIsContainer } |
|
||||||
|
ForEach-Object {
|
||||||
|
[IO.Path]::GetRelativePath(
|
||||||
|
$SourceDirectory,
|
||||||
|
$_.FullName).Replace('\', '/')
|
||||||
|
})
|
||||||
|
[Array]::Sort($files, [StringComparer]::Ordinal)
|
||||||
|
$caseFolded = [Collections.Generic.HashSet[string]]::new(
|
||||||
|
[StringComparer]::OrdinalIgnoreCase)
|
||||||
|
foreach ($relative in $files) {
|
||||||
if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or
|
if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or
|
||||||
[IO.Path]::IsPathRooted($relative)) {
|
[IO.Path]::IsPathRooted($relative) -or
|
||||||
|
-not $caseFolded.Add($relative)) {
|
||||||
throw "Payload path escaped its root: $relative"
|
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 = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal)
|
||||||
$entry.LastWriteTime = $fixedTimestamp
|
$entry.LastWriteTime = $fixedTimestamp
|
||||||
$executable = $relative -ceq 'AcDream.App' -or
|
$executable = $relative -ceq 'AcDream.App' -or
|
||||||
|
|
@ -183,6 +298,7 @@ function New-DeterministicZip(
|
||||||
finally { $archive.Dispose() }
|
finally { $archive.Dispose() }
|
||||||
}
|
}
|
||||||
finally { $stream.Dispose() }
|
finally { $stream.Dispose() }
|
||||||
|
Set-DeterministicZipHostPlatform $Destination
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-Artifact([string]$Path, [string]$Url) {
|
function Get-Artifact([string]$Path, [string]$Url) {
|
||||||
|
|
@ -232,11 +348,15 @@ foreach ($release in $releaseDefinitions) {
|
||||||
"$baseUri/launcher-linux-x64.zip"
|
"$baseUri/launcher-linux-x64.zip"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$manifest | ConvertTo-Json -Depth 8 -Compress |
|
[IO.File]::WriteAllText(
|
||||||
Set-Content -LiteralPath (Join-Path $releaseRoot 'manifest.json') -Encoding utf8NoBOM
|
(Join-Path $releaseRoot 'manifest.json'),
|
||||||
|
($manifest | ConvertTo-Json -Depth 8 -Compress),
|
||||||
|
[Text.UTF8Encoding]::new($false))
|
||||||
}
|
}
|
||||||
Set-Content -LiteralPath (Join-Path $OutputDirectory 'active-release.txt') `
|
[IO.File]::WriteAllText(
|
||||||
-Value 'A' -Encoding ascii -NoNewline
|
(Join-Path $OutputDirectory 'active-release.txt'),
|
||||||
|
'A',
|
||||||
|
[Text.Encoding]::ASCII)
|
||||||
|
|
||||||
$server = @'
|
$server = @'
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
|
|
@ -311,7 +431,10 @@ try {
|
||||||
}
|
}
|
||||||
finally { $listener.Close() }
|
finally { $listener.Close() }
|
||||||
'@
|
'@
|
||||||
$server | Set-Content -LiteralPath (Join-Path $OutputDirectory 'serve-fixture.ps1') -Encoding utf8NoBOM
|
[IO.File]::WriteAllText(
|
||||||
|
(Join-Path $OutputDirectory 'serve-fixture.ps1'),
|
||||||
|
$server.Replace("`r`n", "`n"),
|
||||||
|
[Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
$selector = @'
|
$selector = @'
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
|
|
@ -340,16 +463,24 @@ finally {
|
||||||
}
|
}
|
||||||
Write-Host "Campaign LA fixture active release: $Release"
|
Write-Host "Campaign LA fixture active release: $Release"
|
||||||
'@
|
'@
|
||||||
$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM
|
[IO.File]::WriteAllText(
|
||||||
|
(Join-Path $OutputDirectory 'set-active-release.ps1'),
|
||||||
|
$selector.Replace("`r`n", "`n"),
|
||||||
|
[Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
$inventory = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
|
$inventoryPaths = [string[]]@(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
|
||||||
Where-Object { $_.Name -ne 'fixture-report.json' } |
|
Where-Object { $_.Name -ne 'fixture-report.json' } |
|
||||||
Sort-Object FullName |
|
|
||||||
ForEach-Object {
|
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]@{
|
[ordered]@{
|
||||||
path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
|
path = $_
|
||||||
size = $_.Length
|
size = $item.Length
|
||||||
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
|
sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
$report = [ordered]@{
|
$report = [ordered]@{
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
|
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
|
||||||
|
[Parameter(Mandatory = $true)][string]$AllowedOutputRoot,
|
||||||
[string]$OutputDirectory,
|
[string]$OutputDirectory,
|
||||||
[switch]$DryRun,
|
[switch]$DryRun,
|
||||||
[switch]$IncludeInstalledDat,
|
[switch]$IncludeInstalledDat,
|
||||||
|
|
@ -30,6 +31,66 @@ $Repository = [IO.Path]::TrimEndingDirectorySeparator(
|
||||||
if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) {
|
if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) {
|
||||||
throw "Repository does not contain AcDream.slnx: $Repository"
|
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 ($IncludeInstalledDat) {
|
||||||
if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or
|
if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or
|
||||||
-not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) {
|
-not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) {
|
||||||
|
|
@ -50,16 +111,28 @@ if ($IncludeInstalledDat) {
|
||||||
|
|
||||||
$stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
|
$stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
|
||||||
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
|
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
|
||||||
$OutputDirectory = Join-Path $Repository "logs/campaign-la-gate-$stamp"
|
$OutputDirectory = Join-Path $AllowedOutputRoot "campaign-la-preflight-$stamp"
|
||||||
}
|
}
|
||||||
elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
|
elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
|
||||||
$OutputDirectory = Join-Path $Repository $OutputDirectory
|
throw '-OutputDirectory must be absolute when supplied.'
|
||||||
}
|
}
|
||||||
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
|
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
|
||||||
[IO.Path]::GetFullPath($OutputDirectory))
|
[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'
|
$logsDirectory = Join-Path $OutputDirectory 'commands'
|
||||||
$publishDirectory = Join-Path $OutputDirectory 'publish'
|
$publishDirectory = Join-Path $OutputDirectory 'publish'
|
||||||
$null = New-Item -ItemType Directory -Force -Path $logsDirectory
|
$null = New-Item -ItemType Directory -Path $logsDirectory
|
||||||
|
|
||||||
$commandResults = [Collections.Generic.List[object]]::new()
|
$commandResults = [Collections.Generic.List[object]]::new()
|
||||||
$failures = [Collections.Generic.List[string]]::new()
|
$failures = [Collections.Generic.List[string]]::new()
|
||||||
|
|
@ -245,6 +318,22 @@ $portableTestProjects = @(
|
||||||
try {
|
try {
|
||||||
Invoke-DotNet 'release-build' @(
|
Invoke-DotNet 'release-build' @(
|
||||||
'build', 'AcDream.slnx', '-c', 'Release', '--nologo', '-m:1')
|
'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' @(
|
Invoke-DotNet 'release-tests-serial' @(
|
||||||
'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1',
|
'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1',
|
||||||
'--', 'RunConfiguration.MaxCpuCount=1')
|
'--', 'RunConfiguration.MaxCpuCount=1')
|
||||||
|
|
@ -384,14 +473,20 @@ finally {
|
||||||
$dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all)
|
$dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all)
|
||||||
$artifacts = @()
|
$artifacts = @()
|
||||||
if (-not $DryRun) {
|
if (-not $DryRun) {
|
||||||
$artifacts = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
|
[string[]]$artifactPaths = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
|
||||||
Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } |
|
Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } |
|
||||||
Sort-Object FullName |
|
|
||||||
ForEach-Object {
|
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]@{
|
[ordered]@{
|
||||||
path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
|
path = $_
|
||||||
size = $_.Length
|
size = $item.Length
|
||||||
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
|
sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -402,6 +497,7 @@ finally {
|
||||||
dryRun = [bool]$DryRun
|
dryRun = [bool]$DryRun
|
||||||
success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0)
|
success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0)
|
||||||
repository = $Repository
|
repository = $Repository
|
||||||
|
allowedOutputRoot = $AllowedOutputRoot
|
||||||
head = $head
|
head = $head
|
||||||
dirty = ($dirtyLines.Count -gt 0)
|
dirty = ($dirtyLines.Count -gt 0)
|
||||||
dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ })
|
dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ })
|
||||||
|
|
|
||||||
296
tools/test-campaign-la-gate-helpers.ps1
Normal file
296
tools/test-campaign-la-gate-helpers.ps1
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Connection-free contract tests for Campaign LA gate evidence helpers.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
|
||||||
|
[Parameter(Mandatory = $true)][string]$OutputDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||||
|
throw 'Campaign LA helper tests require PowerShell 7 or newer.'
|
||||||
|
}
|
||||||
|
$Repository = [IO.Path]::GetFullPath($Repository)
|
||||||
|
if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
|
||||||
|
throw '-OutputDirectory must be absolute.'
|
||||||
|
}
|
||||||
|
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
|
||||||
|
if (Test-Path -LiteralPath $OutputDirectory) {
|
||||||
|
throw '-OutputDirectory must be fresh.'
|
||||||
|
}
|
||||||
|
$null = New-Item -ItemType Directory -Path $OutputDirectory
|
||||||
|
$pwsh = [Environment]::ProcessPath
|
||||||
|
if ([string]::IsNullOrWhiteSpace($pwsh)) {
|
||||||
|
throw 'The PowerShell process path is unavailable.'
|
||||||
|
}
|
||||||
|
$validator = Join-Path $Repository 'tools/test-campaign-la-session-status.ps1'
|
||||||
|
$capture = Join-Path $Repository 'tools/capture-campaign-la-session-process.ps1'
|
||||||
|
|
||||||
|
function Write-Profile([string]$Path, [string]$Secret) {
|
||||||
|
$document = [ordered]@{
|
||||||
|
version = 1
|
||||||
|
servers = @([ordered]@{
|
||||||
|
name = 'fixture'
|
||||||
|
host = '127.0.0.1'
|
||||||
|
port = 9000
|
||||||
|
accounts = @([ordered]@{
|
||||||
|
account = 'fixture-account'
|
||||||
|
password = $Secret
|
||||||
|
characters = @()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
[IO.File]::WriteAllText(
|
||||||
|
$Path,
|
||||||
|
($document | ConvertTo-Json -Depth 8),
|
||||||
|
[Text.UTF8Encoding]::new($false))
|
||||||
|
if ($IsLinux) {
|
||||||
|
[IO.File]::SetUnixFileMode(
|
||||||
|
$Path,
|
||||||
|
[IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-GuiEvents {
|
||||||
|
$begin = [DateTimeOffset]::ParseExact(
|
||||||
|
'2026-08-15T10:00:00.0000000+00:00',
|
||||||
|
'O',
|
||||||
|
[Globalization.CultureInfo]::InvariantCulture)
|
||||||
|
$session = 'fixture-session'
|
||||||
|
return @(
|
||||||
|
[ordered]@{ v = 1; e = 'started'; t = $begin.ToString('O'); sessionId = $session },
|
||||||
|
[ordered]@{ v = 1; e = 'pluginLoaded'; t = $begin.AddSeconds(1).ToString('O'); sessionId = $session; plugin = 'smoke' },
|
||||||
|
[ordered]@{ v = 1; e = 'pluginFailed'; t = $begin.AddSeconds(2).ToString('O'); sessionId = $session; plugin = 'optional'; error = 'allowed fixture failure' },
|
||||||
|
[ordered]@{ v = 1; e = 'connected'; t = $begin.AddSeconds(3).ToString('O'); sessionId = $session },
|
||||||
|
[ordered]@{
|
||||||
|
v = 1; e = 'characterList'; t = $begin.AddSeconds(4).ToString('O')
|
||||||
|
sessionId = $session; accountName = 'fixture-account'; slotCount = 1
|
||||||
|
characters = @([ordered]@{ id = 1342177290; name = 'Fixture'; secondsGreyedOut = 0 })
|
||||||
|
},
|
||||||
|
[ordered]@{ v = 1; e = 'enteredWorld'; t = $begin.AddSeconds(5).ToString('O'); sessionId = $session; characterId = 1342177290; characterName = 'Fixture' },
|
||||||
|
[ordered]@{ v = 1; e = 'loginCommandFailed'; t = $begin.AddSeconds(6).ToString('O'); sessionId = $session; commandIndex = 0; command = '/fixture'; error = 'allowed fixture failure' },
|
||||||
|
[ordered]@{ v = 1; e = 'disconnected'; t = $begin.AddSeconds(7).ToString('O'); sessionId = $session; reason = 'stopped' },
|
||||||
|
[ordered]@{ v = 1; e = 'exited'; t = $begin.AddSeconds(8).ToString('O'); sessionId = $session; code = 0; reason = 'graceful' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Events([string]$Path, [object[]]$Events) {
|
||||||
|
$lines = @($Events | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress })
|
||||||
|
[IO.File]::WriteAllLines($Path, $lines, [Text.UTF8Encoding]::new($false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Validator(
|
||||||
|
[string]$Status,
|
||||||
|
[string]$Profile,
|
||||||
|
[string]$Report,
|
||||||
|
[int]$ExpectedProcessId,
|
||||||
|
[bool]$ShouldPass,
|
||||||
|
[string]$SessionConfig = '') {
|
||||||
|
$arguments = [Collections.Generic.List[string]]::new()
|
||||||
|
foreach ($value in @(
|
||||||
|
'-NoProfile', '-File', $validator,
|
||||||
|
'-StatusFile', $Status,
|
||||||
|
'-Mode', 'gui',
|
||||||
|
'-ExpectedProcessId', $ExpectedProcessId.ToString(
|
||||||
|
[Globalization.CultureInfo]::InvariantCulture),
|
||||||
|
'-CredentialProfilePath', $Profile,
|
||||||
|
'-ExpectedPlugin', 'smoke',
|
||||||
|
'-AllowPluginFailure',
|
||||||
|
'-AllowLoginCommandFailure',
|
||||||
|
'-ReportPath', $Report)) {
|
||||||
|
$arguments.Add($value)
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($SessionConfig)) {
|
||||||
|
$arguments.Add('-SessionConfigPath')
|
||||||
|
$arguments.Add($SessionConfig)
|
||||||
|
}
|
||||||
|
$start = [Diagnostics.ProcessStartInfo]::new($pwsh)
|
||||||
|
$start.UseShellExecute = $false
|
||||||
|
$start.CreateNoWindow = $true
|
||||||
|
$start.RedirectStandardOutput = $true
|
||||||
|
$start.RedirectStandardError = $true
|
||||||
|
foreach ($argument in $arguments) { $start.ArgumentList.Add($argument) }
|
||||||
|
$process = [Diagnostics.Process]::Start($start)
|
||||||
|
if ($null -eq $process) { throw 'Could not start status validator.' }
|
||||||
|
$stdout = $process.StandardOutput.ReadToEndAsync()
|
||||||
|
$stderr = $process.StandardError.ReadToEndAsync()
|
||||||
|
$process.WaitForExit()
|
||||||
|
$outText = $stdout.GetAwaiter().GetResult()
|
||||||
|
$errorText = $stderr.GetAwaiter().GetResult()
|
||||||
|
$exitCode = $process.ExitCode
|
||||||
|
$process.Dispose()
|
||||||
|
if (($exitCode -eq 0) -ne $ShouldPass) {
|
||||||
|
throw "Validator result mismatch (exit $exitCode). $outText $errorText"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$quickInfo = [Diagnostics.ProcessStartInfo]::new($pwsh)
|
||||||
|
$quickInfo.UseShellExecute = $false
|
||||||
|
$quickInfo.ArgumentList.Add('-NoProfile')
|
||||||
|
$quickInfo.ArgumentList.Add('-Command')
|
||||||
|
$quickInfo.ArgumentList.Add('exit 0')
|
||||||
|
$quick = [Diagnostics.Process]::Start($quickInfo)
|
||||||
|
if ($null -eq $quick) { throw 'Could not create an exited PID fixture.' }
|
||||||
|
$goneProcessId = $quick.Id
|
||||||
|
$quick.WaitForExit()
|
||||||
|
$quick.Dispose()
|
||||||
|
|
||||||
|
$profile = Join-Path $OutputDirectory 'launcher-profiles.json'
|
||||||
|
Write-Profile $profile 'la11-positive-secret-7E477A2D'
|
||||||
|
$positiveStatus = Join-Path $OutputDirectory 'positive.jsonl'
|
||||||
|
Write-Events $positiveStatus (New-GuiEvents)
|
||||||
|
Invoke-Validator `
|
||||||
|
$positiveStatus $profile (Join-Path $OutputDirectory 'positive.validation.json') `
|
||||||
|
$goneProcessId $true
|
||||||
|
|
||||||
|
foreach ($reason in @('transport', 'reconnect', 'other')) {
|
||||||
|
$events = @(New-GuiEvents)
|
||||||
|
$events[7].reason = $reason
|
||||||
|
$path = Join-Path $OutputDirectory "reason-$reason.jsonl"
|
||||||
|
$report = Join-Path $OutputDirectory "reason-$reason.validation.json"
|
||||||
|
Write-Events $path $events
|
||||||
|
Invoke-Validator $path $profile $report $goneProcessId $false
|
||||||
|
$result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json
|
||||||
|
if (-not ($result.failures -match 'disconnected reason')) {
|
||||||
|
throw "Disconnected reason '$reason' was not rejected by its exact assertion."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$secretCases = @(
|
||||||
|
'eventName', 'timestamp', 'sessionId', 'accountName', 'characterName',
|
||||||
|
'enteredCharacterName', 'loadedPlugin', 'failedPlugin', 'pluginError',
|
||||||
|
'command', 'commandError', 'disconnectedReason', 'exitReason')
|
||||||
|
foreach ($case in $secretCases) {
|
||||||
|
$secret = "la11-secret-$case-5A7D"
|
||||||
|
$caseProfile = Join-Path $OutputDirectory "secret-$case.profile.json"
|
||||||
|
Write-Profile $caseProfile $secret
|
||||||
|
$events = @(New-GuiEvents)
|
||||||
|
switch ($case) {
|
||||||
|
'eventName' { $events[0].e = $secret }
|
||||||
|
'timestamp' { $events[0].t = $secret }
|
||||||
|
'sessionId' { foreach ($event in $events) { $event.sessionId = $secret } }
|
||||||
|
'accountName' { $events[4].accountName = $secret }
|
||||||
|
'characterName' { $events[4].characters[0].name = $secret }
|
||||||
|
'enteredCharacterName' { $events[5].characterName = $secret }
|
||||||
|
'loadedPlugin' { $events[1].plugin = $secret }
|
||||||
|
'failedPlugin' { $events[2].plugin = $secret }
|
||||||
|
'pluginError' { $events[2].error = $secret }
|
||||||
|
'command' { $events[6].command = $secret }
|
||||||
|
'commandError' { $events[6].error = $secret }
|
||||||
|
'disconnectedReason' { $events[7].reason = $secret }
|
||||||
|
'exitReason' { $events[8].reason = $secret }
|
||||||
|
}
|
||||||
|
$path = Join-Path $OutputDirectory "secret-$case.jsonl"
|
||||||
|
$report = Join-Path $OutputDirectory "secret-$case.validation.json"
|
||||||
|
Write-Events $path $events
|
||||||
|
Invoke-Validator $path $caseProfile $report $goneProcessId $false
|
||||||
|
$result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json
|
||||||
|
if (-not ($result.failures -match 'credential value')) {
|
||||||
|
throw "Credential echo case '$case' was not rejected by recursive scanning."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fixtureSource = Join-Path `
|
||||||
|
$Repository 'tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/bin/Release/net10.0'
|
||||||
|
$fixtureRoot = Join-Path $OutputDirectory 'process-fixture'
|
||||||
|
Copy-Item -LiteralPath $fixtureSource -Destination $fixtureRoot -Recurse
|
||||||
|
$sourceBase = 'AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder'
|
||||||
|
$suffix = if ($IsWindows) { '.exe' } else { '' }
|
||||||
|
$sourceHost = Join-Path $fixtureRoot "$sourceBase$suffix"
|
||||||
|
$sameNameHost = Join-Path $fixtureRoot "acdream-headless$suffix"
|
||||||
|
Copy-Item -LiteralPath $sourceHost -Destination $sameNameHost
|
||||||
|
foreach ($extension in @('.runtimeconfig.json', '.deps.json')) {
|
||||||
|
Copy-Item -LiteralPath (Join-Path $fixtureRoot "$sourceBase$extension") `
|
||||||
|
-Destination (Join-Path $fixtureRoot "acdream-headless$extension")
|
||||||
|
}
|
||||||
|
if ($IsLinux) {
|
||||||
|
[IO.File]::SetUnixFileMode(
|
||||||
|
$sameNameHost,
|
||||||
|
[IO.File]::GetUnixFileMode($sourceHost))
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionConfig = Join-Path $OutputDirectory 'session.json'
|
||||||
|
[IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false))
|
||||||
|
$targetReady = Join-Path $OutputDirectory 'target.ready'
|
||||||
|
$targetRelease = Join-Path $OutputDirectory 'target.release'
|
||||||
|
$unrelatedReady = Join-Path $OutputDirectory 'unrelated.ready'
|
||||||
|
$unrelatedRelease = Join-Path $OutputDirectory 'unrelated.release'
|
||||||
|
|
||||||
|
function Start-Fixture([string[]]$Arguments) {
|
||||||
|
$start = [Diagnostics.ProcessStartInfo]::new($sameNameHost)
|
||||||
|
$start.UseShellExecute = $false
|
||||||
|
$start.CreateNoWindow = $true
|
||||||
|
foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) }
|
||||||
|
return [Diagnostics.Process]::Start($start)
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = Start-Fixture @(
|
||||||
|
'hold-campaign-la-process', '--config', $sessionConfig, $targetReady, $targetRelease)
|
||||||
|
$unrelated = Start-Fixture @(
|
||||||
|
'hold-update-lease', 'session', (Join-Path $OutputDirectory 'unrelated-data'),
|
||||||
|
$unrelatedReady, $unrelatedRelease)
|
||||||
|
if ($null -eq $target -or $null -eq $unrelated) {
|
||||||
|
throw 'Could not start process-correlation fixtures.'
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$deadline = [DateTime]::UtcNow.AddSeconds(10)
|
||||||
|
while ((-not (Test-Path -LiteralPath $targetReady) -or
|
||||||
|
-not (Test-Path -LiteralPath $unrelatedReady)) -and
|
||||||
|
[DateTime]::UtcNow -lt $deadline) {
|
||||||
|
Start-Sleep -Milliseconds 50
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $targetReady) -or
|
||||||
|
-not (Test-Path -LiteralPath $unrelatedReady)) {
|
||||||
|
throw 'Process-correlation fixtures did not become ready.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$captureReport = Join-Path $OutputDirectory 'process-capture.json'
|
||||||
|
& $pwsh -NoProfile -File $capture `
|
||||||
|
-SessionConfigPath $sessionConfig -ReportPath $captureReport
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Process capture failed.' }
|
||||||
|
$captured = Get-Content -LiteralPath $captureReport -Raw | ConvertFrom-Json
|
||||||
|
if ([int]$captured.processId -ne $target.Id) {
|
||||||
|
throw 'Process capture did not return the exact correlated PID.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$liveReport = Join-Path $OutputDirectory 'live-pid.validation.json'
|
||||||
|
Invoke-Validator `
|
||||||
|
$positiveStatus $profile $liveReport $target.Id $false $sessionConfig
|
||||||
|
$liveResult = Get-Content -LiteralPath $liveReport -Raw | ConvertFrom-Json
|
||||||
|
if (-not ($liveResult.failures -match 'remains alive')) {
|
||||||
|
throw 'A live exact child PID was not rejected by the terminal validator.'
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline
|
||||||
|
$target.WaitForExit()
|
||||||
|
Invoke-Validator `
|
||||||
|
$positiveStatus $profile `
|
||||||
|
(Join-Path $OutputDirectory 'unrelated-same-name.validation.json') `
|
||||||
|
$target.Id $true $sessionConfig
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline
|
||||||
|
Set-Content -LiteralPath $unrelatedRelease -Value 'release' -NoNewline
|
||||||
|
if (-not $target.HasExited) { $target.WaitForExit() }
|
||||||
|
if (-not $unrelated.HasExited) { $unrelated.WaitForExit() }
|
||||||
|
$target.Dispose()
|
||||||
|
$unrelated.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = [ordered]@{
|
||||||
|
schemaVersion = 1
|
||||||
|
kind = 'campaign-la-gate-helper-tests'
|
||||||
|
success = $true
|
||||||
|
disconnectedReasonNegatives = 3
|
||||||
|
credentialStringFieldNegatives = $secretCases.Count
|
||||||
|
exactPidCapture = $true
|
||||||
|
livePidRejected = $true
|
||||||
|
unrelatedSameNameIgnored = $true
|
||||||
|
platform = if ($IsWindows) { 'windows' } else { 'linux' }
|
||||||
|
}
|
||||||
|
$summary | ConvertTo-Json -Depth 4 |
|
||||||
|
Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM
|
||||||
|
Write-Host "Campaign LA gate helper tests: $OutputDirectory"
|
||||||
254
tools/test-campaign-la-script-safety.ps1
Normal file
254
tools/test-campaign-la-script-safety.ps1
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Connection-free negative and determinism tests for Campaign LA scripts.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
|
||||||
|
[Parameter(Mandatory = $true)][string]$OutputDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||||
|
throw 'Campaign LA script-safety tests require PowerShell 7 or newer.'
|
||||||
|
}
|
||||||
|
$Repository = [IO.Path]::GetFullPath($Repository)
|
||||||
|
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
|
||||||
|
if (Test-Path -LiteralPath $OutputDirectory) {
|
||||||
|
throw '-OutputDirectory must be fresh.'
|
||||||
|
}
|
||||||
|
$null = New-Item -ItemType Directory -Path $OutputDirectory
|
||||||
|
$pwsh = [Environment]::ProcessPath
|
||||||
|
if ([string]::IsNullOrWhiteSpace($pwsh)) {
|
||||||
|
throw 'The PowerShell process path is unavailable.'
|
||||||
|
}
|
||||||
|
$preflight = Join-Path $Repository 'tools/run-campaign-la-preflight.ps1'
|
||||||
|
$fixture = Join-Path $Repository 'tools/new-campaign-la-update-fixture.ps1'
|
||||||
|
$negativeCount = 0
|
||||||
|
|
||||||
|
function Invoke-Expected(
|
||||||
|
[string]$Script,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[bool]$ShouldPass,
|
||||||
|
[string]$Name) {
|
||||||
|
$start = [Diagnostics.ProcessStartInfo]::new($pwsh)
|
||||||
|
$start.UseShellExecute = $false
|
||||||
|
$start.CreateNoWindow = $true
|
||||||
|
$start.RedirectStandardOutput = $true
|
||||||
|
$start.RedirectStandardError = $true
|
||||||
|
$start.ArgumentList.Add('-NoProfile')
|
||||||
|
$start.ArgumentList.Add('-File')
|
||||||
|
$start.ArgumentList.Add($Script)
|
||||||
|
foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) }
|
||||||
|
$process = [Diagnostics.Process]::Start($start)
|
||||||
|
if ($null -eq $process) { throw "Could not start safety case '$Name'." }
|
||||||
|
$stdout = $process.StandardOutput.ReadToEndAsync()
|
||||||
|
$stderr = $process.StandardError.ReadToEndAsync()
|
||||||
|
$process.WaitForExit()
|
||||||
|
$outText = $stdout.GetAwaiter().GetResult()
|
||||||
|
$errorText = $stderr.GetAwaiter().GetResult()
|
||||||
|
$exitCode = $process.ExitCode
|
||||||
|
$process.Dispose()
|
||||||
|
if (($exitCode -eq 0) -ne $ShouldPass) {
|
||||||
|
throw "Safety case '$Name' result mismatch (exit $exitCode). $outText $errorText"
|
||||||
|
}
|
||||||
|
if (-not $ShouldPass) { $script:negativeCount++ }
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = Join-Path $OutputDirectory 'campaign-la-preflight-safety'
|
||||||
|
$null = New-Item -ItemType Directory -Path $allowed
|
||||||
|
Invoke-Expected $preflight @(
|
||||||
|
'-Repository', $Repository,
|
||||||
|
'-AllowedOutputRoot', $allowed,
|
||||||
|
'-OutputDirectory', (Join-Path $allowed 'positive'),
|
||||||
|
'-DryRun') $true 'preflight-positive'
|
||||||
|
|
||||||
|
$existingEmpty = Join-Path $allowed 'existing-empty'
|
||||||
|
$null = New-Item -ItemType Directory -Path $existingEmpty
|
||||||
|
Invoke-Expected $preflight @(
|
||||||
|
'-Repository', $Repository,
|
||||||
|
'-AllowedOutputRoot', $allowed,
|
||||||
|
'-OutputDirectory', $existingEmpty,
|
||||||
|
'-DryRun') $false 'preflight-existing-empty'
|
||||||
|
|
||||||
|
$existingNonempty = Join-Path $allowed 'existing-nonempty'
|
||||||
|
$null = New-Item -ItemType Directory -Path $existingNonempty
|
||||||
|
Set-Content -LiteralPath (Join-Path $existingNonempty 'owner') -Value 'preserve'
|
||||||
|
Invoke-Expected $preflight @(
|
||||||
|
'-Repository', $Repository,
|
||||||
|
'-AllowedOutputRoot', $allowed,
|
||||||
|
'-OutputDirectory', $existingNonempty,
|
||||||
|
'-DryRun') $false 'preflight-existing-nonempty'
|
||||||
|
|
||||||
|
$payloadRootRefusal = Join-Path $OutputDirectory 'update-payloads'
|
||||||
|
$null = New-Item -ItemType Directory -Path $payloadRootRefusal
|
||||||
|
foreach ($case in @(
|
||||||
|
[pscustomobject]@{ Name = 'preflight-root'; Allowed = $Repository; Output = (Join-Path $Repository 'blocked') },
|
||||||
|
[pscustomobject]@{ Name = 'preflight-home'; Allowed = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile); Output = (Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)) 'blocked') },
|
||||||
|
[pscustomobject]@{ Name = 'preflight-source'; Allowed = (Join-Path $Repository 'src'); Output = (Join-Path $Repository 'src/blocked') },
|
||||||
|
[pscustomobject]@{ Name = 'preflight-payload'; Allowed = $payloadRootRefusal; Output = (Join-Path $payloadRootRefusal 'blocked') },
|
||||||
|
[pscustomobject]@{ Name = 'preflight-outside'; Allowed = $allowed; Output = (Join-Path $OutputDirectory 'outside') },
|
||||||
|
[pscustomobject]@{ Name = 'preflight-allowed-root-itself'; Allowed = $allowed; Output = $allowed })) {
|
||||||
|
Invoke-Expected $preflight @(
|
||||||
|
'-Repository', $Repository,
|
||||||
|
'-AllowedOutputRoot', $case.Allowed,
|
||||||
|
'-OutputDirectory', $case.Output,
|
||||||
|
'-DryRun') $false $case.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
$reparseTarget = Join-Path $OutputDirectory 'campaign-la-reparse-target'
|
||||||
|
$reparseRoot = Join-Path $OutputDirectory 'campaign-la-reparse-link'
|
||||||
|
$null = New-Item -ItemType Directory -Path $reparseTarget
|
||||||
|
if ($IsWindows) {
|
||||||
|
$null = New-Item -ItemType Junction -Path $reparseRoot -Target $reparseTarget
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$null = New-Item -ItemType SymbolicLink -Path $reparseRoot -Target $reparseTarget
|
||||||
|
}
|
||||||
|
Invoke-Expected $preflight @(
|
||||||
|
'-Repository', $Repository,
|
||||||
|
'-AllowedOutputRoot', $reparseRoot,
|
||||||
|
'-OutputDirectory', (Join-Path $reparseRoot 'blocked'),
|
||||||
|
'-DryRun') $false 'preflight-reparse-root'
|
||||||
|
|
||||||
|
$source = Join-Path $OutputDirectory 'payload-source'
|
||||||
|
$null = New-Item -ItemType Directory -Path $source
|
||||||
|
function Fixture-DryArguments([string]$Destination, [string]$PayloadSource) {
|
||||||
|
return @(
|
||||||
|
'-OutputDirectory', $Destination,
|
||||||
|
'-ClientWinX64DirectoryA', $PayloadSource,
|
||||||
|
'-LauncherWinX64DirectoryA', $PayloadSource,
|
||||||
|
'-ClientLinuxX64DirectoryA', $PayloadSource,
|
||||||
|
'-LauncherLinuxX64DirectoryA', $PayloadSource,
|
||||||
|
'-ClientWinX64DirectoryB', $PayloadSource,
|
||||||
|
'-LauncherWinX64DirectoryB', $PayloadSource,
|
||||||
|
'-ClientLinuxX64DirectoryB', $PayloadSource,
|
||||||
|
'-LauncherLinuxX64DirectoryB', $PayloadSource,
|
||||||
|
'-DryRun')
|
||||||
|
}
|
||||||
|
Invoke-Expected $fixture (Fixture-DryArguments (Join-Path $source 'child') $source) `
|
||||||
|
$false 'fixture-output-inside-source'
|
||||||
|
Invoke-Expected $fixture (Fixture-DryArguments $source (Join-Path $source 'child-source')) `
|
||||||
|
$false 'fixture-source-inside-output'
|
||||||
|
Invoke-Expected $fixture (Fixture-DryArguments $source $source) `
|
||||||
|
$false 'fixture-output-equals-source'
|
||||||
|
$nearMatch = Join-Path $OutputDirectory 'payload-source-near'
|
||||||
|
Invoke-Expected $fixture (Fixture-DryArguments $nearMatch $source) `
|
||||||
|
$true 'fixture-near-match'
|
||||||
|
|
||||||
|
$sourceLink = Join-Path $OutputDirectory 'payload-source-link'
|
||||||
|
if ($IsWindows) {
|
||||||
|
$null = New-Item -ItemType Junction -Path $sourceLink -Target $source
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$null = New-Item -ItemType SymbolicLink -Path $sourceLink -Target $source
|
||||||
|
}
|
||||||
|
Invoke-Expected $fixture (
|
||||||
|
Fixture-DryArguments (Join-Path $OutputDirectory 'reparse-source-output') $sourceLink) `
|
||||||
|
$false 'fixture-reparse-source'
|
||||||
|
$outputTarget = Join-Path $OutputDirectory 'fixture-output-target'
|
||||||
|
$outputLink = Join-Path $OutputDirectory 'fixture-output-link'
|
||||||
|
$null = New-Item -ItemType Directory -Path $outputTarget
|
||||||
|
if ($IsWindows) {
|
||||||
|
$null = New-Item -ItemType Junction -Path $outputLink -Target $outputTarget
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$null = New-Item -ItemType SymbolicLink -Path $outputLink -Target $outputTarget
|
||||||
|
}
|
||||||
|
Invoke-Expected $fixture (Fixture-DryArguments $outputLink $source) `
|
||||||
|
$false 'fixture-reparse-output'
|
||||||
|
|
||||||
|
function Write-PayloadFile([string]$Root, [string]$Name, [string]$Content) {
|
||||||
|
$path = Join-Path $Root $Name
|
||||||
|
$directory = Split-Path -Parent $path
|
||||||
|
$null = New-Item -ItemType Directory -Force -Path $directory
|
||||||
|
[IO.File]::WriteAllText($path, $Content, [Text.UTF8Encoding]::new($false))
|
||||||
|
}
|
||||||
|
$payloadRoot = Join-Path $OutputDirectory 'deterministic-payloads'
|
||||||
|
$payloads = [ordered]@{
|
||||||
|
ClientWin = Join-Path $payloadRoot 'client-win'
|
||||||
|
LauncherWin = Join-Path $payloadRoot 'launcher-win'
|
||||||
|
ClientLinux = Join-Path $payloadRoot 'client-linux'
|
||||||
|
LauncherLinux = Join-Path $payloadRoot 'launcher-linux'
|
||||||
|
}
|
||||||
|
foreach ($directory in $payloads.Values) {
|
||||||
|
foreach ($entry in @(
|
||||||
|
@('nested/I.txt', 'I'), @('nested/Z.txt', 'Z'),
|
||||||
|
@('nested/ä.txt', 'a-umlaut'), @('nested/ı.txt', 'dotless-i'))) {
|
||||||
|
Write-PayloadFile $directory $entry[0] $entry[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-PayloadFile $payloads.ClientWin 'AcDream.App.exe' 'client-win-gui'
|
||||||
|
Write-PayloadFile $payloads.ClientWin 'acdream-headless.exe' 'client-win-headless'
|
||||||
|
Write-PayloadFile $payloads.LauncherWin 'acdream-launcher.exe' 'launcher-win'
|
||||||
|
Write-PayloadFile $payloads.LauncherWin 'acdream-bake.exe' 'bake-win'
|
||||||
|
Write-PayloadFile $payloads.ClientLinux 'AcDream.App' 'client-linux-gui'
|
||||||
|
Write-PayloadFile $payloads.ClientLinux 'acdream-headless' 'client-linux-headless'
|
||||||
|
Write-PayloadFile $payloads.LauncherLinux 'acdream-launcher' 'launcher-linux'
|
||||||
|
Write-PayloadFile $payloads.LauncherLinux 'acdream-bake' 'bake-linux'
|
||||||
|
|
||||||
|
$fixtureParameters = @{
|
||||||
|
ClientWinX64DirectoryA = $payloads.ClientWin
|
||||||
|
LauncherWinX64DirectoryA = $payloads.LauncherWin
|
||||||
|
ClientLinuxX64DirectoryA = $payloads.ClientLinux
|
||||||
|
LauncherLinuxX64DirectoryA = $payloads.LauncherLinux
|
||||||
|
ClientWinX64DirectoryB = $payloads.ClientWin
|
||||||
|
LauncherWinX64DirectoryB = $payloads.LauncherWin
|
||||||
|
ClientLinuxX64DirectoryB = $payloads.ClientLinux
|
||||||
|
LauncherLinuxX64DirectoryB = $payloads.LauncherLinux
|
||||||
|
}
|
||||||
|
$inventories = [Collections.Generic.List[object]]::new()
|
||||||
|
$originalCulture = [Globalization.CultureInfo]::CurrentCulture
|
||||||
|
$originalUiCulture = [Globalization.CultureInfo]::CurrentUICulture
|
||||||
|
try {
|
||||||
|
foreach ($cultureName in @('en-US', 'tr-TR', 'sv-SE')) {
|
||||||
|
$culture = [Globalization.CultureInfo]::GetCultureInfo($cultureName)
|
||||||
|
[Globalization.CultureInfo]::CurrentCulture = $culture
|
||||||
|
[Globalization.CultureInfo]::CurrentUICulture = $culture
|
||||||
|
$destination = Join-Path $OutputDirectory "fixture-$cultureName"
|
||||||
|
& $fixture -OutputDirectory $destination @fixtureParameters
|
||||||
|
$relativePaths = [string[]]@(Get-ChildItem -LiteralPath $destination -File -Recurse |
|
||||||
|
Where-Object { $_.Name -ne 'fixture-report.json' } |
|
||||||
|
ForEach-Object {
|
||||||
|
[IO.Path]::GetRelativePath($destination, $_.FullName).Replace('\', '/')
|
||||||
|
})
|
||||||
|
[Array]::Sort($relativePaths, [StringComparer]::Ordinal)
|
||||||
|
$inventory = @($relativePaths | ForEach-Object {
|
||||||
|
$path = Join-Path $destination $_.Replace('/', [IO.Path]::DirectorySeparatorChar)
|
||||||
|
"$_|$((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant())"
|
||||||
|
})
|
||||||
|
$inventories.Add($inventory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
[Globalization.CultureInfo]::CurrentCulture = $originalCulture
|
||||||
|
[Globalization.CultureInfo]::CurrentUICulture = $originalUiCulture
|
||||||
|
}
|
||||||
|
$firstInventory = [string]::Join("`n", [string[]]$inventories[0])
|
||||||
|
foreach ($inventory in $inventories) {
|
||||||
|
if ([string]::Join("`n", [string[]]$inventory) -cne $firstInventory) {
|
||||||
|
throw 'Fixture hashes changed with the current culture.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$digestBytes = [Security.Cryptography.SHA256]::HashData(
|
||||||
|
[Text.Encoding]::UTF8.GetBytes($firstInventory))
|
||||||
|
$deterministicDigest = [Convert]::ToHexString($digestBytes).ToLowerInvariant()
|
||||||
|
$expectedCrossPlatformDigest =
|
||||||
|
'9c77b7204dd19e77fad62e572304d52e810afe2d0821c2ec57a692d27a0cc167'
|
||||||
|
if ($deterministicDigest -cne $expectedCrossPlatformDigest) {
|
||||||
|
throw 'Fixture artifact hashes differ from the pinned Windows/Linux contract.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = [ordered]@{
|
||||||
|
schemaVersion = 1
|
||||||
|
kind = 'campaign-la-script-safety-tests'
|
||||||
|
success = $true
|
||||||
|
negativeCases = $negativeCount
|
||||||
|
cultures = @('en-US', 'tr-TR', 'sv-SE')
|
||||||
|
fixtureArtifactSetSha256 = $deterministicDigest
|
||||||
|
crossPlatformExpectedSha256 = $expectedCrossPlatformDigest
|
||||||
|
}
|
||||||
|
$summary | ConvertTo-Json -Depth 5 |
|
||||||
|
Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM
|
||||||
|
Write-Host "Campaign LA script safety tests: $OutputDirectory"
|
||||||
|
|
@ -14,15 +14,15 @@ param(
|
||||||
[Parameter(Mandatory = $true)][string]$StatusFile,
|
[Parameter(Mandatory = $true)][string]$StatusFile,
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode,
|
[ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateRange(1, 2147483647)][int]$ExpectedProcessId,
|
||||||
|
[Parameter(Mandatory = $true)][string]$CredentialProfilePath,
|
||||||
|
[string]$SessionConfigPath,
|
||||||
[string]$ExpectedSessionId,
|
[string]$ExpectedSessionId,
|
||||||
[string[]]$ExpectedPlugin = @(),
|
[string[]]$ExpectedPlugin = @(),
|
||||||
[switch]$ExpectNoEnteredWorld,
|
[switch]$ExpectNoEnteredWorld,
|
||||||
[switch]$AllowPluginFailure,
|
[switch]$AllowPluginFailure,
|
||||||
[switch]$AllowLoginCommandFailure,
|
[switch]$AllowLoginCommandFailure,
|
||||||
[switch]$AllowLauncherChildren,
|
|
||||||
[string[]]$ForbiddenEnvironmentVariable = @(
|
|
||||||
'ACDREAM_TEST_PASS',
|
|
||||||
'ACDREAM_LA_GATE_SECRET'),
|
|
||||||
[string]$ReportPath,
|
[string]$ReportPath,
|
||||||
[int]$ProcessExitWaitSeconds = 5
|
[int]$ProcessExitWaitSeconds = 5
|
||||||
)
|
)
|
||||||
|
|
@ -35,12 +35,59 @@ if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||||
if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') {
|
if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') {
|
||||||
throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.'
|
throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.'
|
||||||
}
|
}
|
||||||
|
. (Join-Path $PSScriptRoot 'CampaignLaProcessCorrelation.ps1')
|
||||||
if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) {
|
if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) {
|
||||||
$StatusFile = [IO.Path]::GetFullPath($StatusFile)
|
$StatusFile = [IO.Path]::GetFullPath($StatusFile)
|
||||||
}
|
}
|
||||||
if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) {
|
if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) {
|
||||||
throw "Status file does not exist: $StatusFile"
|
throw "Status file does not exist: $StatusFile"
|
||||||
}
|
}
|
||||||
|
if (-not [IO.Path]::IsPathFullyQualified($CredentialProfilePath)) {
|
||||||
|
throw '-CredentialProfilePath must be absolute.'
|
||||||
|
}
|
||||||
|
$CredentialProfilePath = [IO.Path]::GetFullPath($CredentialProfilePath)
|
||||||
|
if (-not (Test-Path -LiteralPath $CredentialProfilePath -PathType Leaf)) {
|
||||||
|
throw "Credential profile does not exist: $CredentialProfilePath"
|
||||||
|
}
|
||||||
|
$credentialItem = Get-Item -LiteralPath $CredentialProfilePath -Force
|
||||||
|
if (($credentialItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||||
|
throw 'Credential profile must not be a reparse point.'
|
||||||
|
}
|
||||||
|
if ($IsLinux) {
|
||||||
|
$ownerOnly = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite
|
||||||
|
if ([IO.File]::GetUnixFileMode($CredentialProfilePath) -ne $ownerOnly) {
|
||||||
|
throw 'Credential profile must have exact owner-only mode 0600.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($IsWindows) {
|
||||||
|
$broadSids = @(
|
||||||
|
'S-1-1-0', # Everyone
|
||||||
|
'S-1-5-11', # Authenticated Users
|
||||||
|
'S-1-5-32-545', # Builtin Users
|
||||||
|
'S-1-5-32-546') # Guests
|
||||||
|
$acl = Get-Acl -LiteralPath $CredentialProfilePath
|
||||||
|
if ($null -eq $acl.Owner) { throw 'Credential profile has no ACL owner.' }
|
||||||
|
foreach ($rule in $acl.Access) {
|
||||||
|
if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$sid = $rule.IdentityReference.Translate(
|
||||||
|
[Security.Principal.SecurityIdentifier]).Value
|
||||||
|
}
|
||||||
|
catch { $sid = [string]$rule.IdentityReference.Value }
|
||||||
|
if ($sid -in $broadSids -and $rule.FileSystemRights -ne 0) {
|
||||||
|
throw 'Credential profile grants access to a broad Windows identity.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else { throw 'Campaign LA status validation supports Windows and Linux only.' }
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($SessionConfigPath)) {
|
||||||
|
if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) {
|
||||||
|
throw '-SessionConfigPath must be absolute.'
|
||||||
|
}
|
||||||
|
$SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath)
|
||||||
|
}
|
||||||
if ([string]::IsNullOrWhiteSpace($ReportPath)) {
|
if ([string]::IsNullOrWhiteSpace($ReportPath)) {
|
||||||
$ReportPath = "$StatusFile.validation.json"
|
$ReportPath = "$StatusFile.validation.json"
|
||||||
}
|
}
|
||||||
|
|
@ -65,6 +112,55 @@ $loadedPlugins = [Collections.Generic.List[string]]::new()
|
||||||
$sessionId = $null
|
$sessionId = $null
|
||||||
$previousTimestamp = [DateTimeOffset]::MinValue
|
$previousTimestamp = [DateTimeOffset]::MinValue
|
||||||
$terminalSeen = $false
|
$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) {
|
function Get-Properties([Text.Json.JsonElement]$Element) {
|
||||||
$properties = [Collections.Generic.List[object]]::new()
|
$properties = [Collections.Generic.List[object]]::new()
|
||||||
|
|
@ -112,13 +208,6 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
|
||||||
$failures.Add("line $lineNumber is empty")
|
$failures.Add("line $lineNumber is empty")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
foreach ($variable in $ForbiddenEnvironmentVariable) {
|
|
||||||
$secret = [Environment]::GetEnvironmentVariable($variable)
|
|
||||||
if (-not [string]::IsNullOrEmpty($secret) -and
|
|
||||||
$line.Contains($secret, [StringComparison]::Ordinal)) {
|
|
||||||
$failures.Add("line $lineNumber contains the value of forbidden environment variable $variable")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') {
|
if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') {
|
||||||
$failures.Add("line $lineNumber contains a credential-like JSON field")
|
$failures.Add("line $lineNumber contains a credential-like JSON field")
|
||||||
}
|
}
|
||||||
|
|
@ -130,6 +219,9 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
|
||||||
if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
|
if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
|
||||||
throw 'root is not an 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)
|
$properties = @(Get-Properties $root)
|
||||||
$names = @($properties | ForEach-Object { $_.Name })
|
$names = @($properties | ForEach-Object { $_.Name })
|
||||||
if (@($names | Sort-Object -Unique).Count -ne $names.Count) {
|
if (@($names | Sort-Object -Unique).Count -ne $names.Count) {
|
||||||
|
|
@ -214,7 +306,12 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
|
||||||
throw 'loginCommandFailed is not allowed for this row'
|
throw 'loginCommandFailed is not allowed for this row'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
'disconnected' { $null = Assert-String $root 'reason' }
|
'disconnected' {
|
||||||
|
$reason = Assert-String $root 'reason'
|
||||||
|
if ($reason -cne 'stopped') {
|
||||||
|
throw "terminal disconnected reason is '$reason', expected 'stopped'"
|
||||||
|
}
|
||||||
|
}
|
||||||
'exited' {
|
'exited' {
|
||||||
$code = Assert-Int32 $root 'code'
|
$code = Assert-Int32 $root 'code'
|
||||||
$reason = Assert-String $root 'reason'
|
$reason = Assert-String $root 'reason'
|
||||||
|
|
@ -304,16 +401,27 @@ for ($index = 0; $index -lt $eventNames.Count; $index++) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $AllowLauncherChildren) {
|
|
||||||
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
|
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
|
||||||
do {
|
do {
|
||||||
$children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue)
|
$expectedProcess = Get-Process -Id $ExpectedProcessId -ErrorAction SilentlyContinue
|
||||||
if ($children.Count -eq 0) { break }
|
if ($null -eq $expectedProcess) { break }
|
||||||
Start-Sleep -Milliseconds 100
|
Start-Sleep -Milliseconds 100
|
||||||
} while ([DateTime]::UtcNow -lt $deadline)
|
} while ([DateTime]::UtcNow -lt $deadline)
|
||||||
if ($children.Count -gt 0) {
|
if ($null -ne $expectedProcess) {
|
||||||
|
$failures.Add("expected launcher child PID $ExpectedProcessId remains alive")
|
||||||
|
}
|
||||||
|
|
||||||
|
$pathCorrelationChecked = -not [string]::IsNullOrWhiteSpace($SessionConfigPath)
|
||||||
|
if ($pathCorrelationChecked) {
|
||||||
|
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
|
||||||
|
do {
|
||||||
|
$correlated = @(Get-CampaignLaCorrelatedProcessIds $SessionConfigPath)
|
||||||
|
if ($correlated.Count -eq 0) { break }
|
||||||
|
Start-Sleep -Milliseconds 100
|
||||||
|
} while ([DateTime]::UtcNow -lt $deadline)
|
||||||
|
if ($correlated.Count -gt 0) {
|
||||||
$failures.Add(
|
$failures.Add(
|
||||||
"launcher child process leak(s): $((@($children | ForEach-Object { $_.ProcessName + ':' + $_.Id })) -join ',')")
|
"session-config-correlated launcher child PID(s) remain: $($correlated -join ',')")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,7 +442,11 @@ $report = [ordered]@{
|
||||||
eventNames = @($eventNames)
|
eventNames = @($eventNames)
|
||||||
loadedPluginCount = $loadedPlugins.Count
|
loadedPluginCount = $loadedPlugins.Count
|
||||||
terminalObserved = $terminalSeen
|
terminalObserved = $terminalSeen
|
||||||
launcherChildrenAllowed = [bool]$AllowLauncherChildren
|
expectedProcessId = $ExpectedProcessId
|
||||||
|
processExited = ($null -eq $expectedProcess)
|
||||||
|
sessionConfigCorrelationChecked = $pathCorrelationChecked
|
||||||
|
credentialPermissionsValidated = $true
|
||||||
|
forbiddenCredentialValueCount = $forbiddenValues.Count
|
||||||
failures = @($failures)
|
failures = @($failures)
|
||||||
validatedUtc = [DateTime]::UtcNow.ToString('O')
|
validatedUtc = [DateTime]::UtcNow.ToString('O')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue