346 lines
14 KiB
PowerShell
346 lines
14 KiB
PowerShell
<#
|
|
.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 '$eventName' 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 is '$reason', expected '$expectedReason'"
|
|
}
|
|
$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
|
|
}
|