378 lines
17 KiB
PowerShell
378 lines
17 KiB
PowerShell
<#
|
||
.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"
|