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