Package standalone source CLI with verified runtime and notices
This commit is contained in:
@@ -81,22 +81,33 @@ def linked_path(origin, link):
|
||||
return '/'.join(parts)
|
||||
|
||||
|
||||
def document_inputs(repository, platform):
|
||||
def document_inputs(repository, platform, product='studio'):
|
||||
repository = Path(repository).resolve()
|
||||
raw = subprocess.check_output(['git', '-C', str(repository), 'ls-files', '-z'])
|
||||
tracked = set(raw.decode('utf-8').split('\0')) - {''}
|
||||
roots = ['LICENSE', 'docs/server.md', 'docs/api.rst', 'docs/api.html',
|
||||
'docs/language.rst', 'docs/language.html', f'docs/{platform}-packaging.md',
|
||||
'deployment/Dockerfile', 'deployment/Dockerfile.dockerignore',
|
||||
'deployment/arcadia-server.service']
|
||||
roots += sorted(name for name in tracked if name.startswith('examples/'))
|
||||
if product == 'studio':
|
||||
roots = ['LICENSE', 'docs/server.md', 'docs/api.rst', 'docs/api.html',
|
||||
'docs/language.rst', 'docs/language.html', f'docs/{platform}-packaging.md',
|
||||
'deployment/Dockerfile', 'deployment/Dockerfile.dockerignore',
|
||||
'deployment/arcadia-server.service']
|
||||
roots += sorted(name for name in tracked if name.startswith('examples/'))
|
||||
readme = 'scripts/package-readme.md'
|
||||
elif product == 'cli':
|
||||
roots = ['LICENSE', 'docs/source-cli.md', 'docs/language.rst', 'docs/language.html',
|
||||
'examples/text-first/README.md']
|
||||
# The text-first guide links the modular water-pressure walkthrough;
|
||||
# ship its whole model/evidence directory, not just its Markdown links.
|
||||
roots += sorted(name for name in tracked if name.startswith(('examples/text-first/', 'examples/release1/')))
|
||||
readme = 'scripts/package-cli-readme.md'
|
||||
else:
|
||||
raise ValueError(f'Unknown package product: {product}')
|
||||
# Two explicitly authored inputs can be packaged before their first commit.
|
||||
permitted = tracked | set(roots) | {'scripts/package-readme.md', 'docs/linux-packaging.md', 'docs/windows-packaging.md'}
|
||||
permitted = tracked | set(roots) | {readme, 'docs/linux-packaging.md', 'docs/windows-packaging.md'}
|
||||
def repository_file(name):
|
||||
candidate = ordinary(repository / name)
|
||||
candidate.resolve().relative_to(repository)
|
||||
return candidate
|
||||
sources = {'README.md': repository_file('scripts/package-readme.md')}
|
||||
sources = {'README.md': repository_file(readme)}
|
||||
for name in roots:
|
||||
sources[name] = repository_file(name)
|
||||
queue = list(sources)
|
||||
@@ -335,6 +346,7 @@ def main():
|
||||
docs = commands.add_parser('docs')
|
||||
docs.add_argument('--repository', required=True)
|
||||
docs.add_argument('--platform', choices=['windows', 'linux'], required=True)
|
||||
docs.add_argument('--product', choices=['studio', 'cli'], default='studio')
|
||||
check = commands.add_parser('check-docs')
|
||||
check.add_argument('--stage', required=True)
|
||||
elf = commands.add_parser('elf')
|
||||
@@ -349,7 +361,7 @@ def main():
|
||||
notices.add_argument('--target', required=True)
|
||||
args = parser.parse_args()
|
||||
if args.command == 'docs':
|
||||
result = document_inputs(args.repository, args.platform)
|
||||
result = document_inputs(args.repository, args.platform, args.product)
|
||||
elif args.command == 'check-docs':
|
||||
result = check_documents(args.stage)
|
||||
elif args.command == 'elf':
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Arcadia CLI portable Windows bundle
|
||||
|
||||
Extract the whole archive into a writable directory. Open a terminal there:
|
||||
|
||||
```powershell
|
||||
.\arcadia.exe --version
|
||||
.\arcadia.exe --help
|
||||
.\arcadia.exe check examples/text-first/project.arcadia
|
||||
```
|
||||
|
||||
Keep `libunwind.dll` beside `arcadia.exe` when included. The x86-64 gnullvm bundle
|
||||
uses the Windows Universal C Runtime. No compiler installation, desktop editor,
|
||||
network service or machine PATH change is needed to use this executable.
|
||||
|
||||
Read the [CLI command and diagnostic contract](docs/source-cli.md), the
|
||||
[text-first examples](examples/text-first/README.md) and the
|
||||
[language reference](docs/language.html) ([text source](docs/language.rst)).
|
||||
Copy a complete example folder into your own work directory before editing it.
|
||||
Supply the project entry file explicitly. Editing commands preview by default;
|
||||
`--write` saves the candidate prepared by that invocation. A later invocation
|
||||
may choose different provisional UUIDs for records whose IDs are still missing.
|
||||
|
||||
Some example walkthroughs describe Studio controls such as Apply and Save.
|
||||
For this CLI, edit the copied text in your editor, run `arcadia check` on its
|
||||
entry, review `arcadia stabilize project.arcadia`, then use
|
||||
`arcadia stabilize project.arcadia --write` to save
|
||||
missing identities. The full `examples/release1` model and evidence are included
|
||||
for studying modular source; running its visual walkthrough requires Studio.
|
||||
|
||||
The build instructions in the CLI documentation refer to a separate source
|
||||
checkout; this archive contains the executable and examples. It does not include
|
||||
repository sources needed for Cargo commands. The published milestone's source
|
||||
and exact build evidence are identified in its release record and `package.json`.
|
||||
|
||||
`SHA256SUMS.txt` records the package files. `THIRD-PARTY-NOTICES.txt`,
|
||||
`dependency-notices.json` and `licenses/` retain exact dependency, embedded-asset,
|
||||
Rust and runtime notices. Packaging integrity does not establish runtime
|
||||
acceptance; read the distributor's binary-specific acceptance record.
|
||||
@@ -0,0 +1,75 @@
|
||||
#Requires -Version 5.1
|
||||
# Package an already-built CLI. No builds, downloads, recovery or application launch.
|
||||
[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,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$BuildEvidence,
|
||||
[string]$LlvmMinGWRoot,
|
||||
[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 '..'))
|
||||
$encoding = New-Object Text.UTF8Encoding($false)
|
||||
. (Join-Path $PSScriptRoot 'package-common.ps1')
|
||||
. (Join-Path $PSScriptRoot 'windows-notices.ps1')
|
||||
$windowsInputs = Get-PackageWindowsInputs $BinaryDirectory $Target $LlvmMinGWRoot @('arcadia.exe')
|
||||
$inputs = $windowsInputs.Inputs
|
||||
$inspectedHashes = $windowsInputs.InspectedHashes
|
||||
$evidence = Read-PackageBuildEvidence $BuildEvidence $Target $windowsInputs.BinaryHashes
|
||||
$documentation = Invoke-PackageHelper $Python @('docs', '--repository', $repository, '--platform', 'windows', '--product', 'cli')
|
||||
foreach ($entry in $documentation.files) { $inputs[$entry.path] = $entry.source }
|
||||
$notices = Get-ArcadiaNotices $repository $Target $NoticeManifest $LlvmMinGWRoot @('oa-cli')
|
||||
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 = "ArcadiaCLI-$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', 'crates', 'scripts')) {
|
||||
$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 = Copy-PackageInputs $stage $inputs $inspectedHashes
|
||||
$metadata = [ordered]@{
|
||||
product = 'Arcadia CLI'; version = $Version; target = $Target
|
||||
acceptance = 'Packaging integrity only; clean-environment CLI verification required.'
|
||||
build = 'Prebuilt input; source/compiler/profile assertions are recorded, not inferred.'
|
||||
build_evidence = $evidence; imports = $windowsInputs.Imports; input_sha256 = $inputHashes
|
||||
}
|
||||
[IO.File]::WriteAllText((Join-Path $stage 'package.json'), ($metadata | ConvertTo-Json -Depth 14) + "`n", $encoding)
|
||||
Write-PackageNotices $stage $notices
|
||||
[IO.File]::WriteAllText((Join-Path $stage 'START-HERE.txt'), "Arcadia CLI portable Windows bundle`nOpen a terminal in this folder and run .\arcadia.exe --help.`nRead README.md and docs/source-cli.md. Keep any bundled runtime DLL beside arcadia.exe.`nNo compiler, desktop editor, network service or machine PATH change is required.`nCopy an example into your own work directory before editing it. Preview is the default; --write saves.`nPackaging integrity does not establish runtime acceptance; see package.json and release evidence.`n", $encoding)
|
||||
$documentCheck = Invoke-PackageHelper $Python @('check-docs', '--stage', $stage)
|
||||
[IO.File]::WriteAllText((Join-Path $stage 'documentation-check.json'), ($documentCheck | ConvertTo-Json) + "`n", $encoding)
|
||||
Write-PackageChecksums $stage
|
||||
$result = Write-PackageWindowsArchive $stage $archive $packageName
|
||||
[IO.File]::WriteAllText($checksum, "$($result.SHA256) $packageName.zip`n", $encoding)
|
||||
[pscustomobject]@{ Directory = $stage; Archive = $archive; SHA256 = $result.SHA256; Files = $result.Files; NativeAcceptance = 'Pending' }
|
||||
} finally {
|
||||
$lock.Dispose()
|
||||
# Keep claims and partial outputs. A retry must never overwrite an earlier attempt.
|
||||
}
|
||||
@@ -74,3 +74,147 @@ function Write-PackageChecksums([string]$Stage) {
|
||||
}
|
||||
[IO.File]::WriteAllText((Join-Path $Stage 'SHA256SUMS.txt'), ($lines -join "`n") + "`n", $encoding)
|
||||
}
|
||||
|
||||
function Require-PackageFile([string]$Path) {
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Required file missing: $Path" }
|
||||
Assert-PackageOrdinaryFile $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-PackagePeImports([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()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PackageWindowsInputs([string]$BinaryDirectory, [string]$Target, [string]$LlvmMinGWRoot, [string[]]$BinaryNames) {
|
||||
if ($Target -notin @('x86_64-pc-windows-msvc', 'x86_64-pc-windows-gnullvm')) { throw 'Unsupported Windows target.' }
|
||||
if ($BinaryNames.Count -eq 0 -or @($BinaryNames | Where-Object { $_ -notin @('arcadia.exe', 'arcadia-studio.exe', 'arcadia-server.exe') }).Count -gt 0) { throw 'Unsupported Windows package binary.' }
|
||||
# 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 $BinaryNames) {
|
||||
$inputs[$name] = Require-PackageFile (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-PackageFile (Join-Path $LlvmMinGWRoot 'x86_64-w64-mingw32/bin/libunwind.dll')
|
||||
$inputs['licenses/llvm-mingw-LICENSE.TXT'] = Require-PackageFile (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-PackagePeImports $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 $BinaryNames) { $binaryHashes[$name] = $inspectedHashes[$name] }
|
||||
return [pscustomobject]@{ Inputs = $inputs; Imports = $imports; InspectedHashes = $inspectedHashes; BinaryHashes = $binaryHashes }
|
||||
}
|
||||
|
||||
function Write-PackageWindowsArchive([string]$Stage, [string]$Archive, [string]$PackageName) {
|
||||
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() }
|
||||
return [pscustomobject]@{ SHA256 = (Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash.ToLowerInvariant(); Files = $seen.Count }
|
||||
}
|
||||
|
||||
+5
-103
@@ -23,109 +23,11 @@ $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] }
|
||||
$windowsInputs = Get-PackageWindowsInputs $BinaryDirectory $Target $LlvmMinGWRoot @('arcadia-studio.exe', 'arcadia-server.exe')
|
||||
$inputs = $windowsInputs.Inputs
|
||||
$imports = $windowsInputs.Imports
|
||||
$inspectedHashes = $windowsInputs.InspectedHashes
|
||||
$binaryHashes = $windowsInputs.BinaryHashes
|
||||
$evidence = Read-PackageBuildEvidence $BuildEvidence $Target $binaryHashes
|
||||
$documentation = Get-PackageDocumentation $repository 'windows' $Python
|
||||
foreach ($name in $documentation.Keys) { $inputs[$name] = $documentation[$name] }
|
||||
|
||||
@@ -61,6 +61,44 @@ class PackageContracts(unittest.TestCase):
|
||||
self.assertEqual(archive.getmember('fixture/examples/temperature delta.txt').mode, 0o644)
|
||||
self.assertEqual(len(archive.getmembers()), 3)
|
||||
|
||||
def test_cli_document_closure_uses_own_readme_and_transitive_links(self):
|
||||
names = ['LICENSE', 'docs/source-cli.md', 'docs/language.rst', 'docs/language.html',
|
||||
'examples/text-first/README.md', 'examples/text-first/project.arcadia',
|
||||
'docs/linked.md', 'docs/linked.html', 'examples/release1/oa/module.arcadia']
|
||||
for name in names:
|
||||
self.file(name, b'fixture')
|
||||
self.file('scripts/package-cli-readme.md', b'[CLI](docs/source-cli.md)')
|
||||
self.file('docs/source-cli.md', b'[More](linked.md)')
|
||||
self.file('docs/linked.md', b'[HTML](linked.html)')
|
||||
with patch.object(packaging.subprocess, 'check_output', return_value='\0'.join(names).encode()):
|
||||
result = packaging.document_inputs(self.root, 'windows', 'cli')
|
||||
mapped = {entry['path']: entry['source'] for entry in result['files']}
|
||||
self.assertEqual(Path(mapped['README.md']).name, 'package-cli-readme.md')
|
||||
self.assertIn('docs/linked.html', mapped)
|
||||
self.assertIn('examples/text-first/project.arcadia', mapped)
|
||||
self.assertIn('examples/release1/oa/module.arcadia', mapped)
|
||||
self.assertNotIn('docs/server.md', mapped)
|
||||
self.file('docs/linked.md', b'[Missing](untracked.md)')
|
||||
with patch.object(packaging.subprocess, 'check_output', return_value='\0'.join(names).encode()):
|
||||
with self.assertRaisesRegex(ValueError, 'Untracked/missing'):
|
||||
packaging.document_inputs(self.root, 'windows', 'cli')
|
||||
|
||||
def test_default_document_roots_equal_explicit_studio(self):
|
||||
names = ['LICENSE', 'docs/server.md', 'docs/api.rst', 'docs/api.html',
|
||||
'docs/language.rst', 'docs/language.html', 'docs/windows-packaging.md',
|
||||
'deployment/Dockerfile', 'deployment/Dockerfile.dockerignore',
|
||||
'deployment/arcadia-server.service', 'examples/sample.arcadia']
|
||||
for name in names:
|
||||
self.file(name, b'fixture')
|
||||
self.file('scripts/package-readme.md', b'Studio fixture')
|
||||
with patch.object(packaging.subprocess, 'check_output', return_value='\0'.join(names).encode()):
|
||||
default = packaging.document_inputs(self.root, 'windows')
|
||||
explicit = packaging.document_inputs(self.root, 'windows', 'studio')
|
||||
with self.assertRaisesRegex(ValueError, 'Unknown package product'):
|
||||
packaging.document_inputs(self.root, 'windows', 'unknown')
|
||||
self.assertEqual(default, explicit)
|
||||
self.assertEqual({entry['path'] for entry in default['files']}, set(names) | {'README.md'})
|
||||
|
||||
def test_archive_refuses_overwrite_and_case_collisions(self):
|
||||
self.file('stage/a', b'first')
|
||||
archive = self.file('archive.tar.gz', b'preserve')
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#Requires -Version 5.1
|
||||
# Inert PE fixtures and archive checks. No Cargo, builds or executable launch.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
. (Join-Path $PSScriptRoot 'package-common.ps1')
|
||||
$fixture = Join-Path ([IO.Path]::GetTempPath()) ('arcadia-cli-package-' + [guid]::NewGuid().ToString('N'))
|
||||
[void][IO.Directory]::CreateDirectory($fixture)
|
||||
$encoding = New-Object Text.UTF8Encoding($false)
|
||||
$checks = 0
|
||||
function Assert([bool]$Condition, [string]$Message) { if (-not $Condition) { throw $Message }; $script:checks++ }
|
||||
function Refuses([scriptblock]$Action, [string]$Pattern) {
|
||||
try { & $Action; throw 'Expected refusal' } catch { Assert ($_.ToString() -match $Pattern) "Wrong refusal: $_" }
|
||||
}
|
||||
function Write-Pe([string]$Path, [string]$Import = 'kernel32.dll', [uint16]$Machine = 0x8664) {
|
||||
[void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($Path))
|
||||
$data = New-Object byte[] 1024
|
||||
$stream = [IO.MemoryStream]::new($data, $true)
|
||||
$writer = [IO.BinaryWriter]::new($stream)
|
||||
try {
|
||||
$writer.Write([uint16]0x5a4d)
|
||||
$stream.Position = 0x3c; $writer.Write([uint32]0x80)
|
||||
$stream.Position = 0x80; $writer.Write([uint32]0x4550); $writer.Write($Machine); $writer.Write([uint16]1)
|
||||
$stream.Position = 0x94; $writer.Write([uint16]0xf0)
|
||||
$stream.Position = 0x98; $writer.Write([uint16]0x20b)
|
||||
$stream.Position = 0x110; $writer.Write([uint32]0x1000)
|
||||
$stream.Position = 0x190; $writer.Write([uint32]512); $writer.Write([uint32]0x1000); $writer.Write([uint32]512); $writer.Write([uint32]512)
|
||||
$stream.Position = 524; $writer.Write([uint32]0x1040)
|
||||
$stream.Position = 576; $writer.Write([Text.Encoding]::ASCII.GetBytes($Import)); $writer.Write([byte]0)
|
||||
[IO.File]::WriteAllBytes($Path, $data)
|
||||
} finally { $writer.Dispose() }
|
||||
}
|
||||
$bin = Join-Path $fixture 'bin'
|
||||
$cli = Join-Path $bin 'arcadia.exe'
|
||||
Write-Pe $cli
|
||||
$inspected = Get-PackageWindowsInputs $bin x86_64-pc-windows-msvc '' @('arcadia.exe')
|
||||
Assert (($inspected.Imports['arcadia.exe'] -join ',') -eq 'kernel32.dll') 'System import parse failed'
|
||||
Assert ($inspected.Inputs.Count -eq 1) 'CLI inspection requires unrelated binaries'
|
||||
Refuses { Get-PackageWindowsInputs $bin x86_64-pc-windows-msvc '' @('arcadia-studio.exe', 'arcadia-server.exe') } 'Required file missing'
|
||||
Refuses { Get-PackageWindowsInputs $bin x86_64-pc-windows-gnullvm '' @('arcadia.exe') } 'requires -LlvmMinGWRoot'
|
||||
Refuses { Get-PackageWindowsInputs $bin unsupported '' @('arcadia.exe') } 'Unsupported Windows target'
|
||||
Write-Pe $cli 'unknown-runtime.dll'
|
||||
Refuses { Get-PackageWindowsInputs $bin x86_64-pc-windows-msvc '' @('arcadia.exe') } 'Unpackaged runtime import'
|
||||
Write-Pe $cli 'kernel32.dll' 0x14c
|
||||
Refuses { Get-PackageWindowsInputs $bin x86_64-pc-windows-msvc '' @('arcadia.exe') } 'Expected x86_64'
|
||||
Write-Pe $cli 'libunwind.dll'
|
||||
$runtime = Join-Path $fixture 'runtime'
|
||||
$dll = Join-Path $runtime 'x86_64-w64-mingw32/bin/libunwind.dll'
|
||||
Write-Pe $dll 'unknown-transitive.dll'
|
||||
[IO.File]::WriteAllText((Join-Path $runtime 'LICENSE.TXT'), 'Synthetic license', $encoding)
|
||||
Refuses { Get-PackageWindowsInputs $bin x86_64-pc-windows-gnullvm $runtime @('arcadia.exe') } 'Unpackaged runtime import'
|
||||
Write-Pe $dll
|
||||
$inspected = Get-PackageWindowsInputs $bin x86_64-pc-windows-gnullvm $runtime @('arcadia.exe')
|
||||
Assert ($inspected.Inputs.Count -eq 3) 'Runtime and license closure absent'
|
||||
$evidencePath = Join-Path $fixture 'evidence.json'
|
||||
$evidence = @{source_identity='test'; profile='inert'; compiler='none'; target='x86_64-pc-windows-gnullvm'; binary_sha256=@{'arcadia.exe'='wrong'}}
|
||||
[IO.File]::WriteAllText($evidencePath, ($evidence | ConvertTo-Json), $encoding)
|
||||
Refuses { Read-PackageBuildEvidence $evidencePath x86_64-pc-windows-gnullvm $inspected.BinaryHashes } 'binary hash mismatch'
|
||||
$evidence.binary_sha256['arcadia.exe'] = $inspected.BinaryHashes['arcadia.exe']
|
||||
[IO.File]::WriteAllText($evidencePath, ($evidence | ConvertTo-Json), $encoding)
|
||||
$null = Read-PackageBuildEvidence $evidencePath x86_64-pc-windows-gnullvm $inspected.BinaryHashes
|
||||
Refuses { Read-PackageBuildEvidence $evidencePath x86_64-pc-windows-msvc $inspected.BinaryHashes } 'target differs'
|
||||
$stage = Join-Path $fixture 'stage'
|
||||
[void][IO.Directory]::CreateDirectory($stage)
|
||||
$null = Copy-PackageInputs $stage $inspected.Inputs $inspected.InspectedHashes
|
||||
Write-PackageChecksums $stage
|
||||
$first = Write-PackageWindowsArchive $stage (Join-Path $fixture 'first.zip') 'same-name'
|
||||
$second = Write-PackageWindowsArchive $stage (Join-Path $fixture 'second.zip') 'same-name'
|
||||
Assert ($first.SHA256 -eq $second.SHA256 -and $first.Files -eq 4) 'ZIP is not deterministic/exact'
|
||||
Refuses { Write-PackageWindowsArchive $stage (Join-Path $fixture 'first.zip') 'same-name' } 'already exists'
|
||||
Assert ((Get-FileHash (Join-Path $fixture 'first.zip')).Hash.ToLowerInvariant() -eq $first.SHA256) 'Refusal changed archive'
|
||||
Write-Pe $cli 'kernelbase.dll'
|
||||
Refuses { Copy-PackageInputs (Join-Path $fixture 'changed-stage') $inspected.Inputs $inspected.InspectedHashes } 'Input changed after inspection'
|
||||
Write-Output "PASS: $checks CLI/Windows packaging assertions; inert fixture retained at $fixture"
|
||||
@@ -20,7 +20,7 @@ $null = Write-Fixture 'rust/share/doc/rust/COPYRIGHT.html' 'Synthetic Rust distr
|
||||
$null = Write-Fixture 'rust/share/doc/rust/COPYRIGHT-library.html' 'Synthetic Rust library notice'
|
||||
$null = Write-Fixture 'rust/lib/fake.rlib' 'Synthetic target library'
|
||||
$packages = @(); $nodes = @(); $lock = @()
|
||||
foreach ($name in @('oa-desktop', 'oa-server', 'normal', 'build', 'dev-only', 'unreachable')) {
|
||||
foreach ($name in @('oa-desktop', 'oa-server', 'oa-cli', 'normal', 'build', 'dev-only', 'unreachable')) {
|
||||
$external = $name -notlike 'oa-*'
|
||||
$root = if ($external) { "registry/src/test/$name-1.0.0" } else { "workspace/$name" }
|
||||
$manifest = Write-Fixture "$root/Cargo.toml" "[package]`nname = `"$name`"`nversion = `"1.0.0`""
|
||||
@@ -40,10 +40,13 @@ foreach ($name in @('oa-desktop', 'oa-server', 'normal', 'build', 'dev-only', 'u
|
||||
$deps += [pscustomobject]@{ pkg = $pair[0]; dep_kinds = @([pscustomobject]@{ kind = $pair[1] }) }
|
||||
}
|
||||
}
|
||||
if ($name -eq 'oa-cli') {
|
||||
$deps += [pscustomobject]@{ pkg = 'normal'; dep_kinds = @([pscustomobject]@{ kind = $null }) }
|
||||
}
|
||||
$nodes += [pscustomobject]@{ id = $id; deps = $deps; features = @() }
|
||||
}
|
||||
$null = Write-Fixture 'Cargo.lock' ($lock -join "`n")
|
||||
$script:FixtureMetadata = [pscustomobject]@{ packages = $packages; workspace_members = @('oa-desktop', 'oa-server'); resolve = [pscustomobject]@{ nodes = $nodes } }
|
||||
$script:FixtureMetadata = [pscustomobject]@{ packages = $packages; workspace_members = @('oa-desktop', 'oa-server', 'oa-cli'); resolve = [pscustomobject]@{ nodes = $nodes } }
|
||||
$script:FixtureTarget = 'x86_64-pc-windows-msvc'
|
||||
function cargo {
|
||||
if (($args -join ' ') -notmatch '--locked --offline' -or ($args -join ' ') -notmatch "--filter-platform $script:FixtureTarget") { throw 'Unexpected Cargo command' }
|
||||
@@ -60,6 +63,12 @@ function rustc {
|
||||
$manifestPath = Write-Fixture 'supplements/manifest.json' '{"packages":[],"assets":[],"runtimes":[]}'
|
||||
function Collect { Get-WindowsNotices $fixture x86_64-pc-windows-msvc $manifestPath '' }
|
||||
function Assert([bool]$Condition, [string]$Message) { if (-not $Condition) { throw $Message } }
|
||||
$defaultRoots = (Collect).Inventory.root_packages -join ','
|
||||
Assert ($defaultRoots -eq 'oa-desktop,oa-server') 'Desktop/server default roots changed'
|
||||
$cliOnly = Get-ArcadiaNotices $fixture x86_64-pc-windows-msvc $manifestPath '' @('oa-cli')
|
||||
Assert (($cliOnly.Inventory.root_packages -join ',') -eq 'oa-cli') 'CLI root declaration missing'
|
||||
Assert ((@($cliOnly.Inventory.packages.name | Sort-Object) -join ',') -eq 'normal,oa-cli') 'CLI notice graph leaked desktop/server/dev dependencies'
|
||||
try { $null = Get-ArcadiaNotices $fixture x86_64-pc-windows-msvc $manifestPath '' @('unknown'); throw 'Expected root refusal' } catch { Assert ($_.ToString() -match 'Notice roots must') 'Unknown root was not rejected before metadata' }
|
||||
function Refuses([string]$Pattern) {
|
||||
try { $null = Collect; throw 'Expected preflight refusal' } catch {
|
||||
if ($_.Exception.Message -notmatch $Pattern) { throw }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#Requires -Version 5.1
|
||||
# Read-only dependency preflight. Does not build, download, or change a Cargo target.
|
||||
function Get-ArcadiaNotices([string]$Repository, [string]$Target, [string]$NoticeManifest, [string]$LlvmMinGWRoot, [string[]]$RootPackages = @('oa-desktop', 'oa-server')) {
|
||||
if ($RootPackages.Count -eq 0 -or @($RootPackages | Where-Object { $_ -notin @('oa-desktop', 'oa-server') }).Count -gt 0) { throw 'Notice roots must be oa-desktop and/or oa-server.' }
|
||||
if ($RootPackages.Count -eq 0 -or @($RootPackages | Where-Object { $_ -notin @('oa-desktop', 'oa-server', 'oa-cli') }).Count -gt 0) { throw 'Notice roots must be oa-desktop, oa-server and/or oa-cli.' }
|
||||
if ($LlvmMinGWRoot -and $Target -ne 'x86_64-pc-windows-gnullvm') { throw 'LLVM-MinGW runtime notices apply only to the gnullvm target.' }
|
||||
function Hash-File([string]$Path) {
|
||||
$stream = [IO.File]::OpenRead($Path)
|
||||
|
||||
Reference in New Issue
Block a user