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

@ -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(
[IO.Path]::GetFullPath($OutputDirectory))
function Assert-NoReparseAncestry([string]$Path, [string]$Description) {
$cursor = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path))
while (-not (Test-Path -LiteralPath $cursor)) {
$parent = [IO.Path]::GetDirectoryName($cursor)
if ([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor) { break }
$cursor = $parent
}
while (-not [string]::IsNullOrEmpty($cursor)) {
$item = Get-Item -LiteralPath $cursor -Force
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "$Description has a reparse point in its ancestry."
}
$parent = [IO.Directory]::GetParent($cursor)
if ($null -eq $parent) { break }
$cursor = $parent.FullName
}
}
function Test-SameOrDescendant([string]$Path, [string]$Ancestor) {
$comparison = if ($IsWindows) {
[StringComparison]::OrdinalIgnoreCase
} else { [StringComparison]::Ordinal }
if ([string]::Equals($Path, $Ancestor, $comparison)) { return $true }
$prefix = $Ancestor + [IO.Path]::DirectorySeparatorChar
return $Path.StartsWith($prefix, $comparison)
}
Assert-NoReparseAncestry $OutputDirectory 'Output directory'
if ($Port -lt 1024 -or $Port -gt 65535) { throw '-Port must be 1024..65535.' }
$semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$'
if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or
@ -73,6 +102,11 @@ foreach ($key in @($sources.Keys)) {
if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) {
throw "Payload source '$key' does not exist: $source"
}
Assert-NoReparseAncestry $source "Payload source '$key'"
if ((Test-SameOrDescendant $OutputDirectory $source) -or
(Test-SameOrDescendant $source $OutputDirectory)) {
throw "Output directory and payload source '$key' must not overlap."
}
}
function Require-PayloadFile([string]$Key, [string]$Name) {
@ -98,6 +132,7 @@ if (Test-Path -LiteralPath $OutputDirectory) {
}
}
else { $null = New-Item -ItemType Directory -Path $OutputDirectory }
Assert-NoReparseAncestry $OutputDirectory 'Output directory'
if ($DryRun) {
$plan = [ordered]@{
@ -121,6 +156,73 @@ Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
function Get-LittleEndianUInt16([byte[]]$Bytes, [int]$Offset) {
return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8)
}
function Get-LittleEndianUInt32([byte[]]$Bytes, [int]$Offset) {
return [uint32]([uint32]$Bytes[$Offset] -bor
([uint32]$Bytes[$Offset + 1] -shl 8) -bor
([uint32]$Bytes[$Offset + 2] -shl 16) -bor
([uint32]$Bytes[$Offset + 3] -shl 24))
}
function Set-DeterministicZipHostPlatform([string]$Path) {
[byte[]]$bytes = [IO.File]::ReadAllBytes($Path)
$minimumEocdSize = 22
if ($bytes.Length -lt $minimumEocdSize) {
throw "Generated ZIP is too short: $Path"
}
$eocd = -1
$minimumOffset = [Math]::Max(0, $bytes.Length - 65557)
for ($offset = $bytes.Length - $minimumEocdSize; $offset -ge $minimumOffset; $offset--) {
if ((Get-LittleEndianUInt32 $bytes $offset) -eq 0x06054b50) {
$commentLength = Get-LittleEndianUInt16 $bytes ($offset + 20)
if ($offset + $minimumEocdSize + $commentLength -eq $bytes.Length) {
$eocd = $offset
break
}
}
}
if ($eocd -lt 0) { throw "Generated ZIP has no valid end record: $Path" }
if ((Get-LittleEndianUInt16 $bytes ($eocd + 4)) -ne 0 -or
(Get-LittleEndianUInt16 $bytes ($eocd + 6)) -ne 0) {
throw "Generated ZIP unexpectedly spans multiple disks: $Path"
}
$entriesOnDisk = Get-LittleEndianUInt16 $bytes ($eocd + 8)
$entryCount = Get-LittleEndianUInt16 $bytes ($eocd + 10)
if ($entriesOnDisk -ne $entryCount) {
throw "Generated ZIP central-directory count is inconsistent: $Path"
}
$centralSize = Get-LittleEndianUInt32 $bytes ($eocd + 12)
$centralOffset = Get-LittleEndianUInt32 $bytes ($eocd + 16)
if ([uint64]$centralOffset + [uint64]$centralSize -ne [uint64]$eocd) {
throw "Generated ZIP central-directory bounds are inconsistent: $Path"
}
[uint64]$cursor = $centralOffset
for ($index = 0; $index -lt $entryCount; $index++) {
if ($cursor + 46 -gt $eocd -or
(Get-LittleEndianUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) {
throw "Generated ZIP central-directory entry is invalid: $Path"
}
# ZipArchive intentionally stamps the creating host (FAT on Windows,
# Unix on Linux) in the upper byte of "version made by". Normalize it
# to FAT; permissions are already explicit in ExternalAttributes.
$bytes[[int]$cursor + 5] = 0
$nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28)
$extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30)
$commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32)
$cursor += 46 + $nameLength + $extraLength + $commentLength
}
if ($cursor -ne $eocd) {
throw "Generated ZIP central-directory length is inconsistent: $Path"
}
[IO.File]::WriteAllBytes($Path, $bytes)
}
function New-DeterministicZip(
[string]$SourceDirectory,
[string]$Destination,
@ -141,17 +243,30 @@ function New-DeterministicZip(
$true,
[Text.Encoding]::UTF8)
try {
$files = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Recurse |
Sort-Object { [IO.Path]::GetRelativePath($SourceDirectory, $_.FullName).Replace('\', '/') })
foreach ($file in $files) {
if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Payload contains a reparse point: $($file.FullName)"
$allEntries = @(Get-ChildItem -LiteralPath $SourceDirectory -Force -Recurse)
foreach ($item in $allEntries) {
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Payload contains a reparse point: $($item.FullName)"
}
$relative = [IO.Path]::GetRelativePath($SourceDirectory, $file.FullName).Replace('\', '/')
}
[string[]]$files = @($allEntries |
Where-Object { -not $_.PSIsContainer } |
ForEach-Object {
[IO.Path]::GetRelativePath(
$SourceDirectory,
$_.FullName).Replace('\', '/')
})
[Array]::Sort($files, [StringComparer]::Ordinal)
$caseFolded = [Collections.Generic.HashSet[string]]::new(
[StringComparer]::OrdinalIgnoreCase)
foreach ($relative in $files) {
if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or
[IO.Path]::IsPathRooted($relative)) {
[IO.Path]::IsPathRooted($relative) -or
-not $caseFolded.Add($relative)) {
throw "Payload path escaped its root: $relative"
}
$file = Get-Item -LiteralPath (
Join-Path $SourceDirectory $relative.Replace('/', [IO.Path]::DirectorySeparatorChar))
$entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal)
$entry.LastWriteTime = $fixedTimestamp
$executable = $relative -ceq 'AcDream.App' -or
@ -183,6 +298,7 @@ function New-DeterministicZip(
finally { $archive.Dispose() }
}
finally { $stream.Dispose() }
Set-DeterministicZipHostPlatform $Destination
}
function Get-Artifact([string]$Path, [string]$Url) {
@ -232,11 +348,15 @@ foreach ($release in $releaseDefinitions) {
"$baseUri/launcher-linux-x64.zip"
}
}
$manifest | ConvertTo-Json -Depth 8 -Compress |
Set-Content -LiteralPath (Join-Path $releaseRoot 'manifest.json') -Encoding utf8NoBOM
[IO.File]::WriteAllText(
(Join-Path $releaseRoot 'manifest.json'),
($manifest | ConvertTo-Json -Depth 8 -Compress),
[Text.UTF8Encoding]::new($false))
}
Set-Content -LiteralPath (Join-Path $OutputDirectory 'active-release.txt') `
-Value 'A' -Encoding ascii -NoNewline
[IO.File]::WriteAllText(
(Join-Path $OutputDirectory 'active-release.txt'),
'A',
[Text.Encoding]::ASCII)
$server = @'
[CmdletBinding()]
@ -311,7 +431,10 @@ try {
}
finally { $listener.Close() }
'@
$server | Set-Content -LiteralPath (Join-Path $OutputDirectory 'serve-fixture.ps1') -Encoding utf8NoBOM
[IO.File]::WriteAllText(
(Join-Path $OutputDirectory 'serve-fixture.ps1'),
$server.Replace("`r`n", "`n"),
[Text.UTF8Encoding]::new($false))
$selector = @'
[CmdletBinding()]
@ -340,16 +463,24 @@ finally {
}
Write-Host "Campaign LA fixture active release: $Release"
'@
$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM
[IO.File]::WriteAllText(
(Join-Path $OutputDirectory 'set-active-release.ps1'),
$selector.Replace("`r`n", "`n"),
[Text.UTF8Encoding]::new($false))
$inventory = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
$inventoryPaths = [string[]]@(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
Where-Object { $_.Name -ne 'fixture-report.json' } |
Sort-Object FullName |
ForEach-Object {
[IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
})
[Array]::Sort($inventoryPaths, [StringComparer]::Ordinal)
$inventory = @($inventoryPaths | ForEach-Object {
$fullPath = Join-Path $OutputDirectory $_.Replace('/', [IO.Path]::DirectorySeparatorChar)
$item = Get-Item -LiteralPath $fullPath
[ordered]@{
path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
size = $_.Length
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
path = $_
size = $item.Length
sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant()
}
})
$report = [ordered]@{

View file

@ -13,6 +13,7 @@
[CmdletBinding()]
param(
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
[Parameter(Mandatory = $true)][string]$AllowedOutputRoot,
[string]$OutputDirectory,
[switch]$DryRun,
[switch]$IncludeInstalledDat,
@ -30,6 +31,66 @@ $Repository = [IO.Path]::TrimEndingDirectorySeparator(
if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) {
throw "Repository does not contain AcDream.slnx: $Repository"
}
function Assert-NoReparseAncestry([string]$Path, [string]$Description) {
$cursor = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path))
while (-not (Test-Path -LiteralPath $cursor)) {
$parent = [IO.Path]::GetDirectoryName($cursor)
if ([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor) { break }
$cursor = $parent
}
while (-not [string]::IsNullOrEmpty($cursor)) {
$item = Get-Item -LiteralPath $cursor -Force
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "$Description has a reparse point in its ancestry."
}
$parent = [IO.Directory]::GetParent($cursor)
if ($null -eq $parent) { break }
$cursor = $parent.FullName
}
}
function Test-SameOrDescendant([string]$Path, [string]$Ancestor) {
$comparison = if ($IsWindows) {
[StringComparison]::OrdinalIgnoreCase
} else { [StringComparison]::Ordinal }
if ([string]::Equals($Path, $Ancestor, $comparison)) { return $true }
return $Path.StartsWith(
$Ancestor + [IO.Path]::DirectorySeparatorChar,
$comparison)
}
if (-not [IO.Path]::IsPathFullyQualified($AllowedOutputRoot)) {
throw '-AllowedOutputRoot must be absolute.'
}
$AllowedOutputRoot = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath($AllowedOutputRoot))
if (-not (Test-Path -LiteralPath $AllowedOutputRoot -PathType Container)) {
throw '-AllowedOutputRoot must be an existing campaign gate/log directory.'
}
Assert-NoReparseAncestry $AllowedOutputRoot 'Allowed output root'
$comparison = if ($IsWindows) {
[StringComparison]::OrdinalIgnoreCase
} else { [StringComparison]::Ordinal }
$homeDirectory = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath([Environment]::GetFolderPath(
[Environment+SpecialFolder]::UserProfile)))
if ([string]::Equals($AllowedOutputRoot, $Repository, $comparison) -or
[string]::Equals($AllowedOutputRoot, $homeDirectory, $comparison)) {
throw '-AllowedOutputRoot cannot be the repository root or user home.'
}
$repositoryLogs = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath((Join-Path $Repository 'logs')))
$allowedLeaf = [IO.Path]::GetFileName($AllowedOutputRoot)
$allowedInRepository = Test-SameOrDescendant $AllowedOutputRoot $Repository
if ($allowedInRepository -and
-not (Test-SameOrDescendant $AllowedOutputRoot $repositoryLogs)) {
throw '-AllowedOutputRoot inside the repository must be below its logs directory.'
}
if (-not [string]::Equals($AllowedOutputRoot, $repositoryLogs, $comparison) -and
-not $allowedLeaf.StartsWith('campaign-la-', [StringComparison]::Ordinal)) {
throw '-AllowedOutputRoot must be the repository logs root or a campaign-la-* gate root.'
}
if ($IncludeInstalledDat) {
if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or
-not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) {
@ -50,16 +111,28 @@ if ($IncludeInstalledDat) {
$stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
$OutputDirectory = Join-Path $Repository "logs/campaign-la-gate-$stamp"
$OutputDirectory = Join-Path $AllowedOutputRoot "campaign-la-preflight-$stamp"
}
elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
$OutputDirectory = Join-Path $Repository $OutputDirectory
throw '-OutputDirectory must be absolute when supplied.'
}
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath($OutputDirectory))
if (-not (Test-SameOrDescendant $OutputDirectory $AllowedOutputRoot) -or
[string]::Equals($OutputDirectory, $AllowedOutputRoot, $comparison)) {
throw '-OutputDirectory must be a strict descendant of -AllowedOutputRoot.'
}
if ([string]::Equals($OutputDirectory, $Repository, $comparison) -or
[string]::Equals($OutputDirectory, $homeDirectory, $comparison)) {
throw '-OutputDirectory cannot be the repository root or user home.'
}
if (Test-Path -LiteralPath $OutputDirectory) {
throw '-OutputDirectory must be fresh and must not already exist.'
}
Assert-NoReparseAncestry $OutputDirectory 'Output directory'
$logsDirectory = Join-Path $OutputDirectory 'commands'
$publishDirectory = Join-Path $OutputDirectory 'publish'
$null = New-Item -ItemType Directory -Force -Path $logsDirectory
$null = New-Item -ItemType Directory -Path $logsDirectory
$commandResults = [Collections.Generic.List[object]]::new()
$failures = [Collections.Generic.List[string]]::new()
@ -245,6 +318,22 @@ $portableTestProjects = @(
try {
Invoke-DotNet 'release-build' @(
'build', 'AcDream.slnx', '-c', 'Release', '--nologo', '-m:1')
Invoke-GateCommand 'campaign-la-gate-helper-contracts' `
([Environment]::ProcessPath ??
$(throw 'The PowerShell process path is unavailable.')) `
@(
'-NoProfile',
'-File', 'tools/test-campaign-la-gate-helpers.ps1',
'-Repository', $Repository,
'-OutputDirectory', (Join-Path $OutputDirectory 'helper-contracts'))
Invoke-GateCommand 'campaign-la-script-safety-contracts' `
([Environment]::ProcessPath ??
$(throw 'The PowerShell process path is unavailable.')) `
@(
'-NoProfile',
'-File', 'tools/test-campaign-la-script-safety.ps1',
'-Repository', $Repository,
'-OutputDirectory', (Join-Path $OutputDirectory 'script-safety'))
Invoke-DotNet 'release-tests-serial' @(
'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1',
'--', 'RunConfiguration.MaxCpuCount=1')
@ -384,14 +473,20 @@ finally {
$dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all)
$artifacts = @()
if (-not $DryRun) {
$artifacts = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
[string[]]$artifactPaths = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } |
Sort-Object FullName |
ForEach-Object {
[IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
})
[Array]::Sort($artifactPaths, [StringComparer]::Ordinal)
$artifacts = @($artifactPaths | ForEach-Object {
$fullPath = Join-Path $OutputDirectory $_.Replace(
'/', [IO.Path]::DirectorySeparatorChar)
$item = Get-Item -LiteralPath $fullPath
[ordered]@{
path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
size = $_.Length
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
path = $_
size = $item.Length
sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant()
}
})
}
@ -402,6 +497,7 @@ finally {
dryRun = [bool]$DryRun
success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0)
repository = $Repository
allowedOutputRoot = $AllowedOutputRoot
head = $head
dirty = ($dirtyLines.Count -gt 0)
dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ })

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)]
[ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode,
[Parameter(Mandatory = $true)]
[ValidateRange(1, 2147483647)][int]$ExpectedProcessId,
[Parameter(Mandatory = $true)][string]$CredentialProfilePath,
[string]$SessionConfigPath,
[string]$ExpectedSessionId,
[string[]]$ExpectedPlugin = @(),
[switch]$ExpectNoEnteredWorld,
[switch]$AllowPluginFailure,
[switch]$AllowLoginCommandFailure,
[switch]$AllowLauncherChildren,
[string[]]$ForbiddenEnvironmentVariable = @(
'ACDREAM_TEST_PASS',
'ACDREAM_LA_GATE_SECRET'),
[string]$ReportPath,
[int]$ProcessExitWaitSeconds = 5
)
@ -35,12 +35,59 @@ if ($PSVersionTable.PSVersion.Major -lt 7) {
if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') {
throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.'
}
. (Join-Path $PSScriptRoot 'CampaignLaProcessCorrelation.ps1')
if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) {
$StatusFile = [IO.Path]::GetFullPath($StatusFile)
}
if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) {
throw "Status file does not exist: $StatusFile"
}
if (-not [IO.Path]::IsPathFullyQualified($CredentialProfilePath)) {
throw '-CredentialProfilePath must be absolute.'
}
$CredentialProfilePath = [IO.Path]::GetFullPath($CredentialProfilePath)
if (-not (Test-Path -LiteralPath $CredentialProfilePath -PathType Leaf)) {
throw "Credential profile does not exist: $CredentialProfilePath"
}
$credentialItem = Get-Item -LiteralPath $CredentialProfilePath -Force
if (($credentialItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw 'Credential profile must not be a reparse point.'
}
if ($IsLinux) {
$ownerOnly = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite
if ([IO.File]::GetUnixFileMode($CredentialProfilePath) -ne $ownerOnly) {
throw 'Credential profile must have exact owner-only mode 0600.'
}
}
elseif ($IsWindows) {
$broadSids = @(
'S-1-1-0', # Everyone
'S-1-5-11', # Authenticated Users
'S-1-5-32-545', # Builtin Users
'S-1-5-32-546') # Guests
$acl = Get-Acl -LiteralPath $CredentialProfilePath
if ($null -eq $acl.Owner) { throw 'Credential profile has no ACL owner.' }
foreach ($rule in $acl.Access) {
if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) {
continue
}
try {
$sid = $rule.IdentityReference.Translate(
[Security.Principal.SecurityIdentifier]).Value
}
catch { $sid = [string]$rule.IdentityReference.Value }
if ($sid -in $broadSids -and $rule.FileSystemRights -ne 0) {
throw 'Credential profile grants access to a broad Windows identity.'
}
}
}
else { throw 'Campaign LA status validation supports Windows and Linux only.' }
if (-not [string]::IsNullOrWhiteSpace($SessionConfigPath)) {
if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) {
throw '-SessionConfigPath must be absolute.'
}
$SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath)
}
if ([string]::IsNullOrWhiteSpace($ReportPath)) {
$ReportPath = "$StatusFile.validation.json"
}
@ -65,6 +112,55 @@ $loadedPlugins = [Collections.Generic.List[string]]::new()
$sessionId = $null
$previousTimestamp = [DateTimeOffset]::MinValue
$terminalSeen = $false
$forbiddenValues = [Collections.Generic.HashSet[string]]::new(
[StringComparer]::Ordinal)
function Add-CredentialValues([Text.Json.JsonElement]$Element) {
if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) {
foreach ($property in $Element.EnumerateObject()) {
if ($property.Name -imatch '^(password|secret)$' -and
$property.Value.ValueKind -eq [Text.Json.JsonValueKind]::String) {
$value = $property.Value.GetString()
if (-not [string]::IsNullOrEmpty($value)) {
$null = $forbiddenValues.Add($value)
}
}
Add-CredentialValues $property.Value
}
}
elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) {
foreach ($item in $Element.EnumerateArray()) { Add-CredentialValues $item }
}
}
$credentialDocument = [Text.Json.JsonDocument]::Parse(
[IO.File]::ReadAllText($CredentialProfilePath))
try { Add-CredentialValues $credentialDocument.RootElement }
finally { $credentialDocument.Dispose() }
if ($forbiddenValues.Count -eq 0) {
throw 'Credential profile contains no non-empty password/secret value.'
}
function Test-CredentialEcho([Text.Json.JsonElement]$Element) {
if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::String) {
[string]$text = $Element.GetString()
foreach ($secret in $forbiddenValues) {
if ($text.Contains($secret, [StringComparison]::Ordinal)) { return $true }
}
return $false
}
if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) {
foreach ($property in $Element.EnumerateObject()) {
if (Test-CredentialEcho $property.Value) { return $true }
}
}
elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) {
foreach ($item in $Element.EnumerateArray()) {
if (Test-CredentialEcho $item) { return $true }
}
}
return $false
}
function Get-Properties([Text.Json.JsonElement]$Element) {
$properties = [Collections.Generic.List[object]]::new()
@ -112,13 +208,6 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
$failures.Add("line $lineNumber is empty")
continue
}
foreach ($variable in $ForbiddenEnvironmentVariable) {
$secret = [Environment]::GetEnvironmentVariable($variable)
if (-not [string]::IsNullOrEmpty($secret) -and
$line.Contains($secret, [StringComparison]::Ordinal)) {
$failures.Add("line $lineNumber contains the value of forbidden environment variable $variable")
}
}
if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') {
$failures.Add("line $lineNumber contains a credential-like JSON field")
}
@ -130,6 +219,9 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
throw 'root is not an object'
}
if (Test-CredentialEcho $root) {
throw 'an allowed string field contains an exact credential value'
}
$properties = @(Get-Properties $root)
$names = @($properties | ForEach-Object { $_.Name })
if (@($names | Sort-Object -Unique).Count -ne $names.Count) {
@ -214,7 +306,12 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
throw 'loginCommandFailed is not allowed for this row'
}
}
'disconnected' { $null = Assert-String $root 'reason' }
'disconnected' {
$reason = Assert-String $root 'reason'
if ($reason -cne 'stopped') {
throw "terminal disconnected reason is '$reason', expected 'stopped'"
}
}
'exited' {
$code = Assert-Int32 $root 'code'
$reason = Assert-String $root 'reason'
@ -304,16 +401,27 @@ for ($index = 0; $index -lt $eventNames.Count; $index++) {
}
}
if (-not $AllowLauncherChildren) {
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
do {
$expectedProcess = Get-Process -Id $ExpectedProcessId -ErrorAction SilentlyContinue
if ($null -eq $expectedProcess) { break }
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
if ($null -ne $expectedProcess) {
$failures.Add("expected launcher child PID $ExpectedProcessId remains alive")
}
$pathCorrelationChecked = -not [string]::IsNullOrWhiteSpace($SessionConfigPath)
if ($pathCorrelationChecked) {
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
do {
$children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue)
if ($children.Count -eq 0) { break }
$correlated = @(Get-CampaignLaCorrelatedProcessIds $SessionConfigPath)
if ($correlated.Count -eq 0) { break }
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
if ($children.Count -gt 0) {
if ($correlated.Count -gt 0) {
$failures.Add(
"launcher child process leak(s): $((@($children | ForEach-Object { $_.ProcessName + ':' + $_.Id })) -join ',')")
"session-config-correlated launcher child PID(s) remain: $($correlated -join ',')")
}
}
@ -334,7 +442,11 @@ $report = [ordered]@{
eventNames = @($eventNames)
loadedPluginCount = $loadedPlugins.Count
terminalObserved = $terminalSeen
launcherChildrenAllowed = [bool]$AllowLauncherChildren
expectedProcessId = $ExpectedProcessId
processExited = ($null -eq $expectedProcess)
sessionConfigCorrelationChecked = $pathCorrelationChecked
credentialPermissionsValidated = $true
forbiddenCredentialValueCount = $forbiddenValues.Count
failures = @($failures)
validatedUtc = [DateTime]::UtcNow.ToString('O')
}