[CmdletBinding()]
|
param(
|
[Parameter(Mandatory = $true)][string]$ApprovedSourceReceipt,
|
[Parameter(Mandatory = $true)][long]$ApprovedSourceReceiptBytes,
|
[Parameter(Mandatory = $true)][ValidatePattern('^[A-F0-9]{64}$')][string]$ApprovedSourceReceiptSha256,
|
[Parameter(Mandatory = $true)][string]$ReleaseApproval,
|
[Parameter(Mandatory = $true)][long]$ReleaseApprovalBytes,
|
[Parameter(Mandatory = $true)][ValidatePattern('^[A-F0-9]{64}$')][string]$ReleaseApprovalSha256,
|
[Parameter(Mandatory = $true)][string]$InstallApproval,
|
[Parameter(Mandatory = $true)][long]$InstallApprovalBytes,
|
[Parameter(Mandatory = $true)][ValidatePattern('^[A-F0-9]{64}$')][string]$InstallApprovalSha256,
|
[switch]$Install,
|
[switch]$HealthCheck,
|
[switch]$Rollback
|
# INTERNAL_TEST_ADAPTER_PARAMETER_ANCHOR
|
)
|
|
Set-StrictMode -Version Latest
|
$ErrorActionPreference = 'Stop'
|
$script:Contract = $null
|
$script:Approval = $null
|
$script:FileRegistry = $false
|
$script:InternalAllowTestScope = $false
|
$script:InternalFileRegistryPath = $null
|
$script:InternalInjectFailure = 'none'
|
$script:InternalPauseAt = 'none'
|
$script:InternalPauseMarker = $null
|
$script:InternalFailureEvidenceWriteFailure = $false
|
$script:Mutex = $null
|
$script:MutexAcquired = $false
|
$script:FailureRootValidated = $false
|
# 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 Throw-ManagedCode([string]$Code) {
|
$exception = [InvalidOperationException]::new($Code)
|
$exception.Data['ManagedLoadCode'] = $Code
|
throw $exception
|
}
|
|
function Get-ManagedCode($ErrorRecord, [string]$Fallback) {
|
if ($ErrorRecord -and $ErrorRecord.Exception -and $ErrorRecord.Exception.Data.Contains('ManagedLoadCode')) {
|
return [string]$ErrorRecord.Exception.Data['ManagedLoadCode']
|
}
|
if ($ErrorRecord -and $ErrorRecord.Exception -and $ErrorRecord.Exception.Message -ceq 'E_CANCELLED') { return 'E_CANCELLED' }
|
return $Fallback
|
}
|
|
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)
|
$text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes)
|
Assert-ManagedJsonNoDuplicateKeys $text
|
return $text | ConvertFrom-Json
|
}
|
|
function Assert-ExactKeys($Value, [string[]]$Expected, [string]$Label) {
|
if (Compare-Object -CaseSensitive ($Expected | Sort-Object) @($Value.PSObject.Properties.Name | 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 Get-ExpectedPayloadSummary {
|
$entries = @([pscustomobject]@{path='manifest.json';bytes=[long]$script:Contract.overlay_manifest.bytes;sha256=[string]$script:Contract.overlay_manifest.sha256})
|
foreach ($entry in @($script:Contract.runtime_payload) + @($script:Contract.icons)) {
|
$entries += [pscustomobject]@{path=[string]$entry.path;bytes=[long]$entry.bytes;sha256=[string]$entry.sha256}
|
}
|
$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 [pscustomobject]@{entries=$entries;tree_sha256=(Get-Sha256Bytes ([Text.Encoding]::UTF8.GetBytes($builder.ToString())))}
|
}
|
|
function Assert-OriginalSourceExact {
|
$root=(Resolve-Path -LiteralPath (Join-Path (Split-Path -Parent $PSCommandPath) ([string]$script:Contract.original_source.relative_root))).Path
|
$manifestPath=Join-Path $root ([string]$script:Contract.original_source.manifest_path)
|
$manifestItem=Get-Item -LiteralPath (Assert-NoReparseChain $manifestPath)
|
if($manifestItem.Length-ne[long]$script:Contract.original_source.manifest_bytes-or(Get-Sha256File $manifestItem.FullName)-cne[string]$script:Contract.original_source.manifest_sha256){throw 'Original source manifest drift.'}
|
$manifest=Get-StrictJson $manifestItem.FullName
|
$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]$script:Contract.original_source.manifest_path){$actual+=$relative}}
|
}
|
if($actual.Count-ne[int]$script: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;if($item.Length-ne[long]$entry.bytes-or(Get-Sha256File $path)-cne[string]$entry.sha256){throw 'Original source file drift.'}}
|
return $manifestItem
|
}
|
|
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, [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]$script:Contract.trust.implementation_audit_id_prefix}
|
$sectionContract = $script:Contract.trust.implementation_audit_section_contract
|
if ($Binding.verdict -cne 'PASS' -or $Binding.audit_path -cne [string]$script: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]$script: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]$script: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-SourceApproval($Source, $ManagedTree, [string]$ContractPath) {
|
Assert-ExactKeys $Source @($script:Contract.schemas.source_approval_keys) 'Source approval'
|
Assert-ExactKeys $Source.managed_load_contract @('path','bytes','sha256') 'Source approval contract binding'
|
Assert-ExactKeys $Source.original_source_manifest @('path','bytes','sha256') 'Source approval original-source binding'
|
Assert-ExactKeys $Source.managed_load_source_tree @('file_count','tree_sha256') 'Source approval tree binding'
|
Assert-ExactKeys $Source.webstore_payload @('entry_count','payload_tree_sha256','entries') 'Source approval payload binding'
|
Assert-ExactKeys $Source.implementation_review @('audit_id','audit_path','audit_section_format','audit_section_bytes','audit_section_sha256','verdict') 'Source approval review binding'
|
$sourceScopes=if($script:InternalAllowTestScope){@('controlled-webstore-upload-source','test-only-controlled-webstore-upload-source')}else{@('controlled-webstore-upload-source')}
|
$originalItem = Assert-OriginalSourceExact
|
$payload = Get-ExpectedPayloadSummary
|
if($Source.schema-ne[int]$script:Contract.schemas.source_approval_schema-or$Source.scope-cnotin$sourceScopes-or$Source.status-cne'APPROVED'-or$Source.approved_by_role-cne'dev.reviewer.project'-or
|
$Source.task_id-cne[string]$script:Contract.task_id-or$Source.managed_load_contract.path-cne[string]$script:Contract.trust.managed_load_contract_path-or
|
$Source.managed_load_contract.bytes-ne(Get-Item $ContractPath).Length-or
|
$Source.managed_load_contract.sha256-cne(Get-Sha256File $ContractPath)-or$originalItem.Length-ne[long]$script:Contract.original_source.manifest_bytes-or
|
(Get-Sha256File $originalItem.FullName)-cne[string]$script:Contract.original_source.manifest_sha256-or
|
$Source.original_source_manifest.path-cne[string]$script:Contract.trust.original_source_manifest_path-or$Source.original_source_manifest.bytes-ne$originalItem.Length-or$Source.original_source_manifest.sha256-cne(Get-Sha256File $originalItem.FullName)-or
|
$Source.managed_load_source_tree.file_count-ne$ManagedTree.file_count-or$Source.managed_load_source_tree.tree_sha256-cne$ManagedTree.tree_sha256-or
|
$Source.webstore_payload.entry_count-ne9-or$Source.webstore_payload.payload_tree_sha256-cne$payload.tree_sha256-or
|
$Source.implementation_review.verdict-cne'PASS'){throw 'Source approval binding mismatch.'}
|
$sourceRoot=(Resolve-Path -LiteralPath (Split-Path -Parent $PSCommandPath)).Path
|
$projectRoot=(Resolve-Path -LiteralPath (Join-Path $sourceRoot ([string]$script:Contract.trust.project_root_relative_to_managed_source))).Path
|
Assert-ImplementationAudit $Source.implementation_review $ManagedTree $projectRoot
|
Assert-Rfc3339 ([string]$Source.approved_at) 'Source approval approved_at'
|
$approvedEntries=@($Source.webstore_payload.entries)
|
if($approvedEntries.Count-ne9){throw 'Source approval payload count mismatch.'}
|
foreach($expected in $payload.entries){
|
$match=@($approvedEntries|Where-Object{$_.path-ceq$expected.path})
|
if($match.Count-ne1){throw 'Source approval payload entry mismatch.'}
|
Assert-ExactKeys $match[0] @('path','bytes','sha256') 'Source approval payload entry'
|
if($match[0].bytes-ne$expected.bytes-or$match[0].sha256-cne$expected.sha256){throw 'Source approval payload entry mismatch.'}
|
}
|
}
|
|
function Assert-ReleaseArtifacts($Release, [string]$SourceApprovalSha256) {
|
Assert-ExactKeys $Release.managed_load_contract @('bytes','sha256') 'Release approval contract binding'
|
Assert-ExactKeys $Release.upload_build_receipt @('path','bytes','sha256') 'Release approval build binding'
|
Assert-ExactKeys $Release.release_evidence @('path','bytes','sha256') 'Release approval evidence binding'
|
if($Release.schema-ne1-or$Release.task_id-cne[string]$script:Contract.task_id){throw 'Release approval identity mismatch.'}
|
Assert-Rfc3339 ([string]$Release.approved_at) 'Release approval approved_at'
|
$buildBinding=$Release.upload_build_receipt
|
$buildPath=(Resolve-Path -LiteralPath (Assert-NoReparseChain ([string]$buildBinding.path))).Path
|
$build=Get-StrictJson $buildPath
|
Assert-ExactKeys $build @($script:Contract.schemas.build_receipt_keys) 'Build receipt'
|
foreach($bindingName in @('managed_load_contract','source_approval','original_source_manifest','zip')){Assert-ExactKeys $build.$bindingName @($(if($bindingName -cin @('managed_load_contract','original_source_manifest')){'path','bytes','sha256'}elseif($bindingName -ceq 'zip'){'path','bytes','sha256'}else{'bytes','sha256'})) "Build receipt $bindingName"}
|
foreach($entry in @($build.entries)){Assert-ExactKeys $entry @('path','bytes','sha256') 'Build receipt entry'}
|
$payload=Get-ExpectedPayloadSummary
|
$contractPath=Join-Path (Split-Path -Parent $PSCommandPath) 'managed-load-contract.json'
|
$originalManifest=Join-Path (Resolve-Path -LiteralPath (Join-Path (Split-Path -Parent $PSCommandPath) ([string]$script:Contract.original_source.relative_root))).Path ([string]$script:Contract.original_source.manifest_path)
|
if($build.schema-ne1-or$build.status-cne'BUILD_COMPLETE'-or$build.task_id-cne[string]$script:Contract.task_id-or
|
$build.extension_id-cne[string]$script:Contract.extension_id-or$build.extension_version-cne[string]$script:Contract.extension_version-or
|
$build.managed_load_contract.bytes-ne(Get-Item $contractPath).Length-or$build.managed_load_contract.sha256-cne(Get-Sha256File $contractPath)-or
|
$build.source_approval.bytes-ne$ApprovedSourceReceiptBytes-or$build.source_approval.sha256-cne$SourceApprovalSha256-or
|
$build.original_source_manifest.bytes-ne(Get-Item $originalManifest).Length-or$build.original_source_manifest.sha256-cne(Get-Sha256File $originalManifest)-or
|
$build.payload_tree_sha256-cne$payload.tree_sha256-or@($build.entries).Count-ne9-or$build.zip.path-cne'project-info-bili-auth-ingress-webstore.zip'-or
|
$build.zip.sha256-notmatch'^[A-F0-9]{64}$'-or$build.zip.bytes-lt1){throw 'Build receipt binding mismatch.'}
|
Assert-Rfc3339 ([string]$build.started_at) 'Build receipt started_at';Assert-Rfc3339 ([string]$build.finished_at) 'Build receipt finished_at'
|
foreach($expected in $payload.entries){$match=@($build.entries|Where-Object{$_.path-ceq$expected.path});if($match.Count-ne1-or$match[0].bytes-ne$expected.bytes-or$match[0].sha256-cne$expected.sha256){throw 'Build receipt entry mismatch.'}}
|
$evidenceBinding=$Release.release_evidence
|
$evidencePath=(Resolve-Path -LiteralPath (Assert-NoReparseChain ([string]$evidenceBinding.path))).Path
|
$evidence=Get-StrictJson $evidencePath
|
Assert-ExactKeys $evidence @($script:Contract.schemas.release_evidence_keys) 'Release evidence'
|
$escaped=[Regex]::Escape([string]$script:Contract.extension_id)
|
if($evidence.schema-ne1-or$evidence.item_id-cne[string]$script:Contract.extension_id-or$evidence.version-cne[string]$script:Contract.extension_version-or
|
$evidence.publication_state-cne'PUBLISHED'-or$evidence.store_url-notmatch("^https://chromewebstore\.google\.com/detail/(?:[^/?#]+/)?"+$escaped+"/?$")-or
|
$evidence.upload_zip_sha256-cne[string]$build.zip.sha256){throw 'Release evidence contract mismatch.'}
|
Assert-Rfc3339 ([string]$evidence.published_at) 'Release evidence published_at'
|
}
|
|
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) {
|
$entries = @()
|
foreach ($item in @(Get-ChildItem -LiteralPath $Root -Force -Recurse)) {
|
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Managed-load source contains a reparse path.' }
|
if (-not $item.PSIsContainer) {
|
$relative = $item.FullName.Substring($Root.Length + 1).Replace('\','/')
|
$entries += [pscustomobject]@{ path=$relative; bytes=[long]$item.Length; sha256=(Get-Sha256File $item.FullName) }
|
}
|
}
|
$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 [pscustomobject]@{ file_count=$entries.Count; tree_sha256=(Get-Sha256Bytes ([Text.Encoding]::UTF8.GetBytes($builder.ToString()))) }
|
}
|
|
function Get-ExternalExactJson([string]$Path, [long]$Bytes, [string]$Sha256, [string[]]$ForbiddenRoots) {
|
$resolved = (Resolve-Path -LiteralPath (Assert-NoReparseChain $Path)).Path
|
$item = Get-Item -LiteralPath $resolved -Force
|
if ($item.PSIsContainer -or $item.Length -ne $Bytes -or (Get-Sha256File $resolved) -cne $Sha256) { throw 'External approval identity mismatch.' }
|
foreach ($root in $ForbiddenRoots) { if (Test-PathWithin $resolved $root) { throw 'Approval must be outside source and receipt trees.' } }
|
return [pscustomobject]@{ path=$resolved; item=$item; value=(Get-StrictJson $resolved) }
|
}
|
|
function Get-StringRawBytes([string]$Value) { return [Text.Encoding]::Unicode.GetBytes($Value + [char]0) }
|
|
function Convert-RegistryValueToBytes($Value, [Microsoft.Win32.RegistryValueKind]$Kind) {
|
switch ($Kind) {
|
'String' { return Get-StringRawBytes ([string]$Value) }
|
'ExpandString' { return Get-StringRawBytes ([string]$Value) }
|
'MultiString' {
|
$builder = [Text.StringBuilder]::new()
|
foreach ($part in @($Value)) { $null = $builder.Append([string]$part).Append([char]0) }
|
$null = $builder.Append([char]0)
|
return [Text.Encoding]::Unicode.GetBytes($builder.ToString())
|
}
|
'DWord' { return [BitConverter]::GetBytes([int]$Value) }
|
'QWord' { return [BitConverter]::GetBytes([long]$Value) }
|
'Binary' { return [byte[]]$Value }
|
default { throw 'Unsupported registry value kind.' }
|
}
|
}
|
|
function Get-FileRegistryState {
|
$path = (Resolve-Path -LiteralPath (Assert-NoReparseChain $script:InternalFileRegistryPath)).Path
|
$state = Get-StrictJson $path
|
Assert-ExactKeys $state @('key_exists','values') 'File registry state'
|
$values = @()
|
foreach ($entry in @($state.values)) {
|
Assert-ExactKeys $entry @('name','kind','data_base64') 'File registry value'
|
if ([string]$entry.name -notmatch '^[1-9][0-9]{0,3}$' -or [string]$entry.kind -cnotin @('String','ExpandString','MultiString','DWord','QWord','Binary')) { throw 'File registry value contract mismatch.' }
|
$values += [pscustomobject]@{ name=[string]$entry.name; kind=[string]$entry.kind; raw=[Convert]::FromBase64String([string]$entry.data_base64) }
|
}
|
return [pscustomobject]@{ key_exists=[bool]$state.key_exists; values=$values }
|
}
|
|
function Write-FileRegistryState($State) {
|
$payload = [ordered]@{ key_exists=[bool]$State.key_exists; values=@($State.values | ForEach-Object { [ordered]@{ name=$_.name; kind=$_.kind; data_base64=[Convert]::ToBase64String([byte[]]$_.raw) } }) }
|
$bytes = [Text.UTF8Encoding]::new($false).GetBytes(($payload | ConvertTo-Json -Depth 20 -Compress) + "`n")
|
$temp = $script:InternalFileRegistryPath + '.tmp-' + [Guid]::NewGuid().ToString('N')
|
$stream = [IO.File]::Open($temp,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
|
try { $stream.Write($bytes,0,$bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() }
|
$backup = $script:InternalFileRegistryPath + '.bak-' + [Guid]::NewGuid().ToString('N')
|
try { [IO.File]::Replace($temp,$script:InternalFileRegistryPath,$backup,$true) }
|
finally { if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Force } }
|
}
|
|
function Get-RegistryState {
|
if ($script:FileRegistry) { return Get-FileRegistryState }
|
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey([string]$script:Contract.policy_key, $false)
|
if ($null -eq $key) { return [pscustomobject]@{ key_exists=$false; values=@() } }
|
try {
|
$values = @()
|
foreach ($name in $key.GetValueNames()) {
|
if ($name -notmatch '^[1-9][0-9]{0,3}$') { throw 'Registry value name contract mismatch.' }
|
$kind = $key.GetValueKind($name)
|
if ($kind -notin @([Microsoft.Win32.RegistryValueKind]::String,[Microsoft.Win32.RegistryValueKind]::ExpandString,[Microsoft.Win32.RegistryValueKind]::MultiString,[Microsoft.Win32.RegistryValueKind]::DWord,[Microsoft.Win32.RegistryValueKind]::QWord,[Microsoft.Win32.RegistryValueKind]::Binary)) { throw 'Unsupported registry value kind.' }
|
$value = $key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
$values += [pscustomobject]@{ name=$name; kind=$kind.ToString(); raw=(Convert-RegistryValueToBytes $value $kind) }
|
}
|
return [pscustomobject]@{ key_exists=$true; values=$values }
|
} finally { $key.Dispose() }
|
}
|
|
function Set-PolicyTarget([string]$Name, [string]$Data) {
|
if ($script:FileRegistry) {
|
$state = Get-FileRegistryState
|
if (@($state.values | Where-Object { $_.name -ceq $Name }).Count) { throw 'Policy target already exists.' }
|
$state.key_exists = $true
|
$state.values += [pscustomobject]@{ name=$Name; kind='String'; raw=(Get-StringRawBytes $Data) }
|
Write-FileRegistryState $state
|
return
|
}
|
$key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey([string]$script:Contract.policy_key,$true)
|
try {
|
if ($key.GetValueNames() -ccontains $Name) { throw 'Policy target already exists.' }
|
$key.SetValue($Name,$Data,[Microsoft.Win32.RegistryValueKind]::String)
|
$key.Flush()
|
} finally { $key.Dispose() }
|
}
|
|
function Remove-PolicyTarget([string]$Name) {
|
if ($script:FileRegistry) {
|
$state = Get-FileRegistryState
|
$matches = @($state.values | Where-Object { $_.name -ceq $Name })
|
if ($matches.Count -ne 1) { throw 'Policy target is not uniquely present.' }
|
$state.values = @($state.values | Where-Object { $_.name -cne $Name })
|
Write-FileRegistryState $state
|
return
|
}
|
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey([string]$script:Contract.policy_key,$true)
|
if ($null -eq $key) { throw 'Policy key is absent.' }
|
try { $key.DeleteValue($Name,$true); $key.Flush() } finally { $key.Dispose() }
|
}
|
|
function Restore-AbsentPolicyKeyIfRequired($Preimage) {
|
if ([bool]$Preimage.key_exists) { return }
|
if ($script:FileRegistry) {
|
$state = Get-FileRegistryState
|
if ($state.values.Count -ne 0) { throw 'The policy key cannot be restored to absent because values remain.' }
|
$state.key_exists = $false
|
Write-FileRegistryState $state
|
return
|
}
|
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey([string]$script:Contract.policy_key, $false)
|
if ($null -eq $key) { return }
|
try {
|
if ($key.GetValueNames().Count -ne 0 -or $key.GetSubKeyNames().Count -ne 0) {
|
throw 'The policy key cannot be restored to absent because content remains.'
|
}
|
}
|
finally { $key.Dispose() }
|
[Microsoft.Win32.Registry]::CurrentUser.DeleteSubKey([string]$script:Contract.policy_key, $false)
|
}
|
|
function Get-PolicySnapshot {
|
$state = Get-RegistryState
|
$values = @()
|
$names = [string[]]@($state.values | ForEach-Object { $_.name })
|
[Array]::Sort($names,[StringComparer]::Ordinal)
|
foreach ($name in $names) {
|
$entry = @($state.values | Where-Object { $_.name -ceq $name })
|
if ($entry.Count -ne 1) { throw 'Duplicate registry value name.' }
|
$values += [ordered]@{ name=$name; kind=[string]$entry[0].kind; data_bytes=[long]$entry[0].raw.Length; data_sha256=(Get-Sha256Bytes ([byte[]]$entry[0].raw)) }
|
}
|
$builder = [Text.StringBuilder]::new()
|
$null = $builder.Append(([bool]$state.key_exists).ToString().ToLowerInvariant()).Append("`n")
|
foreach ($entry in $values) { $null = $builder.Append($entry.name).Append([char]0).Append($entry.kind).Append([char]0).Append($entry.data_bytes).Append([char]0).Append($entry.data_sha256.ToLowerInvariant()).Append("`n") }
|
return [ordered]@{ key_exists=[bool]$state.key_exists; target_value_name=[string]$script:Approval.policy.value_name; target_absent=(@($state.values | Where-Object { $_.name -ceq [string]$script:Approval.policy.value_name }).Count -eq 0); values=$values; canonical_sha256=(Get-Sha256Bytes ([Text.Encoding]::UTF8.GetBytes($builder.ToString()))) }
|
}
|
|
function Test-SnapshotEqual($Left,$Right) {
|
if ([bool]$Left.key_exists -ne [bool]$Right.key_exists -or [string]$Left.target_value_name -cne [string]$Right.target_value_name -or
|
[bool]$Left.target_absent -ne [bool]$Right.target_absent -or [string]$Left.canonical_sha256 -cne [string]$Right.canonical_sha256) { return $false }
|
$l = $Left.values | ConvertTo-Json -Compress -Depth 10
|
$r = $Right.values | ConvertTo-Json -Compress -Depth 10
|
return $l -ceq $r
|
}
|
|
function Test-CommittedPostimage($Snapshot,$Preimage) {
|
$name = [string]$script:Approval.policy.value_name
|
$expectedRaw = Get-StringRawBytes ([string]$script:Contract.policy_data)
|
$target = @($Snapshot.values | Where-Object { $_.name -ceq $name })
|
if ($target.Count -ne 1 -or $target[0].kind -cne 'String' -or $target[0].data_bytes -ne $expectedRaw.Length -or $target[0].data_sha256 -cne (Get-Sha256Bytes $expectedRaw)) { return $false }
|
$without = [ordered]@{ key_exists=$Snapshot.key_exists; target_value_name=$name; target_absent=$true; values=@($Snapshot.values | Where-Object { $_.name -cne $name }); canonical_sha256='' }
|
$builder = [Text.StringBuilder]::new(); $null=$builder.Append(([bool]$without.key_exists).ToString().ToLowerInvariant()).Append("`n")
|
foreach($entry in $without.values){$null=$builder.Append($entry.name).Append([char]0).Append($entry.kind).Append([char]0).Append($entry.data_bytes).Append([char]0).Append($entry.data_sha256.ToLowerInvariant()).Append("`n")}
|
$without.canonical_sha256=Get-Sha256Bytes ([Text.Encoding]::UTF8.GetBytes($builder.ToString()))
|
if (-not $Preimage.key_exists -and $without.values.Count -eq 0) {
|
# Creating the policy key changes key existence; target removal restores an empty key.
|
$without.key_exists=$false
|
$builder=[Text.StringBuilder]::new();$null=$builder.Append('false').Append("`n");$without.canonical_sha256=Get-Sha256Bytes ([Text.Encoding]::UTF8.GetBytes($builder.ToString()))
|
}
|
return Test-SnapshotEqual $without $Preimage
|
}
|
|
function Test-RollbackDeletionState($Snapshot,$Preimage) {
|
if (Test-SnapshotEqual $Snapshot $Preimage) { return $true }
|
return (-not [bool]$Preimage.key_exists -and [bool]$Snapshot.key_exists -and [bool]$Snapshot.target_absent -and @($Snapshot.values).Count -eq 0)
|
}
|
|
function Write-AtomicCreateNewJson([string]$FinalPath,$Value,[string]$RunId,[string]$Point) {
|
$temp = $FinalPath + '.' + $RunId + '.tmp'
|
$bytes = [Text.UTF8Encoding]::new($false).GetBytes(($Value | ConvertTo-Json -Depth 100 -Compress) + "`n")
|
$stream = [IO.File]::Open($temp,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
|
try { $stream.Write($bytes,0,$bytes.Length);$stream.Flush($true) } finally { $stream.Dispose() }
|
try {
|
Invoke-TestPoint $Point
|
[IO.File]::Move($temp,$FinalPath)
|
}
|
catch {
|
if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
|
throw
|
}
|
}
|
|
function Write-CreateNewJson([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()}
|
}
|
|
function Write-FailureEvidence([string]$Operation,[string]$PrimaryCode,[AllowNull()][string]$SecondaryCode) {
|
if (-not $script:FailureRootValidated) { Throw-ManagedCode 'E_FAILURE_EVIDENCE' }
|
if ($script:InternalFailureEvidenceWriteFailure) { Throw-ManagedCode 'E_FAILURE_EVIDENCE' }
|
$root=[string]$script:Approval.receipt_paths.failure_root
|
$id=[Guid]::NewGuid().ToString('N')
|
$path=Join-Path $root ('managed-extension-failure-'+$id+'.json')
|
$secondaryValue=if([string]::IsNullOrEmpty($SecondaryCode)){$null}else{$SecondaryCode}
|
$secondaryHash=if($secondaryValue){Get-Sha256Bytes([Text.Encoding]::UTF8.GetBytes($secondaryValue))}else{$null}
|
$record=[pscustomobject][ordered]@{
|
schema=1;task_id=[string]$script:Contract.task_id;operation=$Operation;status='FAILED'
|
primary_error_code=$PrimaryCode;primary_error_sha256=(Get-Sha256Bytes([Text.Encoding]::UTF8.GetBytes($PrimaryCode)))
|
secondary_error_code=$secondaryValue;secondary_error_sha256=$secondaryHash
|
recorded_at=[DateTimeOffset]::Now.ToString('o')
|
}
|
Assert-ExactKeys $record @($script:Contract.schemas.failure_evidence_keys) 'Failure evidence'
|
Write-CreateNewJson $path $record
|
$item=Get-Item -LiteralPath $path -Force
|
$verified=Get-StrictJson $path
|
Assert-ExactKeys $verified @($script:Contract.schemas.failure_evidence_keys) 'Failure evidence'
|
if($verified.schema-ne1-or$verified.task_id-cne[string]$script:Contract.task_id-or$verified.operation-cne$Operation-or
|
$verified.status-cne'FAILED'-or$verified.primary_error_code-cne$PrimaryCode-or
|
$verified.primary_error_sha256-cne(Get-Sha256Bytes([Text.Encoding]::UTF8.GetBytes($PrimaryCode)))-or
|
[string]$verified.secondary_error_code-cne[string]$secondaryValue-or
|
[string]$verified.secondary_error_sha256-cne[string]$secondaryHash){Throw-ManagedCode 'E_FAILURE_EVIDENCE'}
|
Assert-Rfc3339 ([string]$verified.recorded_at) 'Failure evidence recorded_at'
|
return [ordered]@{bytes=[long]$item.Length;sha256=(Get-Sha256File $path)}
|
}
|
|
function Invoke-TestPoint([string]$Point) {
|
if (-not $script:FileRegistry -and ($script:InternalInjectFailure -cne 'none' -or $script:InternalPauseAt -cne 'none')) { throw 'Internal test adapter requires the file registry provider.' }
|
if ($Point -cne 'none' -and $script:InternalPauseAt -ceq $Point) {
|
if (-not $script:InternalPauseMarker) { throw 'Internal pause marker is required.' }
|
[IO.File]::WriteAllText($script:InternalPauseMarker,'READY',[Text.UTF8Encoding]::new($false))
|
while ($true) { Start-Sleep -Milliseconds 200 }
|
}
|
$failurePoints=@([string]$script:InternalInjectFailure -split ','|Where-Object{$_-and$_-cne'none'})
|
if ($failurePoints -ccontains 'pipeline-stop' -and $Point -ceq 'after-install-pending') {
|
try { throw [System.Management.Automation.PipelineStoppedException]::new() }
|
catch [System.Management.Automation.PipelineStoppedException] { throw [OperationCanceledException]::new('E_CANCELLED') }
|
}
|
if ($Point -cne 'none' -and $failurePoints -ccontains $Point) { throw 'Injected terminating error.' }
|
}
|
|
function Get-ReceiptPaths {
|
$paths = $script:Approval.receipt_paths
|
return [ordered]@{
|
parent=[string]$paths.parent
|
failure_root=[string]$paths.failure_root
|
install_pending=[string]$paths.install_pending
|
install_final=[string]$paths.install_final
|
install_recovery=[string]$paths.install_recovery
|
rollback_pending=[string]$paths.rollback_pending
|
rollback_final=[string]$paths.rollback_final
|
rollback_recovery=[string]$paths.rollback_recovery
|
}
|
}
|
|
function Assert-NoUnknownReceiptTemps($Paths) {
|
$known = @(@($Paths.install_pending,$Paths.install_final,$Paths.install_recovery,$Paths.rollback_pending,$Paths.rollback_final,$Paths.rollback_recovery) | ForEach-Object { [IO.Path]::GetFileName([string]$_) })
|
foreach ($item in @(Get-ChildItem -LiteralPath $Paths.parent -Force -File)) {
|
foreach ($name in $known) {
|
if ($item.Name -cmatch ('^' + [Regex]::Escape($name) + '\.[a-f0-9]{32}\.tmp$')) { Throw-ManagedCode 'E_RECEIPT' }
|
}
|
}
|
}
|
|
function Write-RecoveryReceipt([string]$Path,[string]$Operation,[string]$Result,[string]$PendingPath) {
|
$receipt=[ordered]@{schema=1;operation=$Operation;status='RECOVERY_COMPLETE';result=$Result;task_id=[string]$script:Contract.task_id;pending=[ordered]@{path=$PendingPath;bytes=(Get-Item $PendingPath).Length;sha256=(Get-Sha256File $PendingPath)};recovered_at=[DateTimeOffset]::Now.ToString('o')}
|
Invoke-TestPoint 'before-recovery-receipt'
|
Write-AtomicCreateNewJson $Path $receipt ([Guid]::NewGuid().ToString('N')) 'after-recovery-receipt-temp'
|
Invoke-TestPoint 'after-recovery-receipt'
|
}
|
|
function Assert-RecoveryReceipt($Receipt,[string]$Operation,[string[]]$AllowedResults,[string]$PendingPath) {
|
Assert-ExactKeys $Receipt @('schema','operation','status','result','task_id','pending','recovered_at') 'Recovery receipt'
|
Assert-ExactKeys $Receipt.pending @('path','bytes','sha256') 'Recovery receipt pending binding'
|
if($Receipt.schema-ne1-or$Receipt.operation-cne$Operation-or$Receipt.status-cne'RECOVERY_COMPLETE'-or
|
$Receipt.result-cnotin$AllowedResults-or$Receipt.task_id-cne[string]$script:Contract.task_id-or
|
$Receipt.pending.path-cne$PendingPath-or$Receipt.pending.bytes-ne(Get-Item $PendingPath).Length-or
|
$Receipt.pending.sha256-cne(Get-Sha256File $PendingPath)){Throw-ManagedCode 'E_RECEIPT'}
|
Assert-Rfc3339 ([string]$Receipt.recovered_at) 'Recovery receipt recovered_at'
|
}
|
|
function Assert-InstallPending($Pending, $Paths) {
|
Assert-ExactKeys $Pending @('schema','operation','phase','run_id','task_id','created_at','owner_sid_sha256','contract','source_approval','release_approval','install_approval','policy','policy_preimage','final_receipt','commit_rule') 'Install pending'
|
foreach($bindingName in @('contract','source_approval','release_approval','install_approval')){Assert-ExactKeys $Pending.$bindingName @('bytes','sha256') "Install pending $bindingName"}
|
Assert-ExactKeys $Pending.policy @($script:Contract.schemas.policy_binding_keys) 'Install pending policy'
|
if($Pending.schema-ne1-or$Pending.operation-cne'install'-or$Pending.phase-cne'PREPARED'-or$Pending.run_id-notmatch'^[a-f0-9]{32}$'-or
|
$Pending.task_id-cne[string]$script:Contract.task_id-or$Pending.owner_sid_sha256-notmatch'^[A-F0-9]{64}$'-or
|
$Pending.contract.bytes-ne(Get-Item (Join-Path (Split-Path -Parent $PSCommandPath) 'managed-load-contract.json')).Length-or
|
$Pending.contract.sha256-cne(Get-Sha256File (Join-Path (Split-Path -Parent $PSCommandPath) 'managed-load-contract.json'))-or
|
$Pending.source_approval.bytes-ne$ApprovedSourceReceiptBytes-or$Pending.source_approval.sha256-cne$ApprovedSourceReceiptSha256-or
|
$Pending.release_approval.bytes-ne$ReleaseApprovalBytes-or$Pending.release_approval.sha256-cne$ReleaseApprovalSha256-or
|
$Pending.install_approval.bytes-ne$InstallApprovalBytes-or$Pending.install_approval.sha256-cne$InstallApprovalSha256-or
|
$Pending.policy.key-cne[string]$script:Approval.policy.key-or$Pending.policy.value_name-cne[string]$script:Approval.policy.value_name-or
|
$Pending.policy.kind-cne'String'-or$Pending.policy.data_bytes-ne$script:Approval.policy.data_bytes-or$Pending.policy.data_sha256-cne[string]$script:Approval.policy.data_sha256-or
|
$Pending.final_receipt-cne[string]$Paths.install_final-or$Pending.commit_rule-cne'FINAL_RECEIPT_ATOMIC_RENAME'-or
|
-not(Test-SnapshotEqual $Pending.policy_preimage $script:Approval.policy_preimage)){throw 'Install pending binding mismatch.'}
|
Assert-Rfc3339 ([string]$Pending.created_at) 'Install pending created_at'
|
}
|
|
function Assert-InstallFinal($Final, $Pending, [string]$PendingPath) {
|
Assert-ExactKeys $Final @('schema','operation','status','task_id','pending','policy_preimage','policy_postimage','committed_at') 'Install final receipt'
|
Assert-ExactKeys $Final.pending @('path','bytes','sha256') 'Install final pending binding'
|
if($Final.schema-ne1-or$Final.operation-cne'install'-or$Final.status-cne'COMMITTED'-or$Final.task_id-cne[string]$script:Contract.task_id-or
|
$Final.pending.path-cne$PendingPath-or$Final.pending.bytes-ne(Get-Item $PendingPath).Length-or$Final.pending.sha256-cne(Get-Sha256File $PendingPath)-or
|
-not(Test-SnapshotEqual $Final.policy_preimage $script:Approval.policy_preimage)-or-not(Test-CommittedPostimage $Final.policy_postimage $script:Approval.policy_preimage)){throw 'Install final receipt binding mismatch.'}
|
Assert-Rfc3339 ([string]$Final.committed_at) 'Install final committed_at'
|
}
|
|
function Assert-RollbackPending($Pending, $Paths) {
|
Assert-ExactKeys $Pending @('schema','operation','phase','run_id','task_id','created_at','install_receipt','install_preimage','policy','final_receipt','commit_rule') 'Rollback pending'
|
Assert-ExactKeys $Pending.install_receipt @('path','bytes','sha256') 'Rollback install receipt binding'
|
Assert-ExactKeys $Pending.policy @($script:Contract.schemas.policy_binding_keys) 'Rollback pending policy'
|
if($Pending.schema-ne1-or$Pending.operation-cne'rollback'-or$Pending.phase-cne'PREPARED'-or$Pending.run_id-notmatch'^[a-f0-9]{32}$'-or
|
$Pending.task_id-cne[string]$script:Contract.task_id-or$Pending.install_receipt.path-cne[string]$Paths.install_final-or
|
$Pending.install_receipt.bytes-ne(Get-Item $Paths.install_final).Length-or$Pending.install_receipt.sha256-cne(Get-Sha256File $Paths.install_final)-or
|
-not(Test-SnapshotEqual $Pending.install_preimage $script:Approval.policy_preimage)-or
|
$Pending.policy.key-cne[string]$script:Approval.policy.key-or$Pending.policy.value_name-cne[string]$script:Approval.policy.value_name-or
|
$Pending.policy.kind-cne'String'-or$Pending.policy.data_bytes-ne$script:Approval.policy.data_bytes-or$Pending.policy.data_sha256-cne[string]$script:Approval.policy.data_sha256-or
|
$Pending.final_receipt-cne[string]$Paths.rollback_final-or$Pending.commit_rule-cne'FINAL_RECEIPT_ATOMIC_RENAME'){throw 'Rollback pending binding mismatch.'}
|
Assert-Rfc3339 ([string]$Pending.created_at) 'Rollback pending created_at'
|
}
|
|
function Assert-RollbackFinal($Final, $Pending, [string]$PendingPath) {
|
Assert-ExactKeys $Final @('schema','operation','status','task_id','pending','restored_preimage','committed_at') 'Rollback final receipt'
|
Assert-ExactKeys $Final.pending @('path','bytes','sha256') 'Rollback final pending binding'
|
if($Final.schema-ne1-or$Final.operation-cne'rollback'-or$Final.status-cne'COMMITTED'-or$Final.task_id-cne[string]$script:Contract.task_id-or
|
$Final.pending.path-cne$PendingPath-or$Final.pending.bytes-ne(Get-Item $PendingPath).Length-or$Final.pending.sha256-cne(Get-Sha256File $PendingPath)-or
|
-not(Test-SnapshotEqual $Final.restored_preimage $script:Approval.policy_preimage)){throw 'Rollback final receipt binding mismatch.'}
|
Assert-Rfc3339 ([string]$Final.committed_at) 'Rollback final committed_at'
|
}
|
|
function Invoke-RecoveryIfNeeded([switch]$DuringFailure) {
|
$p=Get-ReceiptPaths
|
$hasInstallPending=Test-Path -LiteralPath $p.install_pending
|
$hasRollbackPending=Test-Path -LiteralPath $p.rollback_pending
|
if($hasRollbackPending -and -not $hasInstallPending){Throw-ManagedCode 'E_RECEIPT'}
|
if ($hasInstallPending -and -not ($Rollback -and $hasRollbackPending)) {
|
try{$pending=Get-StrictJson $p.install_pending;Assert-InstallPending $pending $p}catch{Throw-ManagedCode 'E_RECEIPT'}
|
if (Test-Path -LiteralPath $p.install_final) {
|
try{$final=Get-StrictJson $p.install_final;Assert-InstallFinal $final $pending $p.install_pending}catch{Throw-ManagedCode 'E_RECEIPT'}
|
if($DuringFailure){return 'INSTALL_COMMITTED'}
|
} else {
|
if (Test-Path -LiteralPath $p.install_recovery) {
|
try{$recovery=Get-StrictJson $p.install_recovery;Assert-RecoveryReceipt $recovery 'install' @('NO_POLICY_MUTATION','INSTALL_PREIMAGE_RESTORED') $p.install_pending}catch{Throw-ManagedCode 'E_RECEIPT'}
|
try{$current=Get-PolicySnapshot}catch{Throw-ManagedCode 'E_RECOVERY_FAILED'}
|
if(-not(Test-SnapshotEqual $current $pending.policy_preimage)){Throw-ManagedCode 'E_RECOVERY_AMBIGUOUS'}
|
return 'INSTALL_RECOVERED'
|
}
|
try{$current=Get-PolicySnapshot}catch{Throw-ManagedCode 'E_RECOVERY_FAILED'}
|
if (Test-SnapshotEqual $current $pending.policy_preimage) {
|
try{Write-RecoveryReceipt $p.install_recovery 'install' 'NO_POLICY_MUTATION' $p.install_pending}catch{$code=Get-ManagedCode $_ 'E_RECEIPT';Throw-ManagedCode $code}
|
return 'INSTALL_RECOVERED'
|
}
|
if (Test-CommittedPostimage $current $pending.policy_preimage) {
|
try{
|
Remove-PolicyTarget ([string]$script:Approval.policy.value_name)
|
Restore-AbsentPolicyKeyIfRequired $pending.policy_preimage
|
$restored=Get-PolicySnapshot
|
if (-not (Test-SnapshotEqual $restored $pending.policy_preimage)) { Throw-ManagedCode 'E_RECOVERY_FAILED' }
|
}catch{$code=Get-ManagedCode $_ 'E_RECOVERY_FAILED';Throw-ManagedCode $code}
|
try{Write-RecoveryReceipt $p.install_recovery 'install' 'INSTALL_PREIMAGE_RESTORED' $p.install_pending}catch{$code=Get-ManagedCode $_ 'E_RECEIPT';Throw-ManagedCode $code}
|
return 'INSTALL_RECOVERED'
|
}
|
Throw-ManagedCode 'E_RECOVERY_AMBIGUOUS'
|
}
|
}
|
if ($hasRollbackPending) {
|
try{$pending=Get-StrictJson $p.rollback_pending;Assert-RollbackPending $pending $p}catch{Throw-ManagedCode 'E_RECEIPT'}
|
if (Test-Path -LiteralPath $p.rollback_final) {
|
try{$final=Get-StrictJson $p.rollback_final;Assert-RollbackFinal $final $pending $p.rollback_pending}catch{Throw-ManagedCode 'E_RECEIPT'}
|
if($DuringFailure){return 'ROLLBACK_COMMITTED'}
|
} else {
|
if (Test-Path -LiteralPath $p.rollback_recovery) {
|
try{$recovery=Get-StrictJson $p.rollback_recovery;Assert-RecoveryReceipt $recovery 'rollback' @('NO_POLICY_MUTATION','ROLLBACK_ACTIVE_PREIMAGE_RESTORED') $p.rollback_pending}catch{Throw-ManagedCode 'E_RECEIPT'}
|
try{$current=Get-PolicySnapshot}catch{Throw-ManagedCode 'E_RECOVERY_FAILED'}
|
if(-not(Test-CommittedPostimage $current $pending.install_preimage)){Throw-ManagedCode 'E_RECOVERY_AMBIGUOUS'}
|
return 'ROLLBACK_RECOVERED'
|
}
|
try{$current=Get-PolicySnapshot}catch{Throw-ManagedCode 'E_RECOVERY_FAILED'}
|
if (Test-CommittedPostimage $current $pending.install_preimage) {
|
try{Write-RecoveryReceipt $p.rollback_recovery 'rollback' 'NO_POLICY_MUTATION' $p.rollback_pending}catch{$code=Get-ManagedCode $_ 'E_RECEIPT';Throw-ManagedCode $code}
|
return 'ROLLBACK_RECOVERED'
|
}
|
if (Test-RollbackDeletionState $current $pending.install_preimage) {
|
try{
|
Set-PolicyTarget ([string]$script:Approval.policy.value_name) ([string]$script:Contract.policy_data)
|
if (-not (Test-CommittedPostimage (Get-PolicySnapshot) $pending.install_preimage)) { Throw-ManagedCode 'E_RECOVERY_FAILED' }
|
}catch{$code=Get-ManagedCode $_ 'E_RECOVERY_FAILED';Throw-ManagedCode $code}
|
try{Write-RecoveryReceipt $p.rollback_recovery 'rollback' 'ROLLBACK_ACTIVE_PREIMAGE_RESTORED' $p.rollback_pending}catch{$code=Get-ManagedCode $_ 'E_RECEIPT';Throw-ManagedCode $code}
|
return 'ROLLBACK_RECOVERED'
|
}
|
Throw-ManagedCode 'E_RECOVERY_AMBIGUOUS'
|
}
|
}
|
return $null
|
}
|
|
function Complete-ManagedFailure($PrimaryError,[bool]$Cancelled) {
|
$primaryCode=if($Cancelled){'E_CANCELLED'}else{Get-ManagedCode $PrimaryError 'E_MANAGED_LOAD'}
|
$operation=if($Rollback){'rollback'}elseif($Install){'install'}elseif($HealthCheck){'health'}else{'validation'}
|
$recovery=$null;$secondaryCode=$null
|
if($script:Approval -and $script:MutexAcquired -and $primaryCode -cnotin @('E_RECEIPT','E_RECOVERY_AMBIGUOUS')){
|
try{$recovery=Invoke-RecoveryIfNeeded -DuringFailure}catch{$secondaryCode=Get-ManagedCode $_ 'E_RECOVERY_FAILED'}
|
}
|
if($recovery-ceq'INSTALL_COMMITTED' -and $Install){
|
[ordered]@{schema=1;status='INSTALL_COMPLETE';extension_id=[string]$script:Contract.extension_id;value_name=[string]$script:Approval.policy.value_name}|ConvertTo-Json -Compress;exit 0
|
}
|
if($recovery-ceq'ROLLBACK_COMMITTED' -and $Rollback){
|
[ordered]@{schema=1;status='ROLLBACK_COMPLETE_POLICY_REMOVED';extension_id=[string]$script:Contract.extension_id}|ConvertTo-Json -Compress;exit 0
|
}
|
if($recovery-cin@('INSTALL_COMMITTED','ROLLBACK_COMMITTED')){$recovery=$null}
|
if($script:FailureRootValidated){
|
try{$evidence=Write-FailureEvidence $operation $primaryCode $secondaryCode}catch{$secondaryCode='E_FAILURE_EVIDENCE';$evidence=$null}
|
}else{$evidence=$null}
|
if($secondaryCode){
|
$terminalCode=if($secondaryCode-cin@('E_RECEIPT','E_RECOVERY_AMBIGUOUS')){$secondaryCode}else{'E_RECOVERY_FAILED'}
|
[Console]::Error.WriteLine($terminalCode)
|
[ordered]@{schema=1;status='SAFETY_STOP';error_code=$terminalCode;failure_evidence=$evidence}|ConvertTo-Json -Compress
|
exit 3
|
}
|
if($recovery){
|
if($Cancelled){[Console]::Error.WriteLine('E_CANCELLED');[ordered]@{schema=1;status='CANCELLED_ROLLED_BACK';error_code='E_CANCELLED';recovery=$recovery;failure_evidence=$evidence}|ConvertTo-Json -Compress;exit 130}
|
[Console]::Error.WriteLine('E_OPERATION_ROLLED_BACK')
|
[ordered]@{schema=1;status='FAILED_ROLLED_BACK';error_code='E_OPERATION_ROLLED_BACK';recovery=$recovery;failure_evidence=$evidence}|ConvertTo-Json -Compress
|
exit 4
|
}
|
$terminalCode=if($primaryCode-cin@('E_RECEIPT','E_RECOVERY_AMBIGUOUS')){$primaryCode}else{'E_MANAGED_LOAD'}
|
if($Cancelled){$terminalCode='E_CANCELLED'}
|
[Console]::Error.WriteLine($terminalCode)
|
[ordered]@{schema=1;status='SAFETY_STOP';error_code=$terminalCode;failure_evidence=$evidence}|ConvertTo-Json -Compress
|
$exitCode=if($Cancelled){130}else{3}
|
exit $exitCode
|
}
|
|
function Assert-NativeHost {
|
$native=$script:Contract.native_host
|
foreach($pair in @(@($native.manifest_path,$native.manifest_bytes,$native.manifest_sha256),@($native.config_path,$native.config_bytes,$native.config_sha256),@($native.executable_path,$native.executable_bytes,$native.executable_sha256))){
|
$path=(Resolve-Path -LiteralPath (Assert-NoReparseChain ([string]$pair[0]))).Path;$item=Get-Item $path
|
if($item.Length-ne[long]$pair[1]-or(Get-Sha256File $path)-cne[string]$pair[2]){throw 'Native Host exact identity mismatch.'}
|
}
|
$manifest=Get-StrictJson ([string]$native.manifest_path)
|
if($manifest.name-cne[string]$native.name-or$manifest.type-cne'stdio'-or@($manifest.allowed_origins).Count-ne1-or$manifest.allowed_origins[0]-cne[string]$native.allowed_origin-or$manifest.path-cne[string]$native.executable_path){throw 'Native Host manifest contract mismatch.'}
|
}
|
|
try {
|
$modeCount = @(@($Install, $HealthCheck, $Rollback) | Where-Object { $_ }).Count
|
if($modeCount -gt 1){throw 'Only one mode is allowed.'}
|
$script:FileRegistry = [bool]$script:InternalFileRegistryPath
|
if (($script:InternalFileRegistryPath -or $script:InternalInjectFailure -cne 'none' -or $script:InternalPauseAt -cne 'none' -or $script:InternalPauseMarker) -and -not $script:InternalAllowTestScope) { throw 'Internal test adapter is unavailable.' }
|
if ($script:InternalAllowTestScope -and -not $script:InternalFileRegistryPath) { throw 'Internal synthetic approval mode requires the file-registry adapter.' }
|
$sourceRoot=(Resolve-Path -LiteralPath (Split-Path -Parent $PSCommandPath)).Path
|
$contractPath=Join-Path $sourceRoot 'managed-load-contract.json';$script:Contract=Get-StrictJson $contractPath
|
if($script:Contract.schema-ne1-or$script:Contract.task_id-cne'DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001'){throw 'Contract identity mismatch.'}
|
$projectRoot=(Resolve-Path -LiteralPath (Join-Path $sourceRoot ([string]$script:Contract.trust.project_root_relative_to_managed_source))).Path
|
$managedTree=Get-TreeSummary $sourceRoot
|
$forbidden=@($sourceRoot)
|
$source=Get-ExternalExactJson $ApprovedSourceReceipt $ApprovedSourceReceiptBytes $ApprovedSourceReceiptSha256 $forbidden
|
$fixedApproval=[IO.Path]::GetFullPath((Join-Path $projectRoot ([string]$script:Contract.trust.reviewer_source_approval_path)));if($source.path-cne$fixedApproval){throw 'Source approval is not at the fixed reviewer-owned trust location.'}
|
Assert-SourceApproval $source.value $managedTree $contractPath
|
$release=Get-ExternalExactJson $ReleaseApproval $ReleaseApprovalBytes $ReleaseApprovalSha256 $forbidden
|
Assert-ExactKeys $release.value @($script:Contract.schemas.release_approval_keys) 'Release approval'
|
$releaseScopes=if($script:InternalAllowTestScope){@('install-exact-webstore-extension','test-only-install-exact-webstore-extension')}else{@('install-exact-webstore-extension')}
|
if($release.value.schema-ne1-or$release.value.task_id-cne[string]$script:Contract.task_id-or$release.value.scope-cnotin$releaseScopes-or$release.value.status-cne'APPROVED'-or$release.value.approved_by_role-cne'dev.reviewer.project'-or$release.value.extension_id-cne[string]$script:Contract.extension_id-or$release.value.extension_version-cne[string]$script:Contract.extension_version-or$release.value.webstore_update_url-cne[string]$script:Contract.webstore_update_url-or$release.value.managed_load_contract.bytes-ne(Get-Item $contractPath).Length-or$release.value.managed_load_contract.sha256-cne(Get-Sha256File $contractPath)){throw 'Release approval binding mismatch.'}
|
foreach($binding in @($release.value.upload_build_receipt,$release.value.release_evidence)){$path=(Resolve-Path -LiteralPath (Assert-NoReparseChain ([string]$binding.path))).Path;$item=Get-Item $path;if($item.Length-ne[long]$binding.bytes-or(Get-Sha256File $path)-cne[string]$binding.sha256){throw 'Release evidence binding mismatch.'}}
|
Assert-ReleaseArtifacts $release.value $ApprovedSourceReceiptSha256
|
$installApprovalResult=Get-ExternalExactJson $InstallApproval $InstallApprovalBytes $InstallApprovalSha256 $forbidden
|
$script:Approval=$installApprovalResult.value
|
Assert-ExactKeys $script:Approval @($script:Contract.schemas.install_approval_keys) 'Install approval'
|
Assert-ExactKeys $script:Approval.source_approval @('bytes','sha256') 'Install approval source binding'
|
Assert-ExactKeys $script:Approval.release_approval @('bytes','sha256') 'Install approval release binding'
|
$installScopes=if($script:InternalAllowTestScope){@('install-exact-managed-extension-policy','test-only-install-exact-managed-extension-policy')}else{@('install-exact-managed-extension-policy')}
|
Assert-ExactKeys $script:Approval.policy @($script:Contract.schemas.policy_binding_keys) 'Install approval policy'
|
Assert-ExactKeys $script:Approval.policy_preimage @($script:Contract.schemas.policy_preimage_keys) 'Install approval policy preimage'
|
Assert-ExactKeys $script:Approval.receipt_paths @($script:Contract.schemas.receipt_paths_keys) 'Install approval receipt paths'
|
Assert-ExactKeys $script:Approval.native_host @($script:Contract.schemas.native_host_binding_keys) 'Install approval native host'
|
if($script:Approval.schema-ne1-or$script:Approval.scope-cnotin$installScopes-or$script:Approval.status-cne'APPROVED'-or$script:Approval.approved_by_role-cne'project.admin'-or$script:Approval.task_id-cne[string]$script:Contract.task_id-or$script:Approval.source_approval.bytes-ne$ApprovedSourceReceiptBytes-or$script:Approval.source_approval.sha256-cne$ApprovedSourceReceiptSha256-or$script:Approval.release_approval.bytes-ne$ReleaseApprovalBytes-or$script:Approval.release_approval.sha256-cne$ReleaseApprovalSha256-or$script:Approval.policy.key-cne[string]$script:Contract.policy_key-or$script:Approval.policy.value_name-notmatch'^[1-9][0-9]{0,3}$'-or$script:Approval.policy.kind-cne'String'){throw 'Install approval binding mismatch.'}
|
foreach($property in @($script:Contract.schemas.native_host_binding_keys)){if([string]$script:Approval.native_host.$property-cne[string]$script:Contract.native_host.$property){throw 'Install approval Native Host binding mismatch.'}}
|
Assert-Rfc3339 ([string]$script:Approval.approved_at) 'Install approval approved_at'
|
$expectedRaw=Get-StringRawBytes ([string]$script:Contract.policy_data)
|
if($script:Approval.policy.data_bytes-ne$expectedRaw.Length-or$script:Approval.policy.data_sha256-cne(Get-Sha256Bytes $expectedRaw)){throw 'Policy data binding mismatch.'}
|
Assert-NativeHost
|
$paths=Get-ReceiptPaths
|
$parent=(Resolve-Path -LiteralPath (Assert-NoReparseChain $paths.parent)).Path
|
$failureRoot=(Resolve-Path -LiteralPath (Assert-NoReparseChain $paths.failure_root)).Path
|
$failureItem=Get-Item -LiteralPath $failureRoot -Force
|
if(-not$failureItem.PSIsContainer-or(Test-PathWithin $failureRoot $parent)-or(Test-PathWithin $parent $failureRoot)-or(Test-PathWithin $failureRoot $sourceRoot)){throw 'Failure root is not an independent approved directory.'}
|
$expectedReceiptNames=[ordered]@{install_pending='managed-extension-install-pending.json';install_final='managed-extension-install-receipt.json';install_recovery='managed-extension-install-recovery-receipt.json';rollback_pending='managed-extension-rollback-pending.json';rollback_final='managed-extension-rollback-receipt.json';rollback_recovery='managed-extension-rollback-recovery-receipt.json'}
|
if(-not $script:InternalAllowTestScope){$projectLocal=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'project-info'));if(-not(Test-PathWithin $parent $projectLocal)-or-not(Test-PathWithin $failureRoot $projectLocal)){throw 'Approved state roots are outside the LocalAppData boundary.'}}
|
foreach ($external in @($source.path, $release.path, $installApprovalResult.path)) {
|
if ((Test-PathWithin $external $parent)-or(Test-PathWithin $external $failureRoot)) { throw 'Approval must be outside source and state trees.' }
|
}
|
foreach($binding in @($release.value.upload_build_receipt,$release.value.release_evidence)){if((Test-PathWithin ([IO.Path]::GetFullPath([string]$binding.path)) $parent)-or(Test-PathWithin ([IO.Path]::GetFullPath([string]$binding.path)) $failureRoot)){throw 'Release evidence must be outside state trees.'}}
|
foreach($name in @('install_pending','install_final','install_recovery','rollback_pending','rollback_final','rollback_recovery')){if((Split-Path -Parent ([string]$paths[$name]))-cne$parent-or[IO.Path]::GetFileName([string]$paths[$name])-cne[string]$expectedReceiptNames[$name]){throw 'Receipt path leaves its exact approved target.'};Assert-NoReparseChain ([string]$paths[$name]) -LeafMayBeAbsent|Out-Null}
|
if($script:InternalFileRegistryPath){Assert-NoReparseChain $script:InternalFileRegistryPath|Out-Null}
|
$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
$mutexHash=Get-Sha256Bytes ([Text.Encoding]::UTF8.GetBytes($sid+'|'+[string]$script:Contract.policy_key))
|
$script:Mutex=[Threading.Mutex]::new($false,'Local\project-info-bili-managed-load-'+$mutexHash.Substring(0,24))
|
if(-not$script:Mutex.WaitOne([TimeSpan]::FromSeconds(10))){throw 'Installer mutex is busy.'}
|
$script:MutexAcquired = $true
|
$script:FailureRootValidated = $true
|
Assert-NoUnknownReceiptTemps $paths
|
if((Test-Path -LiteralPath $paths.install_final)-and-not(Test-Path -LiteralPath $paths.install_pending)){Throw-ManagedCode 'E_RECEIPT'}
|
if((Test-Path -LiteralPath $paths.install_recovery)-and-not(Test-Path -LiteralPath $paths.install_pending)){Throw-ManagedCode 'E_RECEIPT'}
|
if((Test-Path -LiteralPath $paths.rollback_final)-and-not(Test-Path -LiteralPath $paths.rollback_pending)){Throw-ManagedCode 'E_RECEIPT'}
|
if((Test-Path -LiteralPath $paths.rollback_recovery)-and-not(Test-Path -LiteralPath $paths.rollback_pending)){Throw-ManagedCode 'E_RECEIPT'}
|
$recovered=Invoke-RecoveryIfNeeded
|
if($recovered){$evidence=Write-FailureEvidence 'recovery' 'E_PROCESS_INTERRUPTED' $null;[ordered]@{schema=1;status='FAILED_ROLLED_BACK';error_code='E_PROCESS_INTERRUPTED';recovery=$recovered;failure_evidence=$evidence}|ConvertTo-Json -Compress;exit 4}
|
$approvedPreimage=$script:Approval.policy_preimage
|
Assert-ExactKeys $approvedPreimage @('key_exists','target_value_name','target_absent','values','canonical_sha256') 'Policy preimage'
|
if($approvedPreimage.target_value_name-cne[string]$script:Approval.policy.value_name-or-not[bool]$approvedPreimage.target_absent){throw 'Policy preimage contract mismatch.'}
|
if(-not $Install -and -not $HealthCheck -and -not $Rollback){
|
foreach($name in @('install_pending','install_final','install_recovery','rollback_pending','rollback_final','rollback_recovery')){if(Test-Path -LiteralPath $paths[$name]){throw 'Fresh validation requires absent receipt targets.'}}
|
if(-not(Test-SnapshotEqual (Get-PolicySnapshot) $approvedPreimage)){throw 'Policy preimage mismatch.'}
|
[ordered]@{schema=1;status='VALIDATION_PASS_ONLY';extension_id=[string]$script:Contract.extension_id}|ConvertTo-Json -Compress;exit 0
|
}
|
if($HealthCheck){
|
if(-not(Test-Path -LiteralPath $paths.install_pending)-or-not(Test-Path -LiteralPath $paths.install_final)-or-not(Test-CommittedPostimage (Get-PolicySnapshot) $approvedPreimage)){throw 'Health state mismatch.'}
|
[ordered]@{schema=1;status='HEALTH_PASS';extension_id=[string]$script:Contract.extension_id}|ConvertTo-Json -Compress;exit 0
|
}
|
if($Install){
|
if(-not(Test-SnapshotEqual (Get-PolicySnapshot) $approvedPreimage)){throw 'Policy preimage mismatch.'}
|
foreach($name in @('install_pending','install_final','install_recovery','rollback_pending','rollback_final','rollback_recovery')){if(Test-Path -LiteralPath $paths[$name]){throw 'Receipt target already exists.'}}
|
$run=[Guid]::NewGuid().ToString('N')
|
$pending=[ordered]@{schema=1;operation='install';phase='PREPARED';run_id=$run;task_id=[string]$script:Contract.task_id;created_at=[DateTimeOffset]::Now.ToString('o');owner_sid_sha256=(Get-Sha256Bytes([Text.Encoding]::UTF8.GetBytes($sid)));contract=[ordered]@{bytes=(Get-Item $contractPath).Length;sha256=(Get-Sha256File $contractPath)};source_approval=[ordered]@{bytes=$ApprovedSourceReceiptBytes;sha256=$ApprovedSourceReceiptSha256};release_approval=[ordered]@{bytes=$ReleaseApprovalBytes;sha256=$ReleaseApprovalSha256};install_approval=[ordered]@{bytes=$InstallApprovalBytes;sha256=$InstallApprovalSha256};policy=[ordered]@{key=[string]$script:Contract.policy_key;value_name=[string]$script:Approval.policy.value_name;kind='String';data_bytes=$expectedRaw.Length;data_sha256=(Get-Sha256Bytes $expectedRaw)};policy_preimage=$approvedPreimage;final_receipt=$paths.install_final;commit_rule='FINAL_RECEIPT_ATOMIC_RENAME'}
|
Write-AtomicCreateNewJson $paths.install_pending $pending $run 'after-install-pending-temp';Invoke-TestPoint 'after-install-pending';Invoke-TestPoint 'before-policy-write'
|
if(-not(Test-SnapshotEqual (Get-PolicySnapshot) $approvedPreimage)){throw 'Last policy preimage check failed.'}
|
Set-PolicyTarget ([string]$script:Approval.policy.value_name) ([string]$script:Contract.policy_data);Invoke-TestPoint 'after-policy-write'
|
if(-not(Test-CommittedPostimage (Get-PolicySnapshot) $approvedPreimage)){throw 'Install postimage mismatch.'};Invoke-TestPoint 'after-install-postimage'
|
$final=[ordered]@{schema=1;operation='install';status='COMMITTED';task_id=[string]$script:Contract.task_id;pending=[ordered]@{path=$paths.install_pending;bytes=(Get-Item $paths.install_pending).Length;sha256=(Get-Sha256File $paths.install_pending)};policy_preimage=$approvedPreimage;policy_postimage=(Get-PolicySnapshot);committed_at=[DateTimeOffset]::Now.ToString('o')}
|
Write-AtomicCreateNewJson $paths.install_final $final $run 'after-install-final-temp';Invoke-TestPoint 'after-install-final'
|
[ordered]@{schema=1;status='INSTALL_COMPLETE';extension_id=[string]$script:Contract.extension_id;value_name=[string]$script:Approval.policy.value_name}|ConvertTo-Json -Compress;exit 0
|
}
|
if($Rollback){
|
if(-not(Test-Path -LiteralPath $paths.install_pending)-or-not(Test-Path -LiteralPath $paths.install_final)-or(Test-Path -LiteralPath $paths.rollback_pending)-or(Test-Path -LiteralPath $paths.rollback_final)){throw 'Rollback receipt precondition failed.'}
|
if(-not(Test-CommittedPostimage (Get-PolicySnapshot) $approvedPreimage)){throw 'Rollback active state mismatch.'}
|
$run=[Guid]::NewGuid().ToString('N')
|
$pending=[ordered]@{schema=1;operation='rollback';phase='PREPARED';run_id=$run;task_id=[string]$script:Contract.task_id;created_at=[DateTimeOffset]::Now.ToString('o');install_receipt=[ordered]@{path=$paths.install_final;bytes=(Get-Item $paths.install_final).Length;sha256=(Get-Sha256File $paths.install_final)};install_preimage=$approvedPreimage;policy=[ordered]@{key=[string]$script:Contract.policy_key;value_name=[string]$script:Approval.policy.value_name;kind='String';data_bytes=$expectedRaw.Length;data_sha256=(Get-Sha256Bytes $expectedRaw)};final_receipt=$paths.rollback_final;commit_rule='FINAL_RECEIPT_ATOMIC_RENAME'}
|
Write-AtomicCreateNewJson $paths.rollback_pending $pending $run 'after-rollback-pending-temp';Invoke-TestPoint 'after-rollback-pending';Invoke-TestPoint 'before-policy-delete'
|
if(-not(Test-CommittedPostimage (Get-PolicySnapshot) $approvedPreimage)){throw 'Last rollback active check failed.'}
|
Remove-PolicyTarget ([string]$script:Approval.policy.value_name);Invoke-TestPoint 'after-policy-delete'
|
Restore-AbsentPolicyKeyIfRequired $approvedPreimage
|
if(-not(Test-SnapshotEqual (Get-PolicySnapshot) $approvedPreimage)){throw 'Rollback postimage mismatch.'};Invoke-TestPoint 'after-rollback-postimage'
|
$final=[ordered]@{schema=1;operation='rollback';status='COMMITTED';task_id=[string]$script:Contract.task_id;pending=[ordered]@{path=$paths.rollback_pending;bytes=(Get-Item $paths.rollback_pending).Length;sha256=(Get-Sha256File $paths.rollback_pending)};restored_preimage=$approvedPreimage;committed_at=[DateTimeOffset]::Now.ToString('o')}
|
Write-AtomicCreateNewJson $paths.rollback_final $final $run 'after-rollback-final-temp';Invoke-TestPoint 'after-rollback-final'
|
[ordered]@{schema=1;status='ROLLBACK_COMPLETE_POLICY_REMOVED';extension_id=[string]$script:Contract.extension_id}|ConvertTo-Json -Compress;exit 0
|
}
|
}
|
catch [System.Management.Automation.PipelineStoppedException] {
|
Complete-ManagedFailure $_ $true
|
}
|
catch [OperationCanceledException] {
|
Complete-ManagedFailure $_ $true
|
}
|
catch {
|
Complete-ManagedFailure $_ $false
|
}
|
finally {
|
if($script:MutexAcquired){$script:Mutex.ReleaseMutex()|Out-Null;$script:MutexAcquired=$false}
|
if($script:Mutex){$script:Mutex.Dispose()}
|
}
|