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

378 lines
17 KiB
PowerShell
Raw Permalink 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))
}
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'
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
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 {
[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 =
'cc58d5717de6686690b7f01213c9d52a99aef49447ff645e134f8c97ec8e3a76'
if ($deterministicDigest -cne $expectedCrossPlatformDigest) {
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]@{
schemaVersion = 1
kind = 'campaign-la-script-safety-tests'
success = $true
negativeCases = $negativeCount
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
Write-Host "Campaign LA script safety tests: $OutputDirectory"