254 lines
11 KiB
PowerShell
254 lines
11 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))
|
||
}
|
||
$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"
|