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