<# .SYNOPSIS Strict Campaign LA v1 session-status and terminal-process validator. .DESCRIPTION Validates exact JSONL property sets and property order, lifecycle order for probe/guiSelect/gui/headless, terminal semantics, plugin expectations, credential redaction, and absence of launcher child-process leaks. The report contains hashes and event names only; it does not copy account, character, command, plugin-error, or other payload text. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$StatusFile, [Parameter(Mandatory = $true)] [ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode, [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 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($PSVersionTable.PSVersion.Major -lt 7) { throw 'Campaign LA status validation requires PowerShell 7 or newer.' } if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') { throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.' } 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 ([string]::IsNullOrWhiteSpace($ReportPath)) { $ReportPath = "$StatusFile.validation.json" } elseif (-not [IO.Path]::IsPathFullyQualified($ReportPath)) { $ReportPath = [IO.Path]::GetFullPath($ReportPath) } $exactFields = @{ started = @('v', 'e', 't', 'sessionId') connected = @('v', 'e', 't', 'sessionId') characterList = @('v', 'e', 't', 'sessionId', 'accountName', 'slotCount', 'characters') enteredWorld = @('v', 'e', 't', 'sessionId', 'characterId', 'characterName') pluginLoaded = @('v', 'e', 't', 'sessionId', 'plugin') pluginFailed = @('v', 'e', 't', 'sessionId', 'plugin', 'error') loginCommandFailed = @('v', 'e', 't', 'sessionId', 'commandIndex', 'command', 'error') disconnected = @('v', 'e', 't', 'sessionId', 'reason') exited = @('v', 'e', 't', 'sessionId', 'code', 'reason') } $failures = [Collections.Generic.List[string]]::new() $eventNames = [Collections.Generic.List[string]]::new() $loadedPlugins = [Collections.Generic.List[string]]::new() $sessionId = $null $previousTimestamp = [DateTimeOffset]::MinValue $terminalSeen = $false function Get-Properties([Text.Json.JsonElement]$Element) { $properties = [Collections.Generic.List[object]]::new() foreach ($property in $Element.EnumerateObject()) { $properties.Add($property) } return @($properties) } function Assert-String( [Text.Json.JsonElement]$Root, [string]$Name, [bool]$AllowEmpty = $false) { $value = $Root.GetProperty($Name) if ($value.ValueKind -ne [Text.Json.JsonValueKind]::String) { throw "field '$Name' is not a string" } $text = $value.GetString() if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($text)) { throw "field '$Name' is empty" } return $text } function Assert-Int32([Text.Json.JsonElement]$Root, [string]$Name) { $value = $Root.GetProperty($Name) if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) { throw "field '$Name' is not a number" } return $value.GetInt32() } function Assert-UInt32([Text.Json.JsonElement]$Root, [string]$Name) { $value = $Root.GetProperty($Name) if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) { throw "field '$Name' is not a number" } return $value.GetUInt32() } $lines = @(Get-Content -LiteralPath $StatusFile) if ($lines.Count -eq 0) { $failures.Add('status stream is empty') } for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { $lineNumber = $lineIndex + 1 $line = $lines[$lineIndex] if ([string]::IsNullOrWhiteSpace($line)) { $failures.Add("line $lineNumber is empty") continue } 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") } $document = $null try { $document = [Text.Json.JsonDocument]::Parse($line) $root = $document.RootElement if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) { throw 'root is not an object' } $properties = @(Get-Properties $root) $names = @($properties | ForEach-Object { $_.Name }) if (@($names | Sort-Object -Unique).Count -ne $names.Count) { throw 'object contains duplicate fields' } $eventName = Assert-String $root 'e' if (-not $exactFields.ContainsKey($eventName)) { throw 'event name is not in the v1 vocabulary' } $expected = $exactFields[$eventName] if ($names.Count -ne $expected.Count -or [string]::Join("`n", $names) -cne [string]::Join("`n", $expected)) { throw "event '$eventName' fields/order are '$($names -join ',')'; expected '$($expected -join ',')'" } if ((Assert-Int32 $root 'v') -ne 1) { throw 'field v is not 1' } $timestampText = Assert-String $root 't' $timestamp = [DateTimeOffset]::MinValue if (-not [DateTimeOffset]::TryParseExact( $timestampText, 'O', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind, [ref]$timestamp) -or $timestamp.Offset -ne [TimeSpan]::Zero) { throw 'field t is not an exact UTC round-trip timestamp' } if ($timestamp -lt $previousTimestamp) { throw 'timestamp order moved backwards' } $previousTimestamp = $timestamp $lineSessionId = Assert-String $root 'sessionId' if ($null -eq $sessionId) { $sessionId = $lineSessionId } if ($lineSessionId -cne $sessionId) { throw 'sessionId changed within the stream' } if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and $lineSessionId -cne $ExpectedSessionId) { throw 'sessionId does not match -ExpectedSessionId' } if ($terminalSeen) { throw 'an event appears after terminal exited' } switch ($eventName) { 'characterList' { $null = Assert-String $root 'accountName' $true $slotCount = Assert-Int32 $root 'slotCount' if ($slotCount -lt 0) { throw 'slotCount is negative' } $characters = $root.GetProperty('characters') if ($characters.ValueKind -ne [Text.Json.JsonValueKind]::Array) { throw 'characters is not an array' } foreach ($character in $characters.EnumerateArray()) { if ($character.ValueKind -ne [Text.Json.JsonValueKind]::Object) { throw 'a character is not an object' } $characterNames = @((Get-Properties $character) | ForEach-Object { $_.Name }) $characterExpected = @('id', 'name', 'secondsGreyedOut') if ([string]::Join("`n", $characterNames) -cne [string]::Join("`n", $characterExpected)) { throw 'a character fields/order is not id,name,secondsGreyedOut' } $null = Assert-UInt32 $character 'id' $null = Assert-String $character 'name' $null = Assert-UInt32 $character 'secondsGreyedOut' } } 'enteredWorld' { $null = Assert-UInt32 $root 'characterId' $null = Assert-String $root 'characterName' } 'pluginLoaded' { $loadedPlugins.Add((Assert-String $root 'plugin')) } 'pluginFailed' { $null = Assert-String $root 'plugin' $null = Assert-String $root 'error' if (-not $AllowPluginFailure) { throw 'pluginFailed is not allowed for this row' } } 'loginCommandFailed' { if ((Assert-Int32 $root 'commandIndex') -lt 0) { throw 'commandIndex is negative' } $null = Assert-String $root 'command' $true $null = Assert-String $root 'error' if (-not $AllowLoginCommandFailure) { throw 'loginCommandFailed is not allowed for this row' } } 'disconnected' { $null = Assert-String $root 'reason' } 'exited' { $code = Assert-Int32 $root 'code' $reason = Assert-String $root 'reason' if ($code -ne 0) { throw "terminal exit code is $code, expected 0" } $expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' } if ($reason -cne $expectedReason) { throw "terminal reason does not match mode '$Mode'" } $terminalSeen = $true } } $eventNames.Add($eventName) } catch { $failures.Add("line ${lineNumber}: $($_.Exception.Message)") } finally { if ($null -ne $document) { $document.Dispose() } } } function Require-Count([string]$EventName, [int]$Count) { $actual = @($eventNames | Where-Object { $_ -ceq $EventName }).Count if ($actual -ne $Count) { $failures.Add("event '$EventName' count is $actual, expected $Count") } } function First-Index([string]$EventName) { for ($index = 0; $index -lt $eventNames.Count; $index++) { if ($eventNames[$index] -ceq $EventName) { return $index } } return -1 } Require-Count 'started' 1 Require-Count 'connected' 1 Require-Count 'characterList' 1 Require-Count 'disconnected' 1 Require-Count 'exited' 1 $expectEnteredWorld = $Mode -ne 'probe' -and -not $ExpectNoEnteredWorld Require-Count 'enteredWorld' $(if ($expectEnteredWorld) { 1 } else { 0 }) if ($eventNames.Count -gt 0 -and $eventNames[0] -cne 'started') { $failures.Add('started is not the first event') } if ($eventNames.Count -gt 0 -and $eventNames[-1] -cne 'exited') { $failures.Add('exited is not the final event') } $orderedRequired = if (-not $expectEnteredWorld) { @('started', 'connected', 'characterList', 'disconnected', 'exited') } else { @('started', 'connected', 'characterList', 'enteredWorld', 'disconnected', 'exited') } $last = -1 foreach ($name in $orderedRequired) { $next = First-Index $name if ($next -ge 0 -and $next -le $last) { $failures.Add("event '$name' is out of lifecycle order") } $last = $next } $connectedIndex = First-Index 'connected' foreach ($index in 0..([Math]::Max(0, $eventNames.Count - 1))) { if ($eventNames.Count -eq 0) { break } if ($eventNames[$index] -in @('pluginLoaded', 'pluginFailed') -and ($index -le 0 -or $index -ge $connectedIndex)) { $failures.Add("plugin event at index $index is outside started-to-connected startup") } } foreach ($plugin in $ExpectedPlugin) { if (-not ($loadedPlugins -ccontains $plugin)) { $failures.Add("expected plugin '$plugin' did not emit pluginLoaded") } } $expectedPluginSet = @($ExpectedPlugin | Sort-Object -Unique) $loadedPluginSet = @($loadedPlugins | Sort-Object -Unique) if ($loadedPlugins.Count -ne $loadedPluginSet.Count) { $failures.Add('a plugin emitted pluginLoaded more than once') } if ([string]::Join("`n", $loadedPluginSet) -cne [string]::Join("`n", $expectedPluginSet)) { $failures.Add( "loaded plugin set has $($loadedPluginSet.Count) member(s), expected $($expectedPluginSet.Count)") } $enteredWorldIndex = First-Index 'enteredWorld' for ($index = 0; $index -lt $eventNames.Count; $index++) { if ($eventNames[$index] -ceq 'loginCommandFailed' -and ($enteredWorldIndex -lt 0 -or $index -le $enteredWorldIndex)) { $failures.Add("loginCommandFailed at index $index did not follow enteredWorld") } } if (-not $AllowLauncherChildren) { $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) do { $children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue) if ($children.Count -eq 0) { break } Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $deadline) if ($children.Count -gt 0) { $failures.Add( "launcher child process leak(s): $((@($children | ForEach-Object { $_.ProcessName + ':' + $_.Id })) -join ',')") } } $reportDirectory = Split-Path -Parent $ReportPath if (-not [string]::IsNullOrEmpty($reportDirectory)) { $null = New-Item -ItemType Directory -Force -Path $reportDirectory } $report = [ordered]@{ schemaVersion = 1 kind = 'campaign-la-session-status-validation' success = ($failures.Count -eq 0) mode = $Mode enteredWorldExpected = $expectEnteredWorld statusFile = [IO.Path]::GetFileName($StatusFile) statusSize = (Get-Item -LiteralPath $StatusFile).Length statusSha256 = (Get-FileHash -LiteralPath $StatusFile -Algorithm SHA256).Hash.ToLowerInvariant() lineCount = $lines.Count eventNames = @($eventNames) loadedPluginCount = $loadedPlugins.Count terminalObserved = $terminalSeen launcherChildrenAllowed = [bool]$AllowLauncherChildren failures = @($failures) validatedUtc = [DateTime]::UtcNow.ToString('O') } $report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM Write-Host "Campaign LA status validation report: $ReportPath" if (-not $report.success) { $failures | ForEach-Object { Write-Error $_ } exit 1 }