acdream/tools/test-campaign-la-script-safety.ps1

254 lines
11 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<#
.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"