187 lines
14 KiB
PowerShell
187 lines
14 KiB
PowerShell
#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 ($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)
|
|
$sha = [Security.Cryptography.SHA256]::Create()
|
|
try { return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() }
|
|
finally { $sha.Dispose(); $stream.Dispose() }
|
|
}
|
|
function Under-Root([string]$Root, [string]$Relative) {
|
|
$base = [IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
|
|
$path = [IO.Path]::GetFullPath((Join-Path $base $Relative))
|
|
$comparison = if ([IO.Path]::DirectorySeparatorChar -eq '/') { [StringComparison]::Ordinal } else { [StringComparison]::OrdinalIgnoreCase }
|
|
if (-not $path.StartsWith($base, $comparison)) { throw "Notice path escapes source root: $Relative" }
|
|
if (-not [IO.File]::Exists($path)) { throw "Required notice/source missing: $path" }
|
|
return $path
|
|
}
|
|
$inputs = [ordered]@{}
|
|
$hashes = @{}
|
|
$failures = New-Object 'Collections.Generic.List[string]'
|
|
$records = New-Object 'Collections.Generic.List[object]'
|
|
$supplements = @{ packages = @(); assets = @(); runtimes = @() }
|
|
if (Test-Path -LiteralPath $NoticeManifest -PathType Leaf) { $supplements = Get-Content -LiteralPath $NoticeManifest -Raw | ConvertFrom-Json }
|
|
$supplementRoot = Split-Path -Parent ([IO.Path]::GetFullPath($NoticeManifest))
|
|
function Add-Notice([string]$Source, [string]$Destination) {
|
|
if ($inputs.Contains($Destination) -and $inputs[$Destination] -ne $Source) { throw "Duplicate notice destination: $Destination" }
|
|
$inputs[$Destination] = $Source
|
|
$hashes[$Destination] = Hash-File $Source
|
|
return [ordered]@{ path = $Destination; sha256 = $hashes[$Destination] }
|
|
}
|
|
function Add-Supplements($Entries, [string]$Prefix) {
|
|
foreach ($entry in $Entries) {
|
|
if ($entry.sha256 -notmatch '^[a-f0-9]{64}$' -or $entry.url -notmatch '^https://') { throw 'Supplement requires SHA-256 and an HTTPS provenance URL.' }
|
|
$source = Under-Root $supplementRoot $entry.file
|
|
if ((Hash-File $source) -ne $entry.sha256) { throw "Supplement changed: $($entry.file)" }
|
|
$record = Add-Notice $source "$Prefix/$([IO.Path]::GetFileName($source))"
|
|
$record['origin'] = $entry.url
|
|
$record
|
|
}
|
|
}
|
|
$lock = Under-Root $Repository 'Cargo.lock'
|
|
$lockHash = Hash-File $lock
|
|
# Shared compiler inputs live outside individual crate directories.
|
|
$workspaceInputs = [ordered]@{}
|
|
foreach ($relative in @('Cargo.toml', 'Cargo.lock', 'LICENSE', 'docs/openapi.json')) {
|
|
$workspaceInputs[$relative] = Hash-File (Under-Root $Repository $relative)
|
|
}
|
|
foreach ($file in @(Get-ChildItem -LiteralPath (Join-Path $Repository 'build') -Recurse -File | Sort-Object FullName)) {
|
|
if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Shared build input reparse point requires review.' }
|
|
$relative = $file.FullName.Substring([IO.Path]::GetFullPath($Repository).TrimEnd('\', '/').Length + 1).Replace('\', '/')
|
|
$workspaceInputs[$relative] = Hash-File $file.FullName
|
|
}
|
|
if (-not $workspaceInputs.Contains('build/windows_manifest.rs')) { throw 'Shared Windows build manifest input is missing.' }
|
|
$locked = @{}
|
|
foreach ($block in ([IO.File]::ReadAllText($lock) -split '(?m)^\[\[package\]\]\s*$')) {
|
|
$fields = @{}
|
|
foreach ($field in [regex]::Matches($block, '(?m)^(name|version|source|checksum) = "([^"]+)"\s*$')) { $fields[$field.Groups[1].Value] = $field.Groups[2].Value }
|
|
if ($fields.ContainsKey('source')) { $locked["$($fields.name)|$($fields.version)|$($fields.source)"] = $fields }
|
|
}
|
|
$raw = @(& cargo metadata --locked --offline --manifest-path (Join-Path $Repository 'Cargo.toml') --filter-platform $Target --format-version 1)
|
|
if ($LASTEXITCODE -ne 0) { throw 'Locked offline Cargo metadata failed; prepare the matching dependency cache first.' }
|
|
$metadata = ($raw -join "`n") | ConvertFrom-Json
|
|
if ((Hash-File $lock) -ne $lockHash) { throw 'Cargo.lock changed during notice preflight.' }
|
|
$packages = @{}; $nodes = @{}
|
|
foreach ($package in $metadata.packages) { $packages[$package.id] = $package }
|
|
foreach ($node in $metadata.resolve.nodes) { $nodes[$node.id] = $node }
|
|
$queue = New-Object 'Collections.Generic.Queue[string]'
|
|
foreach ($name in @($RootPackages | Sort-Object -Unique)) {
|
|
$roots = @($metadata.packages | Where-Object { $_.name -eq $name -and $metadata.workspace_members -contains $_.id })
|
|
if ($roots.Count -ne 1) { throw "Expected one workspace package: $name" }
|
|
$queue.Enqueue($roots[0].id)
|
|
}
|
|
$selected = New-Object 'Collections.Generic.HashSet[string]'
|
|
while ($queue.Count -gt 0) {
|
|
$id = $queue.Dequeue()
|
|
if (-not $selected.Add($id)) { continue }
|
|
if (-not $nodes.ContainsKey($id)) { throw "Unresolved dependency node: $id" }
|
|
foreach ($dep in $nodes[$id].deps) {
|
|
if (@($dep.dep_kinds | Where-Object { $_.kind -ne 'dev' }).Count -gt 0) { $queue.Enqueue($dep.pkg) }
|
|
}
|
|
}
|
|
foreach ($id in @($selected | Sort-Object)) {
|
|
$package = $packages[$id]
|
|
$root = Split-Path -Parent $package.manifest_path
|
|
$key = "$($package.name)-$($package.version)"
|
|
if ($key -notmatch '^[A-Za-z0-9._+-]+$') { throw "Unsafe package identity: $key" }
|
|
$prefix = "licenses/dependencies/$key"
|
|
$registryChecksum = $null
|
|
if ($package.source) {
|
|
if ($package.source -notlike 'registry+*') { $failures.Add("Unsupported source requiring deliberate review: $id"); continue }
|
|
$lockKey = "$($package.name)|$($package.version)|$($package.source)"
|
|
if (-not $locked.ContainsKey($lockKey)) { throw "Source is absent from Cargo.lock: $id" }
|
|
$registryChecksum = $locked[$lockKey].checksum
|
|
if ($registryChecksum -notmatch '^[a-f0-9]{64}$') { throw "Missing registry checksum: $key" }
|
|
$registryRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $root))
|
|
$archivePath = Under-Root $registryRoot "cache/$([IO.Path]::GetFileName((Split-Path -Parent $root)))/$key.crate"
|
|
if ((Hash-File $archivePath) -ne $registryChecksum) { throw "Cached crate archive differs from Cargo.lock: $key" }
|
|
}
|
|
# Hash actual local files (not merely Cargo's cached checksum claims).
|
|
$sourceHashes = [ordered]@{}
|
|
$notices = New-Object 'Collections.Generic.List[object]'
|
|
$fontReferences = New-Object 'Collections.Generic.HashSet[string]'
|
|
foreach ($file in @(Get-ChildItem -LiteralPath $root -Recurse -File | Sort-Object FullName)) {
|
|
if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Source reparse point requires review: $($file.FullName)" }
|
|
$relative = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
|
|
if ($relative -match '^target/' -or $file.Name -eq '.cargo-ok') { continue }
|
|
$sourceHashes[$relative] = Hash-File $file.FullName
|
|
if ($file.Name -match '(^|[-._])(LICENSE|LICENCE|COPYING|NOTICE|OFL|UNLICENSE|COPYRIGHT)([-._]|$)') {
|
|
$notices.Add((Add-Notice $file.FullName "$prefix/$relative"))
|
|
}
|
|
# Literal embedded fonts in production/build source, including feature-gated ones
|
|
# conservatively. Tests/examples/benches are excluded, never all font files by glob.
|
|
if ($file.Extension -eq '.rs' -and $relative -notmatch '^(tests|examples|benches)/') {
|
|
$text = [IO.File]::ReadAllText($file.FullName)
|
|
# Full-line comments, including rustdoc examples, are not compiled
|
|
# font embeds. Keep real code and feature-gated macros conservative.
|
|
$text = [regex]::Replace($text, '(?m)^[\t ]*//[^\r\n]*', '')
|
|
foreach ($match in [regex]::Matches($text, 'include_bytes!\s*\(\s*"([^"]+\.(?:ttf|otf|woff2?))"')) {
|
|
$fontPath = [IO.Path]::GetFullPath((Join-Path $file.DirectoryName $match.Groups[1].Value))
|
|
$fontRoot = [IO.Path]::GetFullPath($root).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
|
|
$fontComparison = if ([IO.Path]::DirectorySeparatorChar -eq '/') { [StringComparison]::Ordinal } else { [StringComparison]::OrdinalIgnoreCase }
|
|
if (-not $fontPath.StartsWith($fontRoot, $fontComparison)) { throw "Embedded font path escapes crate source root: $key/$relative" }
|
|
$font = Under-Root $root $fontPath.Substring($fontRoot.Length)
|
|
[void]$fontReferences.Add($font.Substring($root.Length + 1).Replace('\', '/'))
|
|
}
|
|
}
|
|
}
|
|
if (-not $package.source) { $notices.Add((Add-Notice (Under-Root $Repository 'LICENSE') "$prefix/workspace-LICENSE")) }
|
|
$matching = @($supplements.packages | Where-Object { $_.name -eq $package.name -and $_.version -eq $package.version })
|
|
foreach ($supplement in $matching) {
|
|
if ($supplement.checksum -ne $registryChecksum) { throw "Supplement is for different registry source: $key" }
|
|
foreach ($notice in @(Add-Supplements $supplement.notices "$prefix/supplement")) { $notices.Add($notice) }
|
|
}
|
|
if (-not $package.license -and -not $package.license_file) { $failures.Add("No license declaration: $key") }
|
|
if ($package.license_file) {
|
|
$licensePath = Under-Root $root $package.license_file
|
|
$notices.Add((Add-Notice $licensePath "$prefix/declared-license/$([IO.Path]::GetFileName($licensePath))"))
|
|
}
|
|
if ($notices.Count -eq 0) { $failures.Add("No notice text: $key ($($package.license)); provide checksum-bound supplement") }
|
|
$assets = New-Object 'Collections.Generic.List[object]'
|
|
foreach ($relative in @($fontReferences | Sort-Object)) {
|
|
$assetHash = Hash-File (Under-Root $root $relative)
|
|
$matches = @($supplements.assets | Where-Object { $_.name -eq $package.name -and $_.version -eq $package.version -and $_.path -eq $relative })
|
|
if ($matches.Count -ne 1 -or $matches[0].sha256 -ne $assetHash) { $failures.Add("Embedded font requires exact notice mapping: $key/$relative ($assetHash)"); continue }
|
|
$assetNotices = @(Add-Supplements $matches[0].notices "$prefix/assets/$([IO.Path]::GetFileName($relative))")
|
|
if ($assetNotices.Count -eq 0) { $failures.Add("No embedded font notice: $key/$relative") }
|
|
$assets.Add([ordered]@{ path = $relative; sha256 = $assetHash; license = $matches[0].license; notices = $assetNotices })
|
|
}
|
|
$records.Add([ordered]@{ id = $id; name = $package.name; version = $package.version; license = $package.license; license_file = $package.license_file; repository = $package.repository; source = $package.source; registry_checksum = $registryChecksum; features = $nodes[$id].features; local_source_sha256 = $sourceHashes; notices = @($notices.ToArray()); embedded_fonts = @($assets.ToArray()) })
|
|
}
|
|
$rustVersion = @(& rustc -Vv)
|
|
if ($LASTEXITCODE -ne 0) { throw 'Unable to identify the selected Rust compiler.' }
|
|
$sysroot = @(& rustc --print sysroot)
|
|
if ($LASTEXITCODE -ne 0 -or $sysroot.Count -ne 1) { throw 'Unable to locate the selected Rust distribution.' }
|
|
$targetLib = @(& rustc --print target-libdir --target $Target)
|
|
if ($LASTEXITCODE -ne 0 -or $targetLib.Count -ne 1 -or -not (Test-Path -LiteralPath $targetLib[0] -PathType Container)) { throw "Rust target libraries missing: $Target" }
|
|
$rustNotices = @()
|
|
foreach ($path in @('share/doc/rust/COPYRIGHT.html', 'share/doc/rust/COPYRIGHT-library.html')) {
|
|
$rustNotices += Add-Notice (Under-Root $sysroot[0] $path) "licenses/rust/$([IO.Path]::GetFileName($path))"
|
|
}
|
|
foreach ($path in @('share/doc/COPYING3', 'share/doc/COPYING.RUNTIME')) {
|
|
if (Test-Path -LiteralPath (Join-Path $sysroot[0] $path) -PathType Leaf) { $rustNotices += Add-Notice (Join-Path $sysroot[0] $path) "licenses/rust/$([IO.Path]::GetFileName($path))" }
|
|
}
|
|
$rustLibraries = [ordered]@{}
|
|
foreach ($file in @(Get-ChildItem -LiteralPath $targetLib[0] -File | Sort-Object Name)) { $rustLibraries[$file.Name] = Hash-File $file.FullName }
|
|
$runtime = $null
|
|
if ($LlvmMinGWRoot) {
|
|
$runtimeHash = Hash-File (Under-Root $LlvmMinGWRoot 'x86_64-w64-mingw32/bin/libunwind.dll')
|
|
$matches = @($supplements.runtimes | Where-Object { $_.sha256 -eq $runtimeHash -and $_.name -eq 'libunwind.dll' })
|
|
if ($matches.Count -ne 1) { $failures.Add("libunwind.dll requires exact component notice mapping: $runtimeHash") }
|
|
else {
|
|
$runtimeNotices = @(Add-Supplements $matches[0].notices 'licenses/llvm-libunwind')
|
|
if (-not $runtimeNotices.Count) { $failures.Add('Missing libunwind component notices') }
|
|
$runtime = [ordered]@{ name = 'libunwind.dll'; sha256 = $runtimeHash; provenance = $matches[0].provenance; notices = $runtimeNotices }
|
|
}
|
|
}
|
|
if ($failures.Count -gt 0) { throw ("Notice preflight unresolved:`n - " + ($failures -join "`n - ")) }
|
|
return [pscustomobject]@{ Inputs = $inputs; Hashes = $hashes; Inventory = [ordered]@{ format_version = 1; target = $Target; root_packages = @($RootPackages | Sort-Object -Unique); cargo_lock_sha256 = $lockHash; workspace_build_inputs_sha256 = $workspaceInputs; scope = 'Target-filtered locked offline metadata; transitive normal/build edges from the stated root packages. Workspace feature unification may include a conservative superset. No binary/source equivalence or universal license audit claimed.'; packages = @($records.ToArray()); rust = [ordered]@{ version = $rustVersion; notices = $rustNotices; target_libraries_sha256 = $rustLibraries }; runtime = $runtime } }
|
|
}
|
|
|
|
# Compatibility entry point for the reviewed Windows pipeline and its callers.
|
|
function Get-WindowsNotices([string]$Repository, [string]$Target, [string]$NoticeManifest, [string]$LlvmMinGWRoot) {
|
|
Get-ArcadiaNotices $Repository $Target $NoticeManifest $LlvmMinGWRoot
|
|
}
|