Files
ARCADIA-Studio/scripts/package-windows.ps1
T

244 lines
13 KiB
PowerShell

#Requires -Version 5.1
# Does not build or launch applications.
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('x86_64-pc-windows-msvc', 'x86_64-pc-windows-gnullvm')]
[string]$Target,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$')]
[string]$Version,
[Parameter(Mandatory = $true)]
[string]$BinaryDirectory,
[string]$LlvmMinGWRoot,
[string]$BuildEvidence,
[string]$Python = 'python',
[string]$NoticeManifest = (Join-Path $PSScriptRoot 'windows-notices/manifest.json'),
[string]$OutputRoot = (Join-Path $PSScriptRoot '../dist')
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$utf8 = New-Object Text.UTF8Encoding($false)
. (Join-Path $PSScriptRoot 'package-common.ps1')
function Require-File([string]$Path) {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Required file missing: $Path" }
return (Get-Item -LiteralPath $Path).FullName
}
# Read ordinary PE32+ imports without invoking a compiler or trusting PATH tools.
# Dynamically loaded libraries and delay imports remain a separate runtime smoke gate.
function Read-PeImports([string]$Path) {
$stream = [IO.File]::OpenRead($Path)
$reader = New-Object IO.BinaryReader($stream)
try {
if ($reader.ReadUInt16() -ne 0x5a4d) { throw "Not a PE executable: $Path" }
$stream.Position = 0x3c
$pe = $reader.ReadUInt32()
$stream.Position = $pe
if ($reader.ReadUInt32() -ne 0x4550) { throw "Invalid PE signature: $Path" }
if ($reader.ReadUInt16() -ne 0x8664) { throw "Expected x86_64 PE input: $Path" }
$sectionCount = $reader.ReadUInt16()
$stream.Position = $pe + 20
$optionalSize = $reader.ReadUInt16()
$optional = $pe + 24
$stream.Position = $optional
if ($reader.ReadUInt16() -ne 0x20b) { throw "Expected PE32+ input: $Path" }
$stream.Position = $optional + 120
$importRva = $reader.ReadUInt32()
$sections = @()
for ($index = 0; $index -lt $sectionCount; $index++) {
$stream.Position = $optional + $optionalSize + $index * 40 + 8
$virtualSize = $reader.ReadUInt32()
$virtualAddress = $reader.ReadUInt32()
$rawSize = $reader.ReadUInt32()
$rawAddress = $reader.ReadUInt32()
$sections += [pscustomobject]@{ Start = $virtualAddress; Size = [Math]::Max($virtualSize, $rawSize); Raw = $rawAddress }
}
function Rva-Offset([uint32]$Rva) {
foreach ($section in $sections) {
if ($Rva -ge $section.Start -and $Rva -lt ($section.Start + $section.Size)) {
return [long]($section.Raw + $Rva - $section.Start)
}
}
throw "Unmapped PE RVA in $Path"
}
if ($importRva -eq 0) { return }
$descriptors = Rva-Offset $importRva
for ($index = 0; $index -lt 4096; $index++) {
$stream.Position = $descriptors + $index * 20
$fields = @(0..4 | ForEach-Object { $reader.ReadUInt32() })
if (@($fields | Where-Object { $_ -ne 0 }).Count -eq 0) { return }
$stream.Position = Rva-Offset $fields[3]
$name = New-Object Text.StringBuilder
for ($character = 0; $character -lt 256; $character++) {
$value = $reader.ReadByte()
if ($value -eq 0) { break }
[void]$name.Append([char]$value)
}
if ($character -eq 256) { throw "Unterminated PE import name: $Path" }
$name.ToString().ToLowerInvariant()
}
throw "Unterminated PE import table: $Path"
} finally {
$reader.Dispose()
}
}
# Windows system components only. Unknown imports fail, including VC redistributable DLLs.
# Add a redistributable only with explicit provenance, license, and recursive import checks.
$systemDlls = @(
'kernel32.dll', 'kernelbase.dll', 'user32.dll', 'ole32.dll', 'oleaut32.dll',
'bcrypt.dll', 'bcryptprimitives.dll', 'advapi32.dll', 'dwmapi.dll', 'gdi32.dll',
'shell32.dll', 'imm32.dll', 'uxtheme.dll', 'ws2_32.dll', 'ntdll.dll', 'opengl32.dll',
'ucrtbase.dll', 'msvcrt.dll', 'crypt32.dll', 'secur32.dll', 'shlwapi.dll',
'comdlg32.dll', 'comctl32.dll', 'version.dll', 'winmm.dll', 'winhttp.dll',
'iphlpapi.dll', 'userenv.dll', 'dbghelp.dll', 'dwrite.dll', 'd2d1.dll',
'd3d11.dll', 'd3d12.dll', 'dxgi.dll', 'd3dcompiler_47.dll', 'propsys.dll',
'powrprof.dll', 'setupapi.dll', 'cfgmgr32.dll', 'winspool.drv', 'wtsapi32.dll'
)
$inputs = [ordered]@{}
foreach ($name in @('arcadia-studio.exe', 'arcadia-server.exe')) {
$inputs[$name] = Require-File (Join-Path $BinaryDirectory $name)
}
if ($Target -eq 'x86_64-pc-windows-gnullvm') {
if (-not $LlvmMinGWRoot) { throw 'gnullvm requires -LlvmMinGWRoot for its exact runtime and license.' }
$inputs['libunwind.dll'] = Require-File (Join-Path $LlvmMinGWRoot 'x86_64-w64-mingw32/bin/libunwind.dll')
$inputs['licenses/llvm-mingw-LICENSE.TXT'] = Require-File (Join-Path $LlvmMinGWRoot 'LICENSE.TXT')
} elseif ($LlvmMinGWRoot) {
throw 'MSVC packaging does not use LLVM-MinGW; omit -LlvmMinGWRoot.'
}
$imports = [ordered]@{}
$inspectedHashes = @{}
foreach ($name in @($inputs.Keys | Where-Object { $_ -match '\.(exe|dll)$' })) {
$inspectedHashes[$name] = (Get-FileHash -LiteralPath $inputs[$name] -Algorithm SHA256).Hash.ToLowerInvariant()
$imports[$name] = @(Read-PeImports $inputs[$name] | Sort-Object -Unique)
foreach ($dependency in $imports[$name]) {
$isSystem = $dependency -match '^(api-ms-win-|ext-ms-win-)[a-z0-9-]+\.dll$' -or $systemDlls -contains $dependency
if (-not $isSystem -and -not $inputs.Contains($dependency)) {
throw "Unpackaged runtime import '$dependency' in '$name'. Resolve its provenance and dependency closure before packaging."
}
}
}
$binaryHashes = [ordered]@{}
foreach ($name in @('arcadia-studio.exe', 'arcadia-server.exe')) { $binaryHashes[$name] = $inspectedHashes[$name] }
$evidence = Read-PackageBuildEvidence $BuildEvidence $Target $binaryHashes
$documentation = Get-PackageDocumentation $repository 'windows' $Python
foreach ($name in $documentation.Keys) { $inputs[$name] = $documentation[$name] }
# Resolve notice/source inputs before creating an output claim. No Cargo build occurs.
. (Join-Path $PSScriptRoot 'windows-notices.ps1')
$notices = Get-WindowsNotices $repository $Target $NoticeManifest $LlvmMinGWRoot
foreach ($name in $notices.Inputs.Keys) {
if ($inputs.Contains($name)) { throw "Notice collides with package input: $name" }
$inputs[$name] = $notices.Inputs[$name]
$inspectedHashes[$name] = $notices.Hashes[$name]
}
$output = [IO.Path]::GetFullPath($OutputRoot)
$packageName = "ArcadiaStudio-$Version-$Target"
$stage = Join-Path $output $packageName
$archive = "$stage.zip"
$checksum = "$archive.sha256"
$lockPath = "$stage.packaging.lock"
foreach ($path in @($stage, $archive, $checksum, $lockPath)) {
if (Test-Path -LiteralPath $path) { throw "Output already exists; choose a fresh version or output root: $path" }
}
foreach ($directory in @('docs', 'examples', 'deployment')) {
$sourceRoot = [IO.Path]::GetFullPath((Join-Path $repository $directory)) + [IO.Path]::DirectorySeparatorChar
if (($stage + [IO.Path]::DirectorySeparatorChar).StartsWith($sourceRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Output must not be inside a packaged source directory.'
}
}
[void][IO.Directory]::CreateDirectory($output)
$lock = [IO.File]::Open($lockPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
try {
[void][IO.Directory]::CreateDirectory($stage)
$inputHashes = [ordered]@{}
foreach ($name in $inputs.Keys) {
Assert-PackageOrdinaryFile $inputs[$name]
$destination = Join-Path $stage $name
[void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination))
$before = (Get-FileHash -LiteralPath $inputs[$name] -Algorithm SHA256).Hash.ToLowerInvariant()
if ($inspectedHashes.ContainsKey($name) -and $inspectedHashes[$name] -ne $before) {
throw "Input changed after dependency/notice inspection: $name"
}
[IO.File]::Copy($inputs[$name], $destination, $false)
if ((Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash.ToLowerInvariant() -ne $before) {
throw "Input changed during staging: $name"
}
$inputHashes[$name] = $before
}
$metadata = [ordered]@{
version = $Version; target = $Target
acceptance = 'Packaging integrity only; native clean-environment verification required.'
build = 'Prebuilt inputs supplied by caller; this script does not establish build profile or source revision.'
build_evidence = $evidence; imports = $imports; input_sha256 = $inputHashes
}
[IO.File]::WriteAllText((Join-Path $stage 'package.json'), ($metadata | ConvertTo-Json -Depth 14) + "`n", $utf8)
Write-PackageNotices $stage $notices
$start = @'
Arcadia Studio portable Windows bundle
Open arcadia-studio.exe. See docs/windows-packaging.md for runtime prerequisites
and verification, README.md for model workflows, and docs/server.md to run the
optional arcadia-server.exe. Examples are under examples/; Help is built in.
Keep the bundled runtime DLL beside both executables when present. No compiler
installation or machine PATH change is required by the staged runtime closure.
This binary bundle does not include repository sources needed for Cargo/Docker
build commands. Packaging integrity is not native release acceptance; read
package.json and the distributor's binary-specific acceptance record for limitations.
'@
[IO.File]::WriteAllText((Join-Path $stage 'START-HERE.txt'), $start + "`n", $utf8)
$documentCheck = Invoke-PackageHelper $Python @('check-docs', '--stage', $stage)
[IO.File]::WriteAllText((Join-Path $stage 'documentation-check.json'), ($documentCheck | ConvertTo-Json) + "`n", $utf8)
$manifest = New-Object 'Collections.Generic.List[string]'
foreach ($file in Get-ChildItem -LiteralPath $stage -Recurse -File | Sort-Object FullName) {
$relative = $file.FullName.Substring($stage.Length + 1).Replace('\', '/')
$manifest.Add((Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + ' ' + $relative)
}
[IO.File]::WriteAllText((Join-Path $stage 'SHA256SUMS.txt'), ($manifest -join "`n") + "`n", $utf8)
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zipStream = [IO.File]::Open($archive, [IO.FileMode]::CreateNew)
$zip = New-Object IO.Compression.ZipArchive($zipStream, [IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($file in Get-ChildItem -LiteralPath $stage -Recurse -File | Sort-Object FullName) {
$relative = $file.FullName.Substring($stage.Length + 1).Replace('\', '/')
$entry = $zip.CreateEntry("$packageName/$relative", [IO.Compression.CompressionLevel]::Optimal)
$entry.LastWriteTime = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
$destination = $entry.Open()
$source = $file.OpenRead()
try { $source.CopyTo($destination) } finally { $source.Dispose(); $destination.Dispose() }
}
} finally { $zip.Dispose(); $zipStream.Dispose() }
$zip = [IO.Compression.ZipFile]::OpenRead($archive)
try {
$seen = New-Object 'Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
foreach ($entry in $zip.Entries) {
if (-not $seen.Add($entry.FullName)) { throw "Duplicate archive entry: $($entry.FullName)" }
$relative = $entry.FullName.Substring($packageName.Length + 1)
$source = $entry.Open()
$sha = [Security.Cryptography.SHA256]::Create()
try { $actual = [BitConverter]::ToString($sha.ComputeHash($source)).Replace('-', '').ToLowerInvariant() }
finally { $sha.Dispose(); $source.Dispose() }
$expected = (Get-FileHash -LiteralPath (Join-Path $stage $relative) -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $expected) { throw "Archive mismatch: $relative" }
}
if ($seen.Count -ne @(Get-ChildItem -LiteralPath $stage -Recurse -File).Count) { throw 'Archive file count mismatch.' }
} finally { $zip.Dispose() }
$archiveHash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
[IO.File]::WriteAllText($checksum, "$archiveHash $packageName.zip`n", $utf8)
[pscustomobject]@{ Directory = $stage; Archive = $archive; SHA256 = $archiveHash; Files = $seen.Count; NativeAcceptance = 'Pending' }
} finally {
$lock.Dispose()
# Keep the claim file and any partial outputs: retries never overwrite an earlier attempt.
}