fix(launcher): harden LA11 gate evidence

This commit is contained in:
Erik 2026-08-15 01:45:15 +02:00
parent accd01a008
commit 9f9c116792
7 changed files with 514 additions and 89 deletions

View file

@ -1,31 +1,127 @@
Set-StrictMode -Version Latest
function Get-CampaignLaSha256([string]$Text) {
$bytes = [Text.Encoding]::UTF8.GetBytes($Text)
return [Convert]::ToHexString(
[Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant()
}
function Get-CampaignLaCommandLineFingerprint(
[string]$ExecutablePath,
[string]$ConfigArgument,
[string]$SessionConfigPath) {
if (-not [IO.Path]::IsPathFullyQualified($ExecutablePath) -or
-not [IO.Path]::IsPathFullyQualified($SessionConfigPath) -or
$ConfigArgument -cnotin @('--config', '--session-config')) {
throw 'Cannot fingerprint an incomplete launcher-child command line.'
}
$executable = [IO.Path]::GetFullPath($ExecutablePath)
$config = [IO.Path]::GetFullPath($SessionConfigPath)
# Only the executable and the recognized config argument are retained in
# this projection. Launcher credentials use stdin; unrelated argv is
# deliberately excluded so an accidental secret can never enter evidence.
return Get-CampaignLaSha256(
"campaign-la-child-command-v1`n$executable`n$ConfigArgument`n$config")
}
function Get-CampaignLaLinuxProcessIdentity(
[string]$ProcessDirectory,
[string]$BootId) {
$stat = [IO.File]::ReadAllText((Join-Path $ProcessDirectory 'stat'))
$commandEnd = $stat.LastIndexOf(')')
if ($commandEnd -lt 2 -or $commandEnd + 2 -ge $stat.Length) {
throw 'Linux process stat record is malformed.'
}
# The tail begins at field 3 (state); field 22 (starttime) is index 19.
$tail = @($stat.Substring($commandEnd + 2).Split(
' ',
[StringSplitOptions]::RemoveEmptyEntries))
if ($tail.Count -le 19) { throw 'Linux process stat record has no starttime.' }
$startTicks = [uint64]::Parse(
$tail[19],
[Globalization.NumberStyles]::None,
[Globalization.CultureInfo]::InvariantCulture)
return "linux-proc-start-v1:$BootId`:$startTicks"
}
function Get-CampaignLaProcessInstanceIdentity {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateRange(1, 2147483647)][int]$ProcessId)
if ($IsWindows) {
$candidate = Get-CimInstance Win32_Process `
-Filter "ProcessId=$ProcessId" -ErrorAction Stop
if ($null -eq $candidate) { return $null }
if ($null -eq $candidate.CreationDate) {
throw "Windows process $ProcessId has no creation time."
}
return "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)"
}
if ($IsLinux) {
$directory = "/proc/$ProcessId"
if (-not [IO.Directory]::Exists($directory)) { return $null }
try {
$bootId = [IO.File]::ReadAllText(
'/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant()
if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
throw 'Linux boot id is malformed.'
}
return Get-CampaignLaLinuxProcessIdentity $directory $bootId
}
catch [IO.FileNotFoundException] { return $null }
catch [IO.DirectoryNotFoundException] { return $null }
catch [IO.IOException] {
if (-not [IO.Directory]::Exists($directory)) { return $null }
throw
}
}
throw 'Campaign LA process identity supports Windows and Linux only.'
}
function Get-CampaignLaSessionProcessCorrelations {
[CmdletBinding()]
param()
$matches = [Collections.Generic.List[object]]::new()
$correlations = [Collections.Generic.List[object]]::new()
if ($IsWindows) {
$pattern = '(?i)(?:^|\s)(?:--config|--session-config)\s+(?:"([^"]+)"|(\S+))'
$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 }
$executablePath = [string]$candidate.ExecutablePath
if ([string]::IsNullOrWhiteSpace($commandLine) -or
-not [IO.Path]::IsPathFullyQualified($executablePath) -or
$null -eq $candidate.CreationDate) {
continue
}
$identity = "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)"
foreach ($match in [Text.RegularExpressions.Regex]::Matches(
$commandLine,
$pattern)) {
$value = if ($match.Groups[1].Success) {
$match.Groups[1].Value
} else { $match.Groups[2].Value }
$argument = $match.Groups[1].Value.ToLowerInvariant()
$value = if ($match.Groups[2].Success) {
$match.Groups[2].Value
} else { $match.Groups[3].Value }
if ([IO.Path]::IsPathFullyQualified($value)) {
$matches.Add([pscustomobject]@{
$configPath = [IO.Path]::GetFullPath($value)
$correlations.Add([pscustomobject]@{
ProcessId = [int]$candidate.ProcessId
SessionConfigPath = [IO.Path]::GetFullPath($value)
ProcessInstanceIdentity = $identity
SessionConfigPath = $configPath
CommandLineFingerprintSha256 =
Get-CampaignLaCommandLineFingerprint `
$executablePath $argument $configPath
})
}
}
}
}
elseif ($IsLinux) {
$bootId = [IO.File]::ReadAllText('/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant()
if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
throw 'Linux boot id is malformed.'
}
foreach ($directory in [IO.Directory]::EnumerateDirectories('/proc')) {
$leaf = [IO.Path]::GetFileName($directory)
$processId = 0
@ -37,24 +133,34 @@ function Get-CampaignLaSessionProcessCorrelations {
continue
}
try {
$identityBefore = Get-CampaignLaLinuxProcessIdentity $directory $bootId
$bytes = [IO.File]::ReadAllBytes((Join-Path $directory 'cmdline'))
if ($bytes.Length -eq 0) { continue }
$arguments = @([Text.Encoding]::UTF8.GetString($bytes).Split(
[char]0,
[StringSplitOptions]::RemoveEmptyEntries))
$identityAfter = Get-CampaignLaLinuxProcessIdentity $directory $bootId
if ($identityBefore -cne $identityAfter -or $arguments.Count -eq 0 -or
-not [IO.Path]::IsPathFullyQualified($arguments[0])) {
continue
}
for ($index = 0; $index + 1 -lt $arguments.Count; $index++) {
if ($arguments[$index] -cin @('--config', '--session-config') -and
[IO.Path]::IsPathFullyQualified($arguments[$index + 1])) {
$matches.Add([pscustomobject]@{
$configPath = [IO.Path]::GetFullPath($arguments[$index + 1])
$correlations.Add([pscustomobject]@{
ProcessId = $processId
SessionConfigPath = [IO.Path]::GetFullPath(
$arguments[$index + 1])
ProcessInstanceIdentity = $identityBefore
SessionConfigPath = $configPath
CommandLineFingerprintSha256 =
Get-CampaignLaCommandLineFingerprint `
$arguments[0] $arguments[$index] $configPath
})
}
}
}
catch [IO.IOException] {
# A process may exit between /proc enumeration and cmdline read.
# A process may exit between /proc enumeration and either read.
}
catch [UnauthorizedAccessException] {
# Other-user processes cannot be the owner-readable gate child.
@ -65,7 +171,7 @@ function Get-CampaignLaSessionProcessCorrelations {
throw 'Campaign LA process correlation supports Windows and Linux only.'
}
return @($matches)
return @($correlations)
}
function Get-CampaignLaCorrelatedProcessIds {
@ -79,15 +185,48 @@ function Get-CampaignLaCorrelatedProcessIds {
$comparison = if ($IsWindows) {
[StringComparison]::OrdinalIgnoreCase
} else { [StringComparison]::Ordinal }
$matches = [Collections.Generic.HashSet[int]]::new()
$processIds = [Collections.Generic.HashSet[int]]::new()
foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) {
if ([string]::Equals(
$candidate.SessionConfigPath,
$SessionConfigPath,
$comparison)) {
$null = $matches.Add([int]$candidate.ProcessId)
$null = $processIds.Add([int]$candidate.ProcessId)
}
}
return @($matches | Sort-Object)
return @($processIds | Sort-Object)
}
function Test-CampaignLaCapturedProcessState {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][int]$ProcessId,
[Parameter(Mandatory = $true)][string]$ProcessInstanceIdentity,
[Parameter(Mandatory = $true)][string]$SessionConfigPath,
[AllowNull()][string]$CurrentProcessInstanceIdentity,
[Parameter(Mandatory = $true)]
[AllowEmptyCollection()][object[]]$Correlations)
$comparison = if ($IsWindows) {
[StringComparison]::OrdinalIgnoreCase
} else { [StringComparison]::Ordinal }
$sameInstanceAlive = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and
$CurrentProcessInstanceIdentity -ceq $ProcessInstanceIdentity
$exactConfigPathAlive = $false
$pidReused = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and
$CurrentProcessInstanceIdentity -cne $ProcessInstanceIdentity
foreach ($candidate in $Correlations) {
if ([string]::Equals(
[string]$candidate.SessionConfigPath,
$SessionConfigPath,
$comparison)) {
$exactConfigPathAlive = $true
}
}
return [pscustomobject]@{
SameInstanceAlive = $sameInstanceAlive
ExactConfigPathAlive = $exactConfigPathAlive
PidReused = $pidReused
}
}

View file

@ -90,18 +90,26 @@ if ($correlations.Count -ne 1) {
throw 'No live process uses the isolated session config.'
}
$SessionConfigPath = [IO.Path]::GetFullPath($correlations[0].SessionConfigPath)
$processIdentity = [string]$correlations[0].ProcessInstanceIdentity
$commandFingerprint = [string]$correlations[0].CommandLineFingerprintSha256
if ($processIdentity -notmatch '^(windows-creation-v1:[0-9]{15,19}|linux-proc-start-v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9]+)$' -or
$commandFingerprint -notmatch '^[0-9a-f]{64}$') {
throw 'The correlated process instance evidence is malformed.'
}
$directory = Split-Path -Parent $ReportPath
if (-not [string]::IsNullOrEmpty($directory)) {
$null = New-Item -ItemType Directory -Force -Path $directory
}
$report = [ordered]@{
schemaVersion = 1
schemaVersion = 2
kind = 'campaign-la-session-process-capture'
processId = [int]$correlations[0].ProcessId
processInstanceIdentity = $processIdentity
sessionId = [IO.Path]::GetFileName(
[IO.Path]::GetDirectoryName($SessionConfigPath))
sessionConfigFile = [IO.Path]::GetFileName($SessionConfigPath)
sessionConfigPath = $SessionConfigPath
commandLineFingerprintSha256 = $commandFingerprint
capturedUtc = [DateTime]::UtcNow.ToString('O')
}
$report | ConvertTo-Json -Depth 3 |

View file

@ -208,10 +208,11 @@ function Set-DeterministicZipHostPlatform([string]$Path) {
(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
# ZipArchive stamps the creating host (FAT on Windows, Unix on Linux)
# in the upper byte of "version made by". Normalize to Unix so native
# extraction honors the explicit regular-file type and 0755/0644 mode
# bits already stored in ExternalAttributes.
$bytes[[int]$cursor + 5] = 3
$nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28)
$extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30)
$commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32)

View file

@ -28,6 +28,7 @@ if ([string]::IsNullOrWhiteSpace($pwsh)) {
}
$validator = Join-Path $Repository 'tools/test-campaign-la-session-status.ps1'
$capture = Join-Path $Repository 'tools/capture-campaign-la-session-process.ps1'
. (Join-Path $Repository 'tools/CampaignLaProcessCorrelation.ps1')
function Write-Profile([string]$Path, [string]$Secret) {
$document = [ordered]@{
@ -86,16 +87,14 @@ function Invoke-Validator(
[string]$Status,
[string]$Profile,
[string]$Report,
[int]$ExpectedProcessId,
[bool]$ShouldPass,
[string]$SessionConfig = '') {
[string]$ProcessCapture,
[bool]$ShouldPass) {
$arguments = [Collections.Generic.List[string]]::new()
foreach ($value in @(
'-NoProfile', '-File', $validator,
'-StatusFile', $Status,
'-Mode', 'gui',
'-ExpectedProcessId', $ExpectedProcessId.ToString(
[Globalization.CultureInfo]::InvariantCulture),
'-ProcessCapturePath', $ProcessCapture,
'-CredentialProfilePath', $Profile,
'-ExpectedPlugin', 'smoke',
'-AllowPluginFailure',
@ -103,10 +102,6 @@ function Invoke-Validator(
'-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
@ -127,6 +122,30 @@ function Invoke-Validator(
}
}
function Write-ProcessCapture(
[string]$Path,
[int]$ProcessId,
[string]$ProcessInstanceIdentity,
[string]$SessionConfigPath,
[string]$CommandFingerprint = ('a' * 64)) {
$sessionId = [IO.Path]::GetFileName(
[IO.Path]::GetDirectoryName($SessionConfigPath))
$document = [ordered]@{
schemaVersion = 2
kind = 'campaign-la-session-process-capture'
processId = $ProcessId
processInstanceIdentity = $ProcessInstanceIdentity
sessionId = $sessionId
sessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath)
commandLineFingerprintSha256 = $CommandFingerprint
capturedUtc = [DateTime]::UtcNow.ToString('O')
}
[IO.File]::WriteAllText(
$Path,
($document | ConvertTo-Json -Depth 4),
[Text.UTF8Encoding]::new($false))
}
$quickInfo = [Diagnostics.ProcessStartInfo]::new($pwsh)
$quickInfo.UseShellExecute = $false
$quickInfo.ArgumentList.Add('-NoProfile')
@ -138,13 +157,33 @@ $goneProcessId = $quick.Id
$quick.WaitForExit()
$quick.Dispose()
$sessionRoot = Join-Path $OutputDirectory 'fixture-session'
$null = New-Item -ItemType Directory -Path $sessionRoot
$sessionConfig = Join-Path $sessionRoot 'session.json'
[IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false))
$syntheticIdentity = if ($IsWindows) {
'windows-creation-v1:638000000000000000'
} else { 'linux-proc-start-v1:00000000-0000-0000-0000-000000000001:1' }
$goneCapture = Join-Path $OutputDirectory 'gone-process.capture.json'
Write-ProcessCapture `
$goneCapture $goneProcessId $syntheticIdentity $sessionConfig
$profile = Join-Path $OutputDirectory 'launcher-profiles.json'
Write-Profile $profile 'la11-positive-secret-7E477A2D'
$positiveStatus = Join-Path $OutputDirectory 'positive.jsonl'
Write-Events $positiveStatus (New-GuiEvents)
Invoke-Validator `
$positiveStatus $profile (Join-Path $OutputDirectory 'positive.validation.json') `
$goneProcessId $true
$goneCapture $true
$malformedCapture = Join-Path $OutputDirectory 'malformed-process.capture.json'
[IO.File]::WriteAllText(
$malformedCapture,
'{"schemaVersion":2}',
[Text.UTF8Encoding]::new($false))
Invoke-Validator `
$positiveStatus $profile (Join-Path $OutputDirectory 'malformed.validation.json') `
$malformedCapture $false
foreach ($reason in @('transport', 'reconnect', 'other')) {
$events = @(New-GuiEvents)
@ -152,7 +191,7 @@ foreach ($reason in @('transport', 'reconnect', 'other')) {
$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
Invoke-Validator $path $profile $report $goneCapture $false
$result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json
if (-not ($result.failures -match 'disconnected reason')) {
throw "Disconnected reason '$reason' was not rejected by its exact assertion."
@ -186,7 +225,7 @@ foreach ($case in $secretCases) {
$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
Invoke-Validator $path $caseProfile $report $goneCapture $false
$result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json
if (-not ($result.failures -match 'credential value')) {
throw "Credential echo case '$case' was not rejected by recursive scanning."
@ -212,12 +251,14 @@ if ($IsLinux) {
[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'
$unrelatedSessionRoot = Join-Path $OutputDirectory 'unrelated-session'
$null = New-Item -ItemType Directory -Path $unrelatedSessionRoot
$unrelatedConfig = Join-Path $unrelatedSessionRoot 'session.json'
[IO.File]::WriteAllText($unrelatedConfig, '{}', [Text.UTF8Encoding]::new($false))
function Start-Fixture([string[]]$Arguments) {
$start = [Diagnostics.ProcessStartInfo]::new($sameNameHost)
@ -230,7 +271,7 @@ function Start-Fixture([string[]]$Arguments) {
$target = Start-Fixture @(
'hold-campaign-la-process', '--config', $sessionConfig, $targetReady, $targetRelease)
$unrelated = Start-Fixture @(
'hold-update-lease', 'session', (Join-Path $OutputDirectory 'unrelated-data'),
'hold-campaign-la-process', '--config', $unrelatedConfig,
$unrelatedReady, $unrelatedRelease)
if ($null -eq $target -or $null -eq $unrelated) {
throw 'Could not start process-correlation fixtures.'
@ -252,16 +293,19 @@ try {
-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.'
if ([int]$captured.processId -ne $target.Id -or
[string]$captured.sessionConfigPath -cne $sessionConfig -or
[string]$captured.commandLineFingerprintSha256 -cnotmatch '^[0-9a-f]{64}$') {
throw 'Process capture did not return exact sanitized instance evidence.'
}
$liveReport = Join-Path $OutputDirectory 'live-pid.validation.json'
Invoke-Validator `
$positiveStatus $profile $liveReport $target.Id $false $sessionConfig
$positiveStatus $profile $liveReport $captureReport $false
$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.'
if (-not ($liveResult.failures -match 'process instance.*remains alive') -or
$liveResult.capturedProcessInstanceExited) {
throw 'A live exact child instance was not rejected by the terminal validator.'
}
Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline
@ -269,7 +313,31 @@ try {
Invoke-Validator `
$positiveStatus $profile `
(Join-Path $OutputDirectory 'unrelated-same-name.validation.json') `
$target.Id $true $sessionConfig
$captureReport $true
$reusedCapture = Join-Path $OutputDirectory 'reused-pid.capture.json'
$capturedIdentity = [string]$captured.processInstanceIdentity
$identitySeparator = $capturedIdentity.LastIndexOf(':')
$capturedStartValue = [uint64]::Parse(
$capturedIdentity.Substring($identitySeparator + 1),
[Globalization.CultureInfo]::InvariantCulture)
$reusedPriorIdentity = $capturedIdentity.Substring(0, $identitySeparator + 1) `
+ ($capturedStartValue + 1).ToString(
[Globalization.CultureInfo]::InvariantCulture)
Write-ProcessCapture `
$reusedCapture `
$unrelated.Id `
$reusedPriorIdentity `
$sessionConfig `
([string]$captured.commandLineFingerprintSha256)
$reusedReport = Join-Path $OutputDirectory 'reused-pid.validation.json'
Invoke-Validator $positiveStatus $profile $reusedReport $reusedCapture $true
$reusedResult = Get-Content -LiteralPath $reusedReport -Raw | ConvertFrom-Json
if (-not $reusedResult.capturedPidReused -or
-not $reusedResult.capturedProcessInstanceExited -or
-not $reusedResult.sessionConfigProcessExited) {
throw 'A reused PID was not distinguished from the exited captured instance.'
}
}
finally {
Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline
@ -287,7 +355,10 @@ $summary = [ordered]@{
disconnectedReasonNegatives = 3
credentialStringFieldNegatives = $secretCases.Count
exactPidCapture = $true
livePidRejected = $true
stableProcessInstanceCapture = $true
liveProcessInstanceRejected = $true
malformedProcessCaptureRejected = $true
injectedPidReuseIgnored = $true
unrelatedSameNameIgnored = $true
platform = if ($IsWindows) { 'windows' } else { 'linux' }
}

View file

@ -165,6 +165,84 @@ function Write-PayloadFile([string]$Root, [string]$Name, [string]$Content) {
$null = New-Item -ItemType Directory -Force -Path $directory
[IO.File]::WriteAllText($path, $Content, [Text.UTF8Encoding]::new($false))
}
function Get-ZipUInt16([byte[]]$Bytes, [int]$Offset) {
return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8)
}
function Get-ZipUInt32([byte[]]$Bytes, [int]$Offset) {
return [uint32]([uint32]$Bytes[$Offset] -bor
([uint32]$Bytes[$Offset + 1] -shl 8) -bor
([uint32]$Bytes[$Offset + 2] -shl 16) -bor
([uint32]$Bytes[$Offset + 3] -shl 24))
}
function Test-ZipExecutableName([string]$Name) {
return $Name -cin @(
'AcDream.App', 'acdream-headless', 'acdream-launcher', 'acdream-bake')
}
function Assert-ZipUnixMetadata([string]$Path) {
[byte[]]$bytes = [IO.File]::ReadAllBytes($Path)
$eocd = $bytes.Length - 22
if ($eocd -lt 0 -or (Get-ZipUInt32 $bytes $eocd) -ne 0x06054b50 -or
(Get-ZipUInt16 $bytes ($eocd + 20)) -ne 0) {
throw "Fixture ZIP end record is invalid: $Path"
}
$entryCount = Get-ZipUInt16 $bytes ($eocd + 10)
$centralSize = Get-ZipUInt32 $bytes ($eocd + 12)
[uint64]$cursor = Get-ZipUInt32 $bytes ($eocd + 16)
$centralEnd = $cursor + $centralSize
if ($centralEnd -ne $eocd) { throw "Fixture ZIP central bounds are invalid: $Path" }
$rawModes = @{}
for ($index = 0; $index -lt $entryCount; $index++) {
if ($cursor + 46 -gt $centralEnd -or
(Get-ZipUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) {
throw "Fixture ZIP central entry is invalid: $Path"
}
if ($bytes[[int]$cursor + 5] -ne 3) {
throw "Fixture ZIP entry origin is not Unix: $Path"
}
$nameLength = Get-ZipUInt16 $bytes ([int]$cursor + 28)
$extraLength = Get-ZipUInt16 $bytes ([int]$cursor + 30)
$commentLength = Get-ZipUInt16 $bytes ([int]$cursor + 32)
$name = [Text.Encoding]::UTF8.GetString(
$bytes,
[int]$cursor + 46,
$nameLength)
$expectedMode = if (Test-ZipExecutableName $name) { 0x81ED } else { 0x81A4 }
$external = Get-ZipUInt32 $bytes ([int]$cursor + 38)
$expectedExternal = [uint32](([uint64]$expectedMode) -shl 16)
if ($external -ne $expectedExternal) {
throw "Fixture ZIP entry '$name' has wrong raw type/mode bits."
}
$rawModes[$name] = $expectedMode
$cursor += 46 + $nameLength + $extraLength + $commentLength
}
if ($cursor -ne $centralEnd) { throw "Fixture ZIP central length is invalid: $Path" }
Add-Type -AssemblyName System.IO.Compression
$stream = [IO.File]::OpenRead($Path)
try {
$archive = [IO.Compression.ZipArchive]::new(
$stream,
[IO.Compression.ZipArchiveMode]::Read,
$false,
[Text.Encoding]::UTF8)
try {
if ($archive.Entries.Count -ne $rawModes.Count) {
throw "Fixture ZIP entry count changed through ZipArchive: $Path"
}
foreach ($entry in $archive.Entries) {
$mode = ($entry.ExternalAttributes -shr 16) -band 0xffff
if (-not $rawModes.ContainsKey($entry.FullName) -or
$mode -ne $rawModes[$entry.FullName]) {
throw "ZipArchive reports wrong type/mode for '$($entry.FullName)'."
}
}
}
finally { $archive.Dispose() }
}
finally { $stream.Dispose() }
}
$payloadRoot = Join-Path $OutputDirectory 'deterministic-payloads'
$payloads = [ordered]@{
ClientWin = Join-Path $payloadRoot 'client-win'
@ -208,6 +286,9 @@ try {
[Globalization.CultureInfo]::CurrentUICulture = $culture
$destination = Join-Path $OutputDirectory "fixture-$cultureName"
& $fixture -OutputDirectory $destination @fixtureParameters
foreach ($zip in @(Get-ChildItem -LiteralPath $destination -Filter '*.zip' -File -Recurse)) {
Assert-ZipUnixMetadata $zip.FullName
}
$relativePaths = [string[]]@(Get-ChildItem -LiteralPath $destination -File -Recurse |
Where-Object { $_.Name -ne 'fixture-report.json' } |
ForEach-Object {
@ -235,9 +316,49 @@ $digestBytes = [Security.Cryptography.SHA256]::HashData(
[Text.Encoding]::UTF8.GetBytes($firstInventory))
$deterministicDigest = [Convert]::ToHexString($digestBytes).ToLowerInvariant()
$expectedCrossPlatformDigest =
'9c77b7204dd19e77fad62e572304d52e810afe2d0821c2ec57a692d27a0cc167'
'cc58d5717de6686690b7f01213c9d52a99aef49447ff645e134f8c97ec8e3a76'
if ($deterministicDigest -cne $expectedCrossPlatformDigest) {
throw 'Fixture artifact hashes differ from the pinned Windows/Linux contract.'
throw "Fixture artifact hashes differ from the pinned Windows/Linux contract: actual $deterministicDigest."
}
$nativeExtractionModesValidated = $false
if ($IsLinux) {
$unzip = @(Get-Command unzip -CommandType Application -ErrorAction Stop)[0].Source
$extractClient = Join-Path $OutputDirectory 'native-extract-client'
$extractLauncher = Join-Path $OutputDirectory 'native-extract-launcher'
$null = New-Item -ItemType Directory -Path $extractClient
$null = New-Item -ItemType Directory -Path $extractLauncher
& $unzip -qq (Join-Path $OutputDirectory 'fixture-en-US/A/client-linux-x64.zip') `
-d $extractClient
if ($LASTEXITCODE -ne 0) { throw 'Native client ZIP extraction failed.' }
& $unzip -qq (Join-Path $OutputDirectory 'fixture-en-US/A/launcher-linux-x64.zip') `
-d $extractLauncher
if ($LASTEXITCODE -ne 0) { throw 'Native launcher ZIP extraction failed.' }
$mode755 = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor
[IO.UnixFileMode]::UserExecute -bor [IO.UnixFileMode]::GroupRead -bor
[IO.UnixFileMode]::GroupExecute -bor [IO.UnixFileMode]::OtherRead -bor
[IO.UnixFileMode]::OtherExecute
$mode644 = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor
[IO.UnixFileMode]::GroupRead -bor [IO.UnixFileMode]::OtherRead
foreach ($path in @(
(Join-Path $extractClient 'AcDream.App'),
(Join-Path $extractClient 'acdream-headless'),
(Join-Path $extractLauncher 'acdream-launcher'),
(Join-Path $extractLauncher 'acdream-bake'))) {
if ([IO.File]::GetUnixFileMode($path) -ne $mode755) {
throw "Native extraction did not retain mode 0755: $path"
}
}
foreach ($path in @(
(Join-Path $extractClient 'nested/I.txt'),
(Join-Path $extractClient 'campaign-la-fixture-release.txt'),
(Join-Path $extractLauncher 'nested/Z.txt'),
(Join-Path $extractLauncher 'campaign-la-fixture-release.txt'))) {
if ([IO.File]::GetUnixFileMode($path) -ne $mode644) {
throw "Native extraction did not retain mode 0644: $path"
}
}
$nativeExtractionModesValidated = $true
}
$summary = [ordered]@{
@ -248,6 +369,9 @@ $summary = [ordered]@{
cultures = @('en-US', 'tr-TR', 'sv-SE')
fixtureArtifactSetSha256 = $deterministicDigest
crossPlatformExpectedSha256 = $expectedCrossPlatformDigest
zipOrigin = 'unix'
zipModesValidated = $true
nativeExtractionModesValidated = $nativeExtractionModesValidated
}
$summary | ConvertTo-Json -Depth 5 |
Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM

View file

@ -14,10 +14,8 @@ 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]$ProcessCapturePath,
[Parameter(Mandatory = $true)][string]$CredentialProfilePath,
[string]$SessionConfigPath,
[string]$ExpectedSessionId,
[string[]]$ExpectedPlugin = @(),
[switch]$ExpectNoEnteredWorld,
@ -42,6 +40,87 @@ if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) {
if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) {
throw "Status file does not exist: $StatusFile"
}
if (-not [IO.Path]::IsPathFullyQualified($ProcessCapturePath)) {
throw '-ProcessCapturePath must be absolute.'
}
$ProcessCapturePath = [IO.Path]::GetFullPath($ProcessCapturePath)
if (-not (Test-Path -LiteralPath $ProcessCapturePath -PathType Leaf)) {
throw "Process capture does not exist: $ProcessCapturePath"
}
$captureItem = Get-Item -LiteralPath $ProcessCapturePath -Force
if (($captureItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw 'Process capture must not be a reparse point.'
}
$captureDocument = [Text.Json.JsonDocument]::Parse(
[IO.File]::ReadAllText($ProcessCapturePath))
try {
$captureRoot = $captureDocument.RootElement
if ($captureRoot.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
throw 'Process capture root must be an object.'
}
$captureNames = @($captureRoot.EnumerateObject() | ForEach-Object { $_.Name })
$expectedCaptureNames = @(
'schemaVersion', 'kind', 'processId', 'processInstanceIdentity',
'sessionId', 'sessionConfigPath', 'commandLineFingerprintSha256',
'capturedUtc')
if ([string]::Join("`n", $captureNames) -cne
[string]::Join("`n", $expectedCaptureNames)) {
throw 'Process capture fields/order do not match schema v2.'
}
if ($captureRoot.GetProperty('schemaVersion').GetInt32() -ne 2 -or
$captureRoot.GetProperty('kind').GetString() -cne
'campaign-la-session-process-capture') {
throw 'Process capture schema/kind is invalid.'
}
$capturedProcessId = $captureRoot.GetProperty('processId').GetInt32()
if ($capturedProcessId -le 0) { throw 'Process capture PID is invalid.' }
$capturedProcessIdentity = $captureRoot.GetProperty(
'processInstanceIdentity').GetString()
$expectedIdentityPattern = if ($IsWindows) {
'^windows-creation-v1:[0-9]{15,19}$'
} else { '^linux-proc-start-v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9]+$' }
if ($capturedProcessIdentity -notmatch $expectedIdentityPattern) {
throw 'Process capture instance identity is invalid for this platform.'
}
$capturedSessionId = $captureRoot.GetProperty('sessionId').GetString()
if ([string]::IsNullOrWhiteSpace($capturedSessionId) -or
$capturedSessionId.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0) {
throw 'Process capture session id is invalid.'
}
$capturedSessionConfigPath = $captureRoot.GetProperty(
'sessionConfigPath').GetString()
if (-not [IO.Path]::IsPathFullyQualified($capturedSessionConfigPath)) {
throw 'Process capture session-config path is not absolute.'
}
$normalizedCapturedConfigPath = [IO.Path]::GetFullPath(
$capturedSessionConfigPath)
if ($capturedSessionConfigPath -cne $normalizedCapturedConfigPath -or
[IO.Path]::GetFileName($capturedSessionConfigPath) -cne 'session.json' -or
[IO.Path]::GetFileName([IO.Path]::GetDirectoryName(
$capturedSessionConfigPath)) -cne $capturedSessionId) {
throw 'Process capture session-config path is not the exact normalized session path.'
}
$capturedCommandFingerprint = $captureRoot.GetProperty(
'commandLineFingerprintSha256').GetString()
if ($capturedCommandFingerprint -cnotmatch '^[0-9a-f]{64}$') {
throw 'Process capture command-line fingerprint is invalid.'
}
$capturedUtcText = $captureRoot.GetProperty('capturedUtc').GetString()
$capturedUtc = [DateTimeOffset]::MinValue
if (-not [DateTimeOffset]::TryParseExact(
$capturedUtcText,
'O',
[Globalization.CultureInfo]::InvariantCulture,
[Globalization.DateTimeStyles]::RoundtripKind,
[ref]$capturedUtc) -or $capturedUtc.Offset -ne [TimeSpan]::Zero) {
throw 'Process capture timestamp is not exact UTC round-trip form.'
}
}
finally { $captureDocument.Dispose() }
if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and
$ExpectedSessionId -cne $capturedSessionId) {
throw 'Process capture session id does not match -ExpectedSessionId.'
}
if (-not [IO.Path]::IsPathFullyQualified($CredentialProfilePath)) {
throw '-CredentialProfilePath must be absolute.'
}
@ -82,12 +161,6 @@ elseif ($IsWindows) {
}
}
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"
}
@ -402,27 +475,29 @@ for ($index = 0; $index -lt $eventNames.Count; $index++) {
}
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
$capturedState = $null
do {
$expectedProcess = Get-Process -Id $ExpectedProcessId -ErrorAction SilentlyContinue
if ($null -eq $expectedProcess) { break }
$currentProcessIdentity = Get-CampaignLaProcessInstanceIdentity `
-ProcessId $capturedProcessId
$correlations = @(Get-CampaignLaSessionProcessCorrelations)
$capturedState = Test-CampaignLaCapturedProcessState `
-ProcessId $capturedProcessId `
-ProcessInstanceIdentity $capturedProcessIdentity `
-SessionConfigPath $capturedSessionConfigPath `
-CurrentProcessInstanceIdentity $currentProcessIdentity `
-Correlations $correlations
if (-not $capturedState.SameInstanceAlive -and
-not $capturedState.ExactConfigPathAlive) {
break
}
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
if ($null -ne $expectedProcess) {
$failures.Add("expected launcher child PID $ExpectedProcessId remains alive")
if ($capturedState.SameInstanceAlive) {
$failures.Add(
"captured launcher child process instance PID $capturedProcessId remains alive")
}
$pathCorrelationChecked = -not [string]::IsNullOrWhiteSpace($SessionConfigPath)
if ($pathCorrelationChecked) {
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
do {
$correlated = @(Get-CampaignLaCorrelatedProcessIds $SessionConfigPath)
if ($correlated.Count -eq 0) { break }
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
if ($correlated.Count -gt 0) {
$failures.Add(
"session-config-correlated launcher child PID(s) remain: $($correlated -join ',')")
}
if ($capturedState.ExactConfigPathAlive) {
$failures.Add('a launcher child remains correlated to the exact session-config path')
}
$reportDirectory = Split-Path -Parent $ReportPath
@ -442,9 +517,12 @@ $report = [ordered]@{
eventNames = @($eventNames)
loadedPluginCount = $loadedPlugins.Count
terminalObserved = $terminalSeen
expectedProcessId = $ExpectedProcessId
processExited = ($null -eq $expectedProcess)
sessionConfigCorrelationChecked = $pathCorrelationChecked
capturedProcessId = $capturedProcessId
capturedProcessInstanceExited = (-not $capturedState.SameInstanceAlive)
capturedPidReused = [bool]$capturedState.PidReused
sessionConfigCorrelationChecked = $true
sessionConfigProcessExited = (-not $capturedState.ExactConfigPathAlive)
processCaptureSha256 = (Get-FileHash -LiteralPath $ProcessCapturePath -Algorithm SHA256).Hash.ToLowerInvariant()
credentialPermissionsValidated = $true
forbiddenCredentialValueCount = $forbiddenValues.Count
failures = @($failures)