#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 }