fix(launcher): harden Campaign LA11 gate evidence

This commit is contained in:
Erik 2026-08-15 01:08:41 +02:00
parent 134edabed2
commit accd01a008
16 changed files with 1820 additions and 210 deletions

View file

@ -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 DF. The validator enforces exact Add `-ExpectedPlugin acdream.smoke` to rows DF. 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 AH ## 5. Serial Windows user rows AH
@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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)
}

View 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"

View file

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

View file

@ -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 $_ })

View 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"

View 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"

View file

@ -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)
do {
$expectedProcess = Get-Process -Id $ExpectedProcessId -ErrorAction SilentlyContinue
if ($null -eq $expectedProcess) { break }
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
if ($null -ne $expectedProcess) {
$failures.Add("expected launcher child PID $ExpectedProcessId remains alive")
}
$pathCorrelationChecked = -not [string]::IsNullOrWhiteSpace($SessionConfigPath)
if ($pathCorrelationChecked) {
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
do { do {
$children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue) $correlated = @(Get-CampaignLaCorrelatedProcessIds $SessionConfigPath)
if ($children.Count -eq 0) { break } if ($correlated.Count -eq 0) { 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 ($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')
} }