<# .SYNOPSIS Creates deterministic isolated Campaign LA A/B update feeds. .DESCRIPTION Packages caller-supplied published client and launcher roots for win-x64 and linux-x64, adds a deterministic release marker, calculates the exact LA10 SHA-256/size manifest fields, and emits a loopback-only static server plus a local A/B selector. It never downloads, connects, edits a payload source, or writes outside -OutputDirectory. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$OutputDirectory, [Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryA, [Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryA, [Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryA, [Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryA, [Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryB, [Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryB, [Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryB, [Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryB, [string]$VersionA = '1.0.1-la11.a', [string]$VersionB = '1.0.1-la11.b', [string]$MinimumLauncherVersion = '1.0.0', [int]$Port = 43119, [switch]$DryRun ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($PSVersionTable.PSVersion.Major -lt 7) { throw 'Campaign LA update fixture creation requires PowerShell 7 or newer.' } if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { throw '-OutputDirectory must be absolute.' } $OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( [IO.Path]::GetFullPath($OutputDirectory)) function Assert-NoReparseAncestry([string]$Path, [string]$Description) { $cursor = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path)) while (-not (Test-Path -LiteralPath $cursor)) { $parent = [IO.Path]::GetDirectoryName($cursor) if ([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor) { break } $cursor = $parent } while (-not [string]::IsNullOrEmpty($cursor)) { $item = Get-Item -LiteralPath $cursor -Force if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "$Description has a reparse point in its ancestry." } $parent = [IO.Directory]::GetParent($cursor) if ($null -eq $parent) { break } $cursor = $parent.FullName } } function Test-SameOrDescendant([string]$Path, [string]$Ancestor) { $comparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } if ([string]::Equals($Path, $Ancestor, $comparison)) { return $true } $prefix = $Ancestor + [IO.Path]::DirectorySeparatorChar return $Path.StartsWith($prefix, $comparison) } Assert-NoReparseAncestry $OutputDirectory 'Output directory' if ($Port -lt 1024 -or $Port -gt 65535) { throw '-Port must be 1024..65535.' } $semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or $MinimumLauncherVersion -notmatch $semver -or $VersionA -ceq $VersionB) { throw 'VersionA, VersionB, and MinimumLauncherVersion must be SemVer 2.0; A and B must differ.' } $parsedVersionA = [semver]$VersionA $parsedVersionB = [semver]$VersionB $parsedMinimumLauncherVersion = [semver]$MinimumLauncherVersion if ($parsedVersionB.CompareTo($parsedVersionA) -le 0) { throw 'VersionB must be newer than VersionA.' } if ($parsedMinimumLauncherVersion.CompareTo($parsedVersionA) -gt 0) { throw 'MinimumLauncherVersion must not be newer than VersionA.' } $sources = [ordered]@{ 'A-client-win-x64' = $ClientWinX64DirectoryA 'A-launcher-win-x64' = $LauncherWinX64DirectoryA 'A-client-linux-x64' = $ClientLinuxX64DirectoryA 'A-launcher-linux-x64' = $LauncherLinuxX64DirectoryA 'B-client-win-x64' = $ClientWinX64DirectoryB 'B-launcher-win-x64' = $LauncherWinX64DirectoryB 'B-client-linux-x64' = $ClientLinuxX64DirectoryB 'B-launcher-linux-x64' = $LauncherLinuxX64DirectoryB } foreach ($key in @($sources.Keys)) { $source = [string]$sources[$key] if (-not [IO.Path]::IsPathFullyQualified($source)) { throw "Payload source '$key' must be absolute." } $source = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($source)) $sources[$key] = $source if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) { throw "Payload source '$key' does not exist: $source" } Assert-NoReparseAncestry $source "Payload source '$key'" if ((Test-SameOrDescendant $OutputDirectory $source) -or (Test-SameOrDescendant $source $OutputDirectory)) { throw "Output directory and payload source '$key' must not overlap." } } function Require-PayloadFile([string]$Key, [string]$Name) { if ($DryRun) { return } if (-not (Test-Path -LiteralPath (Join-Path $sources[$Key] $Name) -PathType Leaf)) { throw "Payload source '$Key' is missing root file '$Name'." } } foreach ($release in @('A', 'B')) { Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe' Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe' Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe' Require-PayloadFile "$release-launcher-win-x64" 'acdream-bake.exe' Require-PayloadFile "$release-client-linux-x64" 'AcDream.App' Require-PayloadFile "$release-client-linux-x64" 'acdream-headless' Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher' Require-PayloadFile "$release-launcher-linux-x64" 'acdream-bake' } if (Test-Path -LiteralPath $OutputDirectory) { if (@(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0) { throw '-OutputDirectory must not already contain files.' } } else { $null = New-Item -ItemType Directory -Path $OutputDirectory } Assert-NoReparseAncestry $OutputDirectory 'Output directory' if ($DryRun) { $plan = [ordered]@{ schemaVersion = 1 kind = 'campaign-la-update-fixture-plan' outputDirectory = $OutputDirectory versions = @($VersionA, $VersionB) minimumLauncherVersion = $MinimumLauncherVersion port = $Port sources = $sources writesOutsideOutputDirectory = $false externalNetwork = $false } $plan | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'dry-run.json') -Encoding utf8NoBOM Write-Host "Campaign LA update fixture dry run: $OutputDirectory" return } Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem $fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero) function Get-LittleEndianUInt16([byte[]]$Bytes, [int]$Offset) { return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8) } function Get-LittleEndianUInt32([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 Set-DeterministicZipHostPlatform([string]$Path) { [byte[]]$bytes = [IO.File]::ReadAllBytes($Path) $minimumEocdSize = 22 if ($bytes.Length -lt $minimumEocdSize) { throw "Generated ZIP is too short: $Path" } $eocd = -1 $minimumOffset = [Math]::Max(0, $bytes.Length - 65557) for ($offset = $bytes.Length - $minimumEocdSize; $offset -ge $minimumOffset; $offset--) { if ((Get-LittleEndianUInt32 $bytes $offset) -eq 0x06054b50) { $commentLength = Get-LittleEndianUInt16 $bytes ($offset + 20) if ($offset + $minimumEocdSize + $commentLength -eq $bytes.Length) { $eocd = $offset break } } } if ($eocd -lt 0) { throw "Generated ZIP has no valid end record: $Path" } if ((Get-LittleEndianUInt16 $bytes ($eocd + 4)) -ne 0 -or (Get-LittleEndianUInt16 $bytes ($eocd + 6)) -ne 0) { throw "Generated ZIP unexpectedly spans multiple disks: $Path" } $entriesOnDisk = Get-LittleEndianUInt16 $bytes ($eocd + 8) $entryCount = Get-LittleEndianUInt16 $bytes ($eocd + 10) if ($entriesOnDisk -ne $entryCount) { throw "Generated ZIP central-directory count is inconsistent: $Path" } $centralSize = Get-LittleEndianUInt32 $bytes ($eocd + 12) $centralOffset = Get-LittleEndianUInt32 $bytes ($eocd + 16) if ([uint64]$centralOffset + [uint64]$centralSize -ne [uint64]$eocd) { throw "Generated ZIP central-directory bounds are inconsistent: $Path" } [uint64]$cursor = $centralOffset for ($index = 0; $index -lt $entryCount; $index++) { if ($cursor + 46 -gt $eocd -or (Get-LittleEndianUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) { throw "Generated ZIP central-directory entry is invalid: $Path" } # ZipArchive intentionally stamps the creating host (FAT on Windows, # Unix on Linux) in the upper byte of "version made by". Normalize it # to FAT; permissions are already explicit in ExternalAttributes. $bytes[[int]$cursor + 5] = 0 $nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28) $extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30) $commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32) $cursor += 46 + $nameLength + $extraLength + $commentLength } if ($cursor -ne $eocd) { throw "Generated ZIP central-directory length is inconsistent: $Path" } [IO.File]::WriteAllBytes($Path, $bytes) } function New-DeterministicZip( [string]$SourceDirectory, [string]$Destination, [string]$ReleaseLabel, [string]$PayloadKind, [string]$Rid) { $destinationDirectory = Split-Path -Parent $Destination $null = New-Item -ItemType Directory -Force -Path $destinationDirectory $stream = [IO.FileStream]::new( $Destination, [IO.FileMode]::CreateNew, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) try { $archive = [IO.Compression.ZipArchive]::new( $stream, [IO.Compression.ZipArchiveMode]::Create, $true, [Text.Encoding]::UTF8) try { $allEntries = @(Get-ChildItem -LiteralPath $SourceDirectory -Force -Recurse) foreach ($item in $allEntries) { if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Payload contains a reparse point: $($item.FullName)" } } [string[]]$files = @($allEntries | Where-Object { -not $_.PSIsContainer } | ForEach-Object { [IO.Path]::GetRelativePath( $SourceDirectory, $_.FullName).Replace('\', '/') }) [Array]::Sort($files, [StringComparer]::Ordinal) $caseFolded = [Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) foreach ($relative in $files) { if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or [IO.Path]::IsPathRooted($relative) -or -not $caseFolded.Add($relative)) { throw "Payload path escaped its root: $relative" } $file = Get-Item -LiteralPath ( Join-Path $SourceDirectory $relative.Replace('/', [IO.Path]::DirectorySeparatorChar)) $entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal) $entry.LastWriteTime = $fixedTimestamp $executable = $relative -ceq 'AcDream.App' -or $relative -ceq 'acdream-headless' -or $relative -ceq 'acdream-launcher' -or $relative -ceq 'acdream-bake' -or $relative.EndsWith('.sh', [StringComparison]::Ordinal) $mode = if ($executable) { 0x81ED } else { 0x81A4 } $entry.ExternalAttributes = $mode -shl 16 $input = [IO.File]::OpenRead($file.FullName) $output = $entry.Open() try { $input.CopyTo($output) } finally { $output.Dispose(); $input.Dispose() } } $marker = $archive.CreateEntry( 'campaign-la-fixture-release.txt', [IO.Compression.CompressionLevel]::Optimal) $marker.LastWriteTime = $fixedTimestamp $marker.ExternalAttributes = 0x81A4 -shl 16 $writer = [IO.StreamWriter]::new( $marker.Open(), [Text.UTF8Encoding]::new($false)) try { $writer.NewLine = "`n" $writer.Write("release=$ReleaseLabel`npayload=$PayloadKind`nrid=$Rid`n") } finally { $writer.Dispose() } } finally { $archive.Dispose() } } finally { $stream.Dispose() } Set-DeterministicZipHostPlatform $Destination } function Get-Artifact([string]$Path, [string]$Url) { $item = Get-Item -LiteralPath $Path return [ordered]@{ url = $Url sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() size = $item.Length } } $releaseDefinitions = @( [pscustomobject]@{ Label = 'A'; Version = $VersionA }, [pscustomobject]@{ Label = 'B'; Version = $VersionB } ) foreach ($release in $releaseDefinitions) { $releaseRoot = Join-Path $OutputDirectory $release.Label foreach ($rid in @('win-x64', 'linux-x64')) { New-DeterministicZip ` $sources["$($release.Label)-client-$rid"] ` (Join-Path $releaseRoot "client-$rid.zip") ` $release.Label 'client' $rid New-DeterministicZip ` $sources["$($release.Label)-launcher-$rid"] ` (Join-Path $releaseRoot "launcher-$rid.zip") ` $release.Label 'launcher' $rid } $baseUri = "http://127.0.0.1:$Port/$($release.Label)" $manifest = [ordered]@{ schemaVersion = 1 version = $release.Version minimumLauncherVersion = $MinimumLauncherVersion clients = [ordered]@{ 'win-x64' = Get-Artifact ` (Join-Path $releaseRoot 'client-win-x64.zip') ` "$baseUri/client-win-x64.zip" 'linux-x64' = Get-Artifact ` (Join-Path $releaseRoot 'client-linux-x64.zip') ` "$baseUri/client-linux-x64.zip" } launchers = [ordered]@{ 'win-x64' = Get-Artifact ` (Join-Path $releaseRoot 'launcher-win-x64.zip') ` "$baseUri/launcher-win-x64.zip" 'linux-x64' = Get-Artifact ` (Join-Path $releaseRoot 'launcher-linux-x64.zip') ` "$baseUri/launcher-linux-x64.zip" } } [IO.File]::WriteAllText( (Join-Path $releaseRoot 'manifest.json'), ($manifest | ConvertTo-Json -Depth 8 -Compress), [Text.UTF8Encoding]::new($false)) } [IO.File]::WriteAllText( (Join-Path $OutputDirectory 'active-release.txt'), 'A', [Text.Encoding]::ASCII) $server = @' [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Root, [Parameter(Mandatory = $true)][int]$Port, [ValidateRange(0, 1000000)][int]$MaximumRequests = 0 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $expectedRoot = [IO.Path]::TrimEndingDirectorySeparator( [IO.Path]::GetFullPath($PSScriptRoot)) $Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) $pathComparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) { throw '-Root must be the directory containing serve-fixture.ps1.' } $prefix = "http://127.0.0.1:$Port/" $listener = [Net.HttpListener]::new() $listener.Prefixes.Add($prefix) $listener.Start() Write-Host "Campaign LA fixture listening on $prefix" $servedRequests = 0 try { while ($listener.IsListening) { $context = $listener.GetContext() try { if ($context.Request.HttpMethod -cne 'GET') { $context.Response.StatusCode = 405 continue } $relative = [Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/')) if ($relative -ceq 'manifest.json') { $active = (Get-Content -LiteralPath (Join-Path $Root 'active-release.txt') -Raw).Trim() if ($active -notin @('A', 'B')) { throw 'active-release.txt must contain A or B.' } $relative = "$active/manifest.json" } if ([string]::IsNullOrWhiteSpace($relative) -or $relative.Contains('..')) { $context.Response.StatusCode = 404 continue } $path = [IO.Path]::GetFullPath((Join-Path $Root $relative)) if (-not $path.StartsWith($Root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::Ordinal) -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { $context.Response.StatusCode = 404 continue } $context.Response.ContentType = if ($path.EndsWith('.json', [StringComparison]::Ordinal)) { 'application/json' } else { 'application/zip' } $context.Response.StatusCode = 200 $context.Response.Headers['Cache-Control'] = 'no-store' $context.Response.ContentLength64 = (Get-Item -LiteralPath $path).Length $input = [IO.File]::OpenRead($path) try { $input.CopyTo($context.Response.OutputStream) } finally { $input.Dispose() } } catch { $context.Response.StatusCode = 500 Write-Error $_ } finally { $context.Response.Close() $servedRequests++ } if ($MaximumRequests -gt 0 -and $servedRequests -ge $MaximumRequests) { break } } } finally { $listener.Close() } '@ [IO.File]::WriteAllText( (Join-Path $OutputDirectory 'serve-fixture.ps1'), $server.Replace("`r`n", "`n"), [Text.UTF8Encoding]::new($false)) $selector = @' [CmdletBinding()] param( [Parameter(Mandatory = $true)][ValidateSet('A', 'B')][string]$Release, [string]$Root = $PSScriptRoot ) Set-StrictMode -Version Latest $expectedRoot = [IO.Path]::TrimEndingDirectorySeparator( [IO.Path]::GetFullPath($PSScriptRoot)) $Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) $pathComparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) { throw '-Root must be the directory containing set-active-release.ps1.' } $path = Join-Path $Root 'active-release.txt' $temporary = "$path.$([Guid]::NewGuid().ToString('N')).tmp" try { [IO.File]::WriteAllText($temporary, $Release, [Text.Encoding]::ASCII) [IO.File]::Move($temporary, $path, $true) } finally { if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } } Write-Host "Campaign LA fixture active release: $Release" '@ [IO.File]::WriteAllText( (Join-Path $OutputDirectory 'set-active-release.ps1'), $selector.Replace("`r`n", "`n"), [Text.UTF8Encoding]::new($false)) $inventoryPaths = [string[]]@(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | Where-Object { $_.Name -ne 'fixture-report.json' } | ForEach-Object { [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') }) [Array]::Sort($inventoryPaths, [StringComparer]::Ordinal) $inventory = @($inventoryPaths | ForEach-Object { $fullPath = Join-Path $OutputDirectory $_.Replace('/', [IO.Path]::DirectorySeparatorChar) $item = Get-Item -LiteralPath $fullPath [ordered]@{ path = $_ size = $item.Length sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant() } }) $report = [ordered]@{ schemaVersion = 1 kind = 'campaign-la-update-fixture' versions = [ordered]@{ A = $VersionA; B = $VersionB } minimumLauncherVersion = $MinimumLauncherVersion manifestUri = "http://127.0.0.1:$Port/manifest.json" loopbackOnly = $true initialRelease = 'A' sourceDirectories = $sources artifacts = $inventory } $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'fixture-report.json') -Encoding utf8NoBOM Write-Host "Campaign LA update fixture: $OutputDirectory"