244 lines
13 KiB
PowerShell
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.
|
|
}
|