[CmdletBinding()]
|
param(
|
[Parameter(Mandatory = $true)]
|
[string]$ApprovedSourceReceipt,
|
[Parameter(Mandatory = $true)]
|
[long]$ApprovedSourceReceiptBytes,
|
[Parameter(Mandatory = $true)]
|
[ValidatePattern('^[A-F0-9]{64}$')]
|
[string]$ApprovedSourceReceiptSha256,
|
[string]$OutputRoot,
|
[switch]$Build
|
# INTERNAL_TEST_ADAPTER_PARAMETER_ANCHOR
|
)
|
|
Set-StrictMode -Version Latest
|
$ErrorActionPreference = 'Stop'
|
$script:OwnedOutput = $false
|
$script:OutputRootResolved = $null
|
$script:InternalAllowTestScope = $false
|
$script:InternalOriginalSourceRoot = $null
|
# INTERNAL_TEST_ADAPTER_CONTEXT_ANCHOR
|
|
function Get-Sha256Bytes([byte[]]$Bytes) {
|
$sha = [Security.Cryptography.SHA256]::Create()
|
try { return (-join ($sha.ComputeHash($Bytes) | ForEach-Object { $_.ToString('X2') })) }
|
finally { $sha.Dispose() }
|
}
|
|
function Get-Sha256File([string]$Path) {
|
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
|
}
|
|
function Read-ManagedJsonString($State) {
|
$start=$State.Index
|
if($State.Text[$State.Index]-cne '"'){throw 'Invalid JSON string.'}
|
$State.Index++
|
while($State.Index-lt$State.Text.Length){
|
$code=[int][char]$State.Text[$State.Index]
|
if($code-lt32){throw 'Unescaped JSON control character.'}
|
if($State.Text[$State.Index]-ceq '"'){$State.Index++;return ($State.Text.Substring($start,$State.Index-$start)|ConvertFrom-Json)}
|
if($State.Text[$State.Index]-ceq '\'){
|
$State.Index++;if($State.Index-ge$State.Text.Length){throw 'Truncated JSON escape.'}
|
if($State.Text[$State.Index]-ceq 'u'){$State.Index+=5}else{$State.Index++}
|
if($State.Index-gt$State.Text.Length){throw 'Truncated JSON escape.'}
|
}else{$State.Index++}
|
}
|
throw 'Unterminated JSON string.'
|
}
|
|
function Skip-ManagedJsonWhitespace($State){while($State.Index-lt$State.Text.Length-and$State.Text[$State.Index]-in @(' ',"`t","`r","`n")){$State.Index++}}
|
|
function Read-ManagedJsonValue($State){
|
Skip-ManagedJsonWhitespace $State;if($State.Index-ge$State.Text.Length){throw 'Truncated JSON value.'}
|
$character=$State.Text[$State.Index]
|
if($character-ceq '{'){
|
$State.Index++;$names=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal);Skip-ManagedJsonWhitespace $State
|
if($State.Index-lt$State.Text.Length-and$State.Text[$State.Index]-ceq '}'){$State.Index++;return}
|
while($true){
|
Skip-ManagedJsonWhitespace $State;$name=Read-ManagedJsonString $State
|
if(-not$names.Add([string]$name)){throw 'Duplicate JSON object key.'}
|
Skip-ManagedJsonWhitespace $State;if($State.Index-ge$State.Text.Length-or$State.Text[$State.Index]-cne ':'){throw 'JSON object colon missing.'};$State.Index++
|
Read-ManagedJsonValue $State;Skip-ManagedJsonWhitespace $State
|
if($State.Index-ge$State.Text.Length){throw 'Truncated JSON object.'}
|
if($State.Text[$State.Index]-ceq '}'){$State.Index++;return}
|
if($State.Text[$State.Index]-cne ','){throw 'JSON object separator missing.'};$State.Index++
|
}
|
}
|
if($character-ceq '['){
|
$State.Index++;Skip-ManagedJsonWhitespace $State
|
if($State.Index-lt$State.Text.Length-and$State.Text[$State.Index]-ceq ']'){$State.Index++;return}
|
while($true){Read-ManagedJsonValue $State;Skip-ManagedJsonWhitespace $State;if($State.Index-ge$State.Text.Length){throw 'Truncated JSON array.'};if($State.Text[$State.Index]-ceq ']'){$State.Index++;return};if($State.Text[$State.Index]-cne ','){throw 'JSON array separator missing.'};$State.Index++}
|
}
|
if($character-ceq '"'){$null=Read-ManagedJsonString $State;return}
|
$start=$State.Index
|
while($State.Index-lt$State.Text.Length-and$State.Text[$State.Index]-notin @(' ',"`t","`r","`n",',',']','}')){$State.Index++}
|
$token=$State.Text.Substring($start,$State.Index-$start)
|
if($token-notmatch '^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)$'){throw 'Invalid JSON primitive.'}
|
}
|
|
function Assert-ManagedJsonNoDuplicateKeys([string]$Text){$state=[pscustomobject]@{Text=$Text;Index=0};Read-ManagedJsonValue $state;Skip-ManagedJsonWhitespace $state;if($state.Index-ne$Text.Length){throw 'Trailing JSON content.'}}
|
|
function Get-StrictJson([string]$Path) {
|
$bytes = [IO.File]::ReadAllBytes($Path)
|
$utf8 = [Text.UTF8Encoding]::new($false, $true)
|
$text = $utf8.GetString($bytes)
|
Assert-ManagedJsonNoDuplicateKeys $text
|
return $text | ConvertFrom-Json
|
}
|
|
function Assert-ExactKeys($Value, [string[]]$Expected, [string]$Label) {
|
$actual = @($Value.PSObject.Properties.Name)
|
if (Compare-Object -CaseSensitive ($Expected | Sort-Object) ($actual | Sort-Object)) {
|
throw "$Label key set mismatch."
|
}
|
}
|
|
function Assert-Rfc3339([string]$Value, [string]$Label) {
|
$parsed = [DateTimeOffset]::MinValue
|
if (-not [DateTimeOffset]::TryParseExact($Value, 'o', [Globalization.CultureInfo]::InvariantCulture,
|
[Globalization.DateTimeStyles]::None, [ref]$parsed)) { throw "$Label must be canonical RFC3339." }
|
}
|
|
function Test-PathWithin([string]$Candidate, [string]$Root) {
|
$prefix = $Root.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
return $Candidate.Equals($Root, [StringComparison]::OrdinalIgnoreCase) -or
|
$Candidate.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)
|
}
|
|
function Assert-NoReparseChain([string]$Path, [switch]$LeafMayBeAbsent) {
|
if (-not [IO.Path]::IsPathRooted($Path) -or $Path.StartsWith('\\') -or $Path -match '(^|[\\/])\.\.($|[\\/])' -or
|
$Path -match '^[^:]+::' -or $Path.Substring([Math]::Min(2,$Path.Length)) -match ':') { throw 'Unsafe lexical path.' }
|
$full = [IO.Path]::GetFullPath($Path)
|
if (-not [IO.Path]::IsPathRooted($full) -or $full.StartsWith('\\')) { throw 'Only absolute local paths are accepted.' }
|
if ($full -match '(^|\\)\.\.($|\\)' -or $full -match '^[^:]+::' -or $full -match '\\[^\\]+:') { throw 'Unsafe lexical path.' }
|
$probe = $full
|
if ($LeafMayBeAbsent -and -not (Test-Path -LiteralPath $probe)) { $probe = Split-Path -Parent $probe }
|
while ($probe) {
|
if (Test-Path -LiteralPath $probe) {
|
$item = Get-Item -LiteralPath $probe -Force
|
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Reparse paths are not accepted.' }
|
}
|
$parent = Split-Path -Parent $probe
|
if (-not $parent -or $parent -eq $probe) { break }
|
$probe = $parent
|
}
|
return $full
|
}
|
|
function Get-TreeSummary([string]$Root, [string[]]$ExcludedRelativePaths) {
|
$items = @()
|
foreach ($item in @(Get-ChildItem -LiteralPath $Root -Force -Recurse)) {
|
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Source tree contains a reparse path.' }
|
if ($item.PSIsContainer) { continue }
|
$relative = $item.FullName.Substring($Root.Length + 1).Replace('\', '/')
|
if ($relative -cin $ExcludedRelativePaths) { continue }
|
$items += [pscustomobject]@{ path = $relative; bytes = [long]$item.Length; sha256 = (Get-Sha256File $item.FullName) }
|
}
|
$lines = New-Object Text.StringBuilder
|
$paths = [string[]]@($items | ForEach-Object { $_.path }); [Array]::Sort($paths, [StringComparer]::Ordinal)
|
foreach ($path in $paths) {
|
$entry = @($items | Where-Object { $_.path -ceq $path })[0]
|
$null = $lines.Append($entry.path).Append([char]0).Append($entry.bytes).Append([char]0).Append($entry.sha256.ToLowerInvariant()).Append("`n")
|
}
|
$digest = Get-Sha256Bytes ([Text.Encoding]::UTF8.GetBytes($lines.ToString()))
|
return [pscustomobject]@{ file_count = $items.Count; tree_sha256 = $digest; entries = $items }
|
}
|
|
function Get-CanonicalImplementationAuditSection([string]$AuditPath, [string]$AuditId, $SectionContract) {
|
$raw = [IO.File]::ReadAllBytes($AuditPath)
|
if ($raw.Length -ge 3 -and $raw[0] -eq 0xEF -and $raw[1] -eq 0xBB -and $raw[2] -eq 0xBF) { throw 'Implementation audit must not contain a UTF-8 BOM.' }
|
$text = [Text.UTF8Encoding]::new($false, $true).GetString($raw)
|
if ($text.IndexOf([char]0) -ge 0 -or $text.IndexOf("`r", [StringComparison]::Ordinal) -ge 0) { throw 'Implementation audit must be strict LF-only UTF-8.' }
|
$prefix = [string]$SectionContract.heading_prefix
|
$heading = $prefix + $AuditId
|
$lines = [string[]]$text.Split([string[]]@("`n"), [StringSplitOptions]::None)
|
$matches = @()
|
for ($index = 0; $index -lt $lines.Count; $index++) { if ($lines[$index] -ceq $heading) { $matches += $index } }
|
if ($matches.Count -ne 1) { throw 'Implementation audit ID is not unique.' }
|
$start = [int]$matches[0]; $end = $lines.Count
|
for ($index = $start + 1; $index -lt $lines.Count; $index++) {
|
if ($lines[$index].StartsWith($prefix, [StringComparison]::Ordinal)) { $end = $index; break }
|
}
|
while ($end -gt $start -and $lines[$end - 1] -ceq '') { $end-- }
|
$selected = [string[]]$lines[$start..($end - 1)]
|
$canonicalText = [string]::Join("`n", $selected) + "`n"
|
$canonicalBytes = [Text.UTF8Encoding]::new($false).GetBytes($canonicalText)
|
return [pscustomobject]@{ text = $canonicalText; lines = $selected; bytes = [long]$canonicalBytes.Length; sha256 = (Get-Sha256Bytes $canonicalBytes) }
|
}
|
|
function Assert-ImplementationAudit($Binding, $ManagedTree, $Contract, [string]$ProjectRoot) {
|
Assert-ExactKeys $Binding @('audit_id','audit_path','audit_section_format','audit_section_bytes','audit_section_sha256','verdict') 'Source approval review binding'
|
$auditPrefix=if($script:InternalAllowTestScope){'DEV-AUDIT-TEST-MANAGED-LOAD-'}else{[string]$Contract.trust.implementation_audit_id_prefix}
|
$sectionContract = $Contract.trust.implementation_audit_section_contract
|
if ($Binding.verdict -cne 'PASS' -or $Binding.audit_path -cne [string]$Contract.trust.implementation_audit_path -or
|
$Binding.audit_id -notmatch ('^' + [Regex]::Escape($auditPrefix) + '[A-Z0-9-]+$') -or
|
$Binding.audit_section_format -cne [string]$sectionContract.format -or
|
$Binding.audit_section_sha256 -notmatch '^[A-F0-9]{64}$' -or $Binding.audit_section_bytes -lt 1) { throw 'Source approval review identity mismatch.' }
|
$auditPath = Join-Path $ProjectRoot ([string]$Contract.trust.implementation_audit_path)
|
$auditResolved = (Resolve-Path -LiteralPath (Assert-NoReparseChain $auditPath)).Path
|
$auditItem = Get-Item -LiteralPath $auditResolved -Force
|
if ($auditItem.PSIsContainer) { throw 'Implementation audit physical identity mismatch.' }
|
$section = Get-CanonicalImplementationAuditSection $auditResolved ([string]$Binding.audit_id) $sectionContract
|
if ($section.bytes -ne [long]$Binding.audit_section_bytes -or $section.sha256 -cne [string]$Binding.audit_section_sha256) { throw 'Implementation audit section identity mismatch.' }
|
foreach ($token in @($sectionContract.required_exact_lines)) {
|
if (@($section.lines | Where-Object { $_ -ceq [string]$token }).Count -ne 1) { throw 'Implementation audit PASS terminal line missing.' }
|
}
|
if ($section.text.IndexOf([string]$Contract.task_id, [StringComparison]::Ordinal) -lt 0 -or
|
$section.text.IndexOf([string]$ManagedTree.tree_sha256, [StringComparison]::OrdinalIgnoreCase) -lt 0) { throw 'Implementation audit does not bind this source tree.' }
|
}
|
|
function Assert-OriginalSource([string]$Root, $Contract) {
|
$manifestPath = Join-Path $Root ([string]$Contract.original_source.manifest_path)
|
Assert-NoReparseChain $manifestPath | Out-Null
|
$manifestItem = Get-Item -LiteralPath $manifestPath -Force
|
if ($manifestItem.Length -ne [long]$Contract.original_source.manifest_bytes -or
|
(Get-Sha256File $manifestPath) -cne [string]$Contract.original_source.manifest_sha256) {
|
throw 'Original source manifest drift.'
|
}
|
$manifest = Get-StrictJson $manifestPath
|
$listed = @($manifest.files | ForEach-Object { [string]$_.path })
|
$actual = @()
|
foreach ($item in @(Get-ChildItem -LiteralPath $Root -Force -Recurse)) {
|
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Original source contains a reparse path.' }
|
if (-not $item.PSIsContainer) {
|
$relative = $item.FullName.Substring($Root.Length + 1).Replace('\', '/')
|
if ($relative -cne [string]$Contract.original_source.manifest_path) { $actual += $relative }
|
}
|
}
|
if ($actual.Count -ne [int]$Contract.original_source.file_count -or
|
(Compare-Object -CaseSensitive ($listed | Sort-Object) ($actual | Sort-Object))) {
|
throw 'Original source exact file set drift.'
|
}
|
foreach ($entry in $manifest.files) {
|
$path = Join-Path $Root ([string]$entry.path).Replace('/', '\')
|
$item = Get-Item -LiteralPath $path -Force
|
if ($item.Length -ne [long]$entry.bytes -or (Get-Sha256File $path) -cne [string]$entry.sha256) {
|
throw 'Original source file hash drift.'
|
}
|
}
|
return $manifestPath
|
}
|
|
function Read-UInt32BE([byte[]]$Bytes, [int]$Offset) {
|
return ([uint32]$Bytes[$Offset] -shl 24) -bor ([uint32]$Bytes[$Offset + 1] -shl 16) -bor
|
([uint32]$Bytes[$Offset + 2] -shl 8) -bor [uint32]$Bytes[$Offset + 3]
|
}
|
|
function Get-Crc32([byte[]]$Bytes) {
|
[uint32]$crc = [uint32]::MaxValue
|
foreach ($value in $Bytes) {
|
$crc = $crc -bxor [uint32]$value
|
for ($i = 0; $i -lt 8; $i++) {
|
if ($crc -band 1) { $crc = ([uint32]($crc -shr 1)) -bxor [uint32]3988292384 }
|
else { $crc = [uint32]($crc -shr 1) }
|
}
|
}
|
return [uint32]($crc -bxor [uint32]::MaxValue)
|
}
|
|
function Assert-Png([byte[]]$Bytes, [int]$Width, [int]$Height) {
|
$signature = [byte[]](137,80,78,71,13,10,26,10)
|
if ($Bytes.Length -lt 33 -or (Compare-Object $signature $Bytes[0..7])) { throw 'PNG signature mismatch.' }
|
$offset = 8; $seenHeader = $false; $seenEnd = $false; $idat = New-Object Collections.Generic.List[byte]
|
while ($offset -lt $Bytes.Length) {
|
if ($offset + 12 -gt $Bytes.Length) { throw 'Truncated PNG chunk.' }
|
[uint32]$length = Read-UInt32BE $Bytes $offset
|
$typeBytes = $Bytes[($offset + 4)..($offset + 7)]
|
$type = [Text.Encoding]::ASCII.GetString($typeBytes)
|
$dataStart = $offset + 8; $crcOffset = $dataStart + [int]$length
|
if ($crcOffset + 4 -gt $Bytes.Length) { throw 'Truncated PNG data.' }
|
$data = if ($length -eq 0) { [byte[]]@() } else { [byte[]]$Bytes[$dataStart..($crcOffset - 1)] }
|
$crcInput = New-Object byte[] (4 + [int]$length)
|
[Array]::Copy($typeBytes, 0, $crcInput, 0, 4)
|
if ($length) { [Array]::Copy($data, 0, $crcInput, 4, [int]$length) }
|
if ((Get-Crc32 $crcInput) -ne (Read-UInt32BE $Bytes $crcOffset)) { throw 'PNG CRC mismatch.' }
|
if ($type -ceq 'IHDR') {
|
if ($seenHeader -or $length -ne 13 -or (Read-UInt32BE $data 0) -ne $Width -or
|
(Read-UInt32BE $data 4) -ne $Height -or $data[8] -ne 8 -or $data[9] -ne 6 -or
|
$data[10] -ne 0 -or $data[11] -ne 0 -or $data[12] -ne 0) { throw 'PNG IHDR mismatch.' }
|
$seenHeader = $true
|
} elseif ($type -ceq 'IDAT') { foreach ($octet in $data) { $idat.Add([byte]$octet) } }
|
elseif ($type -ceq 'IEND') { if ($length -ne 0) { throw 'PNG IEND mismatch.' }; $seenEnd = $true }
|
$offset = $crcOffset + 4
|
}
|
if (-not $seenHeader -or -not $seenEnd -or $idat.Count -lt 7) { throw 'PNG required chunks missing.' }
|
$compressed = $idat.ToArray()
|
$memory = [IO.MemoryStream]::new($compressed, 2, $compressed.Length - 6, $false)
|
$output = [IO.MemoryStream]::new()
|
try {
|
$deflate = [IO.Compression.DeflateStream]::new($memory, [IO.Compression.CompressionMode]::Decompress)
|
try { $deflate.CopyTo($output) } finally { $deflate.Dispose() }
|
$raw = $output.ToArray()
|
} finally { $output.Dispose(); $memory.Dispose() }
|
if ($raw.Length -ne (($Width * 4 + 1) * $Height)) { throw 'PNG scanline length mismatch.' }
|
for ($row = 0; $row -lt $Height; $row++) { if ($raw[$row * ($Width * 4 + 1)] -ne 0) { throw 'PNG filter mismatch.' } }
|
}
|
|
function Get-ExtensionId([string]$PublicKey) {
|
$der = [Convert]::FromBase64String($PublicKey)
|
$sha = [Security.Cryptography.SHA256]::Create()
|
try { $digest = $sha.ComputeHash($der) } finally { $sha.Dispose() }
|
$alphabet = 'abcdefghijklmnop'; $builder = [Text.StringBuilder]::new()
|
foreach ($value in $digest[0..15]) { $null = $builder.Append($alphabet[$value -shr 4]); $null = $builder.Append($alphabet[$value -band 15]) }
|
return $builder.ToString()
|
}
|
|
function Get-Payload($Contract, [string]$OriginalRoot) {
|
$entries = @()
|
$overlay = [Convert]::FromBase64String([string]$Contract.overlay_manifest.base64)
|
if ($overlay.Length -ne [long]$Contract.overlay_manifest.bytes -or (Get-Sha256Bytes $overlay) -cne [string]$Contract.overlay_manifest.sha256) { throw 'Overlay manifest bytes mismatch.' }
|
$overlayJson = ([Text.UTF8Encoding]::new($false, $true).GetString($overlay)) | ConvertFrom-Json
|
$originalJson = Get-StrictJson (Join-Path $OriginalRoot 'manifest.json')
|
$overlayKeys = @($overlayJson.PSObject.Properties.Name); $originalKeys = @($originalJson.PSObject.Properties.Name)
|
if (Compare-Object -CaseSensitive ($originalKeys + 'icons' | Sort-Object) ($overlayKeys | Sort-Object)) { throw 'Overlay manifest key drift.' }
|
foreach ($name in $originalKeys) {
|
$left = $originalJson.$name | ConvertTo-Json -Compress -Depth 50
|
$right = $overlayJson.$name | ConvertTo-Json -Compress -Depth 50
|
if ($left -cne $right) { throw 'Overlay manifest changed a frozen field.' }
|
}
|
if ((Get-ExtensionId ([string]$overlayJson.key)) -cne [string]$Contract.extension_id) { throw 'Overlay manifest extension ID mismatch.' }
|
$entries += [pscustomobject]@{ path = 'manifest.json'; bytes = [long]$overlay.Length; sha256 = (Get-Sha256Bytes $overlay); content = $overlay }
|
foreach ($runtime in $Contract.runtime_payload) {
|
$path = Join-Path $OriginalRoot ([string]$runtime.path)
|
$content = [IO.File]::ReadAllBytes($path)
|
if ($content.Length -ne [long]$runtime.bytes -or (Get-Sha256Bytes $content) -cne [string]$runtime.sha256) { throw 'Runtime payload drift.' }
|
$entries += [pscustomobject]@{ path = [string]$runtime.path; bytes = [long]$content.Length; sha256 = (Get-Sha256Bytes $content); content = $content }
|
}
|
foreach ($icon in $Contract.icons) {
|
$content = [Convert]::FromBase64String([string]$icon.base64)
|
if ($content.Length -ne [long]$icon.bytes -or (Get-Sha256Bytes $content) -cne [string]$icon.sha256) { throw 'Icon payload drift.' }
|
Assert-Png $content ([int]$icon.width) ([int]$icon.height)
|
$entries += [pscustomobject]@{ path = [string]$icon.path; bytes = [long]$content.Length; sha256 = (Get-Sha256Bytes $content); content = $content }
|
}
|
if ($entries.Count -ne 9 -or @($entries.path | Sort-Object -CaseSensitive | Select-Object -Unique).Count -ne 9) { throw 'Web Store payload must contain exactly nine entries.' }
|
return $entries
|
}
|
|
function Get-PayloadTreeHash($Entries) {
|
$builder = [Text.StringBuilder]::new()
|
$paths = [string[]]@($Entries | ForEach-Object { $_.path }); [Array]::Sort($paths, [StringComparer]::Ordinal)
|
foreach ($path in $paths) {
|
$entry = @($Entries | Where-Object { $_.path -ceq $path })[0]
|
$null = $builder.Append($entry.path).Append([char]0).Append($entry.bytes).Append([char]0).Append($entry.sha256.ToLowerInvariant()).Append("`n")
|
}
|
return Get-Sha256Bytes ([Text.Encoding]::UTF8.GetBytes($builder.ToString()))
|
}
|
|
function Write-JsonCreateNew([string]$Path, $Value) {
|
$bytes = [Text.UTF8Encoding]::new($false).GetBytes(($Value | ConvertTo-Json -Depth 100 -Compress) + "`n")
|
$stream = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() }
|
}
|
|
try {
|
$started = [DateTimeOffset]::Now
|
$sourceRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSCommandPath)).Path
|
$contractPath = Join-Path $sourceRoot 'managed-load-contract.json'
|
Assert-NoReparseChain $contractPath | Out-Null
|
$contract = Get-StrictJson $contractPath
|
if ($contract.schema -ne 1 -or $contract.task_id -cne 'DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001') { throw 'Managed-load contract identity mismatch.' }
|
$projectRoot = (Resolve-Path -LiteralPath (Join-Path $sourceRoot ([string]$contract.trust.project_root_relative_to_managed_source))).Path
|
$originalRoot = if ($script:InternalOriginalSourceRoot) {
|
if (-not $script:InternalAllowTestScope) { throw 'Internal source override is unavailable.' }
|
(Resolve-Path -LiteralPath $script:InternalOriginalSourceRoot).Path
|
} else { (Resolve-Path -LiteralPath (Join-Path $sourceRoot ([string]$contract.original_source.relative_root))).Path }
|
$originalManifest = Assert-OriginalSource $originalRoot $contract
|
$payload = @(Get-Payload $contract $originalRoot)
|
$payloadTree = Get-PayloadTreeHash $payload
|
$managedTree = Get-TreeSummary $sourceRoot @()
|
|
$approvalPath = (Resolve-Path -LiteralPath (Assert-NoReparseChain $ApprovedSourceReceipt)).Path
|
if (Test-PathWithin $approvalPath $sourceRoot) { throw 'Source approval must be outside the managed-load source tree.' }
|
$fixedApproval = [IO.Path]::GetFullPath((Join-Path $projectRoot ([string]$contract.trust.reviewer_source_approval_path)))
|
if ($approvalPath -cne $fixedApproval) { throw 'Source approval is not at the fixed reviewer-owned trust location.' }
|
$approvalItem = Get-Item -LiteralPath $approvalPath -Force
|
if ($approvalItem.Length -ne $ApprovedSourceReceiptBytes -or (Get-Sha256File $approvalPath) -cne $ApprovedSourceReceiptSha256) { throw 'Source approval outer identity mismatch.' }
|
$approval = Get-StrictJson $approvalPath
|
Assert-ExactKeys $approval @($contract.schemas.source_approval_keys) 'Source approval'
|
Assert-ExactKeys $approval.managed_load_contract @('path','bytes','sha256') 'Source approval contract binding'
|
Assert-ExactKeys $approval.original_source_manifest @('path','bytes','sha256') 'Source approval original-source binding'
|
Assert-ExactKeys $approval.managed_load_source_tree @('file_count','tree_sha256') 'Source approval tree binding'
|
Assert-ExactKeys $approval.webstore_payload @('entry_count','payload_tree_sha256','entries') 'Source approval payload binding'
|
$allowedScope = if ($script:InternalAllowTestScope) { @('controlled-webstore-upload-source', 'test-only-controlled-webstore-upload-source') } else { @('controlled-webstore-upload-source') }
|
if ($approval.schema -ne [int]$contract.schemas.source_approval_schema -or $approval.scope -cnotin $allowedScope -or $approval.status -cne 'APPROVED' -or
|
$approval.task_id -cne [string]$contract.task_id -or $approval.approved_by_role -cne 'dev.reviewer.project') { throw 'Source approval identity mismatch.' }
|
if ($approval.managed_load_contract.path -cne [string]$contract.trust.managed_load_contract_path -or
|
$approval.managed_load_contract.bytes -ne (Get-Item -LiteralPath $contractPath).Length -or
|
$approval.managed_load_contract.sha256 -cne (Get-Sha256File $contractPath)) { throw 'Source approval contract mismatch.' }
|
if ($approval.original_source_manifest.path -cne [string]$contract.trust.original_source_manifest_path -or
|
$approval.original_source_manifest.bytes -ne (Get-Item -LiteralPath $originalManifest).Length -or
|
$approval.original_source_manifest.sha256 -cne (Get-Sha256File $originalManifest)) { throw 'Source approval original-source mismatch.' }
|
if ($approval.managed_load_source_tree.file_count -ne $managedTree.file_count -or
|
$approval.managed_load_source_tree.tree_sha256 -cne $managedTree.tree_sha256) { throw 'Source approval tree mismatch.' }
|
if ($approval.webstore_payload.entry_count -ne 9 -or $approval.webstore_payload.payload_tree_sha256 -cne $payloadTree) { throw 'Source approval payload mismatch.' }
|
Assert-ImplementationAudit $approval.implementation_review $managedTree $contract $projectRoot
|
Assert-Rfc3339 ([string]$approval.approved_at) 'Source approval approved_at'
|
|
$approvedEntries = @($approval.webstore_payload.entries)
|
if ($approvedEntries.Count -ne 9) { throw 'Source approval payload entry count mismatch.' }
|
foreach ($entry in $payload) {
|
$match = @($approvedEntries | Where-Object { $_.path -ceq $entry.path })
|
foreach ($approvedEntry in $match) { Assert-ExactKeys $approvedEntry @('path','bytes','sha256') 'Source approval payload entry' }
|
if ($match.Count -ne 1 -or $match[0].bytes -ne $entry.bytes -or $match[0].sha256 -cne $entry.sha256) { throw 'Source approval payload entry mismatch.' }
|
}
|
|
if (-not $Build) {
|
[ordered]@{ schema = 1; status = 'VALIDATION_PASS_ONLY'; extension_id = [string]$contract.extension_id; payload_entries = 9 } | ConvertTo-Json -Compress
|
exit 0
|
}
|
if (-not $OutputRoot) { throw 'OutputRoot is required with -Build.' }
|
$script:OutputRootResolved = Assert-NoReparseChain $OutputRoot -LeafMayBeAbsent
|
if ($script:InternalAllowTestScope -and -not (Test-PathWithin $script:OutputRootResolved ([IO.Path]::GetFullPath([IO.Path]::GetTempPath())))) { throw 'Synthetic builds are restricted to the temporary directory.' }
|
if (Test-Path -LiteralPath $script:OutputRootResolved) { throw 'OutputRoot must be absent.' }
|
[IO.Directory]::CreateDirectory($script:OutputRootResolved) | Out-Null
|
$script:OwnedOutput = $true
|
$zipPath = Join-Path $script:OutputRootResolved 'project-info-bili-auth-ingress-webstore.zip'
|
Add-Type -AssemblyName System.IO.Compression
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
$file = [IO.File]::Open($zipPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
|
try {
|
$archive = [IO.Compression.ZipArchive]::new($file, [IO.Compression.ZipArchiveMode]::Create, $true)
|
try {
|
$zipEntryNames = [string[]]@($payload | ForEach-Object { $_.path }); [Array]::Sort($zipEntryNames, [StringComparer]::Ordinal)
|
foreach ($entryName in $zipEntryNames) {
|
$entry = @($payload | Where-Object { $_.path -ceq $entryName })[0]
|
$zipEntry = $archive.CreateEntry($entry.path, [IO.Compression.CompressionLevel]::NoCompression)
|
$zipEntry.LastWriteTime = [DateTimeOffset]::Parse([string]$contract.zip_timestamp)
|
$zipEntry.ExternalAttributes = 0
|
$stream = $zipEntry.Open()
|
try { $stream.Write($entry.content, 0, $entry.content.Length) } finally { $stream.Dispose() }
|
}
|
} finally { $archive.Dispose() }
|
$file.Flush($true)
|
} finally { $file.Dispose() }
|
|
$zipBytes = (Get-Item -LiteralPath $zipPath).Length
|
$zipSha = Get-Sha256File $zipPath
|
$read = [IO.Compression.ZipFile]::OpenRead($zipPath)
|
try {
|
$names = @($read.Entries | ForEach-Object { $_.FullName })
|
if ($names.Count -ne 9 -or (Compare-Object -CaseSensitive ($payload.path | Sort-Object) ($names | Sort-Object))) { throw 'ZIP entry set mismatch.' }
|
foreach ($expected in $payload) {
|
$actual = @($read.Entries | Where-Object { $_.FullName -ceq $expected.path })
|
if ($actual.Count -ne 1) { throw 'ZIP duplicate or missing entry.' }
|
$stream = $actual[0].Open(); $memory = [IO.MemoryStream]::new()
|
try { $stream.CopyTo($memory); $content = $memory.ToArray() } finally { $memory.Dispose(); $stream.Dispose() }
|
if ($content.Length -ne $expected.bytes -or (Get-Sha256Bytes $content) -cne $expected.sha256) { throw 'ZIP entry content mismatch.' }
|
}
|
} finally { $read.Dispose() }
|
$receiptPath = Join-Path $script:OutputRootResolved 'webstore-upload-build-receipt.json'
|
$receiptEntries = @()
|
foreach ($entryName in $zipEntryNames) {
|
$item = @($payload | Where-Object { $_.path -ceq $entryName })[0]
|
$receiptEntries += [ordered]@{ path = $item.path; bytes = $item.bytes; sha256 = $item.sha256 }
|
}
|
$receipt = [ordered]@{
|
schema = 1; status = 'BUILD_COMPLETE'; task_id = [string]$contract.task_id
|
extension_id = [string]$contract.extension_id; extension_version = [string]$contract.extension_version
|
managed_load_contract = [ordered]@{ path = $contractPath; bytes = (Get-Item $contractPath).Length; sha256 = (Get-Sha256File $contractPath) }
|
source_approval = [ordered]@{ bytes = $approvalItem.Length; sha256 = $ApprovedSourceReceiptSha256 }
|
original_source_manifest = [ordered]@{ path = $originalManifest; bytes = (Get-Item $originalManifest).Length; sha256 = (Get-Sha256File $originalManifest) }
|
zip = [ordered]@{ path = 'project-info-bili-auth-ingress-webstore.zip'; bytes = $zipBytes; sha256 = $zipSha }
|
payload_tree_sha256 = $payloadTree
|
entries = $receiptEntries
|
started_at = $started.ToString('o'); finished_at = [DateTimeOffset]::Now.ToString('o')
|
}
|
Write-JsonCreateNew $receiptPath $receipt
|
if (@(Get-ChildItem -LiteralPath $script:OutputRootResolved -Force).Count -ne 2) { throw 'Output set mismatch.' }
|
[ordered]@{ schema = 1; status = 'BUILD_COMPLETE'; zip_bytes = $zipBytes; zip_sha256 = $zipSha; receipt = $receiptPath } | ConvertTo-Json -Compress
|
exit 0
|
} catch {
|
if ($script:OwnedOutput -and $script:OutputRootResolved -and (Test-Path -LiteralPath $script:OutputRootResolved)) {
|
Remove-Item -LiteralPath $script:OutputRootResolved -Recurse -Force -ErrorAction SilentlyContinue
|
}
|
[Console]::Error.WriteLine('E_BUILD_CONTRACT')
|
[ordered]@{ schema = 1; status = 'SAFETY_STOP'; error_code = 'E_BUILD_CONTRACT' } | ConvertTo-Json -Compress
|
exit 3
|
}
|