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