Files

251 lines
14 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]$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)
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."
}
}
}
foreach ($name in @('README.md', 'LICENSE')) { $inputs[$name] = Require-File (Join-Path $repository $name) }
$tracked = @(& git -C $repository -c core.quotepath=false ls-files -- docs examples deployment)
if ($LASTEXITCODE -ne 0 -or $tracked.Count -eq 0) { throw 'A Git checkout and tracked docs/examples/deployment inputs are required.' }
foreach ($relative in $tracked) {
$source = Require-File (Join-Path $repository $relative)
if (((Get-Item -LiteralPath $source).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Package input must not be a symlink: $source"
}
$inputs[$relative] = $source
}
# Explicitly require the packaging guide, including before its first commit.
$inputs['docs/windows-packaging.md'] = Require-File (Join-Path $repository 'docs/windows-packaging.md')
foreach ($name in @('release-current-coverage-20260906.md', 'release-save-as-runtime-packaging.md')) {
$inputs["harness/$name"] = Require-File (Join-Path $repository "harness/$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) {
$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.'
imports = $imports; input_sha256 = $inputHashes
}
[IO.File]::WriteAllText((Join-Path $stage 'package.json'), ($metadata | ConvertTo-Json -Depth 6) + "`n", $utf8)
[IO.File]::WriteAllText((Join-Path $stage 'dependency-notices.json'), ($notices.Inventory | ConvertTo-Json -Depth 14) + "`n", $utf8)
$noticeIndex = @('Third-party notice inventory', '', 'Exact package versions, license expressions, source hashes and notice paths:', 'dependency-notices.json', '', 'License texts and embedded-asset notices are retained under licenses/.', 'Rust distribution library/copyright reports are retained under licenses/rust/.', 'Collection is conservative; it does not establish binary/source equivalence or constitute a universal license audit.', '')
foreach ($package in $notices.Inventory.packages) { $noticeIndex += "$($package.name) $($package.version) -- $($package.license) -- licenses/dependencies/$($package.name)-$($package.version)/" }
[IO.File]::WriteAllText((Join-Path $stage 'THIRD-PARTY-NOTICES.txt'), ($noticeIndex -join "`n") + "`n", $utf8)
$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 harness/release-current-coverage-20260906.md for limitations.
'@
[IO.File]::WriteAllText((Join-Path $stage 'START-HERE.txt'), $start + "`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.
}