1918 lines
79 KiB
PowerShell
1918 lines
79 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[string]$OutputDir = '',
|
|
[switch]$PlanOnly,
|
|
[switch]$CheckOnly,
|
|
[switch]$UseFrozenReleaseEvidence,
|
|
[ValidateSet('None', 'Download', 'Validation', 'Promotion')]
|
|
[string]$SimulateFailure = 'None'
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
Set-StrictMode -Version Latest
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
|
|
if ([string]::IsNullOrEmpty($OutputDir)) {
|
|
$OutputDir = Join-Path $PSScriptRoot '..\src-tauri\bundled\components'
|
|
} elseif ([string]::IsNullOrWhiteSpace($OutputDir)) {
|
|
throw 'OutputDir must not be whitespace.'
|
|
}
|
|
|
|
$ExpectedComponents = @(
|
|
[PSCustomObject]@{
|
|
id = 'proxifyre'; version = '2.4.0'; installRole = 'proxifyre-runtime'; assetArch = 'x64'
|
|
assetName = 'ProxiFyre-v2.4.0-x64-signed.zip'; licenseId = 'AGPL-3.0-only'; licensePath = 'proxifyre/LICENSE'
|
|
policyType = 'githubReleaseDigest'; sourceUrl = 'https://github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip'
|
|
sha256 = 'eab65fd7d8eeb716abedb5614618c641de3f9eb8326b99cee1da787141e30cac'; size = 1519694L
|
|
fileVersion = '2.4.0'; productVersion = '2.4.0'
|
|
licenseSha256 = '8486a10c4393cee1c25392769ddd3b2d6c242d6ec7928e1414efff7dfb2f07ef'; licenseSize = 34523L
|
|
}
|
|
[PSCustomObject]@{
|
|
id = 'windows-packet-filter'; version = '3.6.2'; installRole = 'packet-filter-driver'; assetArch = 'x64'
|
|
assetName = 'Windows.Packet.Filter.3.6.2.1.x64.msi'; licenseId = 'MIT'; licensePath = 'windows-packet-filter/LICENSE'
|
|
policyType = 'githubReleaseDigest'; sourceUrl = 'https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi'
|
|
sha256 = '9c388c0b7f189f7fa98720bae2caecf7d64f30910838b80b438ecf8956b8502c'; size = 819200L
|
|
fileVersion = '3.6.2.1'; productVersion = '3.6.2.1'
|
|
licenseSha256 = 'b12f4cfcce43cef59100cf8c4eaf67ae5246c9a047e00f6059655f6694030efe'; licenseSize = 1070L
|
|
}
|
|
[PSCustomObject]@{
|
|
id = 'vc-runtime'; version = '14.51.36247.0'; installRole = 'vc-runtime-prerequisite'; assetArch = 'x64'
|
|
assetName = 'VC_redist.x64.exe'; licenseId = 'LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026'
|
|
licensePath = 'vc-runtime/LICENSE.docx'; policyType = 'buildTimeOnlyAuthenticode'
|
|
sourceUrl = 'https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe'
|
|
sha256 = '843068991daaa1f73ad9f6239bce4d0f6a07a51f18c37ea2a867e9beca71295c'; size = 18731856L
|
|
fileVersion = '14.51.36247.0'; productVersion = '14.51.36247.0'
|
|
licenseSha256 = '08651651a7602fc7c0e2763de0fde1ff9f868df2780597cd1775ee9d6441c783'; licenseSize = 39553L
|
|
}
|
|
[PSCustomObject]@{
|
|
id = 'sing-box'; version = '1.13.19'; installRole = 'sing-box-runtime'; assetArch = 'x64'
|
|
assetName = 'sing-box-1.13.19-windows-amd64.zip'; licenseId = 'LicenseRef-Sing-Box-Project'
|
|
licensePath = 'sing-box/LICENSE'; policyType = 'githubReleaseDigest'
|
|
sourceUrl = 'https://github.com/SagerNet/sing-box/releases/download/v1.13.19/sing-box-1.13.19-windows-amd64.zip'
|
|
sha256 = 'e011a4def2f5e2b143ed54adb2b1a20a6be407806ab4442f3667f1dd817a2c8d'; size = 21046252L
|
|
fileVersion = $null; productVersion = $null
|
|
licenseSha256 = '650d5e3b99a446fb38e820fa87a49562e0c79eab868fff58618ac487a58e554c'; licenseSize = 791L
|
|
}
|
|
[PSCustomObject]@{
|
|
id = 'winsw'; version = '2.12.0'; installRole = 'sing-box-service-wrapper'; assetArch = 'anycpu'
|
|
assetName = 'WinSW.NET461.exe'; licenseId = 'MIT'; licensePath = 'winsw/LICENSE.txt'
|
|
policyType = 'bundledOnlyNoIndependentProof'
|
|
sourceUrl = 'https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe'
|
|
sha256 = 'b5066b7bbdfba1293e5d15cda3caaea88fbeab35bd5b38c41c913d492aadfc4f'; size = 655872L
|
|
fileVersion = '2.12.0.0'; productVersion = '2.12.0+eef5bade59fca0254e387ac73ed7625ba6aa7147'
|
|
licenseSha256 = '1cdf703c10a70e5973bf3acf2a5eeabe7746237155b92db2034aeae26fdf7802'; licenseSize = 1158L
|
|
}
|
|
)
|
|
|
|
function ConvertTo-ResultJson([object]$Value) {
|
|
$Value | ConvertTo-Json -Depth 20
|
|
}
|
|
|
|
function Assert-ExactProperties(
|
|
[object]$Value,
|
|
[string[]]$Required,
|
|
[string[]]$Optional,
|
|
[string]$Label
|
|
) {
|
|
if ($null -eq $Value) {
|
|
throw "$Label is missing."
|
|
}
|
|
|
|
$names = @($Value.PSObject.Properties.Name)
|
|
foreach ($name in $Required) {
|
|
if ($names -cnotcontains $name) {
|
|
throw "$Label is missing required property '$name'."
|
|
}
|
|
}
|
|
foreach ($name in $names) {
|
|
if (($Required -cnotcontains $name) -and ($Optional -cnotcontains $name)) {
|
|
throw "$Label contains unknown property '$name'."
|
|
}
|
|
}
|
|
}
|
|
|
|
function Test-StableNumericVersion([object]$Value) {
|
|
if ($Value -isnot [string] -or $Value -notmatch '^[0-9]{1,10}(\.[0-9]{1,10}){1,3}$') {
|
|
return $false
|
|
}
|
|
return $true
|
|
}
|
|
|
|
function Test-StableProductVersion([object]$Value) {
|
|
if ($Value -isnot [string]) {
|
|
return $false
|
|
}
|
|
$parts = $Value.Split('+')
|
|
if ($parts.Count -eq 1) {
|
|
return (Test-StableNumericVersion $Value)
|
|
}
|
|
if ($parts.Count -ne 2 -or -not (Test-StableNumericVersion $parts[0])) {
|
|
return $false
|
|
}
|
|
return $parts[1] -match '^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*$' -and $parts[1].Length -le 128
|
|
}
|
|
|
|
function Test-WindowsReservedName([string]$Segment) {
|
|
$stem = $Segment.Split('.')[0].ToUpperInvariant()
|
|
if (@('CON', 'PRN', 'AUX', 'NUL') -contains $stem) {
|
|
return $true
|
|
}
|
|
return $stem -match '^(COM|LPT)[1-9]$'
|
|
}
|
|
|
|
function Assert-SafeRelativePath([object]$Value, [string]$Label) {
|
|
if ($Value -isnot [string] -or $Value.Length -eq 0 -or $Value.Length -gt 512) {
|
|
throw "$Label is not a safe relative path."
|
|
}
|
|
if ($Value.Contains('\') -or $Value.StartsWith('/') -or $Value.EndsWith('/')) {
|
|
throw "$Label is not a safe relative path."
|
|
}
|
|
foreach ($segment in $Value.Split('/')) {
|
|
if (
|
|
$segment.Length -eq 0 -or
|
|
$segment.Length -gt 128 -or
|
|
$segment -in @('.', '..') -or
|
|
$segment.EndsWith('.') -or
|
|
(Test-WindowsReservedName $segment) -or
|
|
$segment -notmatch '^[A-Za-z0-9._-]+$'
|
|
) {
|
|
throw "$Label is not a safe relative path."
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-PlainHttpsUrl([object]$Value, [string]$Label) {
|
|
if ($Value -isnot [string]) {
|
|
throw "$Label must be an HTTPS URL."
|
|
}
|
|
$uri = $null
|
|
if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$uri)) {
|
|
throw "$Label must be an HTTPS URL."
|
|
}
|
|
if (
|
|
$uri.Scheme -ne 'https' -or
|
|
[string]::IsNullOrWhiteSpace($uri.Host) -or
|
|
-not [string]::IsNullOrEmpty($uri.UserInfo) -or
|
|
-not [string]::IsNullOrEmpty($uri.Query) -or
|
|
-not [string]::IsNullOrEmpty($uri.Fragment) -or
|
|
$Value -notmatch '^https://[^/:@]+(?:/|$)'
|
|
) {
|
|
throw "$Label must be a plain HTTPS URL."
|
|
}
|
|
return $uri
|
|
}
|
|
|
|
function Test-CatalogPattern([string]$Pattern, [string]$Value) {
|
|
$parts = $Pattern.Split('*')
|
|
if ($parts.Count -eq 1) {
|
|
return [string]::Equals($Pattern, $Value, [StringComparison]::Ordinal)
|
|
}
|
|
if ($parts.Count -ne 2) {
|
|
return $false
|
|
}
|
|
return $Value.StartsWith($parts[0], [StringComparison]::Ordinal) -and
|
|
$Value.EndsWith($parts[1], [StringComparison]::Ordinal) -and
|
|
$Value.Length -ge ($parts[0].Length + $parts[1].Length)
|
|
}
|
|
|
|
function Assert-CatalogPattern([object]$Value, [string]$Label) {
|
|
if (
|
|
$Value -isnot [string] -or
|
|
$Value.Length -eq 0 -or
|
|
$Value.Length -gt 160 -or
|
|
@($Value.ToCharArray() | Where-Object { $_ -eq '*' }).Count -gt 1 -or
|
|
$Value -notmatch '^[A-Za-z0-9._+*-]+$'
|
|
) {
|
|
throw "$Label is invalid."
|
|
}
|
|
}
|
|
|
|
function Assert-StringArray([object]$Value, [string]$Label) {
|
|
if ($Value -isnot [Array]) {
|
|
throw "$Label must be an array."
|
|
}
|
|
$items = @($Value)
|
|
if ($items.Count -eq 0) {
|
|
throw "$Label must not be empty."
|
|
}
|
|
$seen = @{}
|
|
foreach ($item in $items) {
|
|
if ($item -isnot [string] -or [string]::IsNullOrWhiteSpace($item) -or $item.Trim() -ne $item) {
|
|
throw "$Label contains an invalid value."
|
|
}
|
|
if ($seen.ContainsKey($item)) {
|
|
throw "$Label contains a duplicate value."
|
|
}
|
|
$seen[$item] = $true
|
|
}
|
|
}
|
|
|
|
function Assert-TrustPolicy([object]$Component, [Uri]$SourceUri, [string]$AssetName) {
|
|
$policy = $Component.updateTrustPolicy
|
|
if ($null -eq $policy -or $policy.PSObject.Properties.Name -cnotcontains 'type') {
|
|
throw "updateTrustPolicy is missing for $($Component.id)."
|
|
}
|
|
|
|
switch -CaseSensitive ($policy.type) {
|
|
'githubReleaseDigest' {
|
|
Assert-ExactProperties $policy @('type', 'repository', 'tagPattern', 'assetPattern', 'requireStable') @('authenticodePublishers') "updateTrustPolicy for $($Component.id)"
|
|
if ($policy.repository -isnot [string] -or $policy.repository -notmatch '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$') {
|
|
throw "GitHub repository is invalid for $($Component.id)."
|
|
}
|
|
Assert-CatalogPattern $policy.tagPattern 'tagPattern'
|
|
Assert-CatalogPattern $policy.assetPattern 'assetPattern'
|
|
if ($policy.requireStable -isnot [bool] -or -not $policy.requireStable) {
|
|
throw "GitHub release policy must require a stable release."
|
|
}
|
|
if ($policy.PSObject.Properties.Name -ccontains 'authenticodePublishers') {
|
|
Assert-StringArray $policy.authenticodePublishers 'authenticodePublishers'
|
|
}
|
|
if ($SourceUri.Host -ne 'github.com') {
|
|
throw "GitHub release source must use github.com."
|
|
}
|
|
$segments = @($SourceUri.AbsolutePath.Trim('/').Split('/'))
|
|
if (
|
|
$segments.Count -ne 6 -or
|
|
$segments[2] -cne 'releases' -or
|
|
$segments[3] -cne 'download' -or
|
|
-not [string]::Equals("$($segments[0])/$($segments[1])", $policy.repository, [StringComparison]::OrdinalIgnoreCase) -or
|
|
$segments[5] -cne $AssetName -or
|
|
-not (Test-CatalogPattern $policy.tagPattern $segments[4]) -or
|
|
-not (Test-CatalogPattern $policy.assetPattern $AssetName)
|
|
) {
|
|
throw "GitHub source does not match trust policy for $($Component.id)."
|
|
}
|
|
}
|
|
'buildTimeOnlyAuthenticode' {
|
|
Assert-ExactProperties $policy @('type', 'allowedSourceHosts', 'assetPattern', 'publishers') @() "updateTrustPolicy for $($Component.id)"
|
|
Assert-StringArray $policy.allowedSourceHosts 'allowedSourceHosts'
|
|
Assert-StringArray $policy.publishers 'publishers'
|
|
Assert-CatalogPattern $policy.assetPattern 'assetPattern'
|
|
$hosts = @($policy.allowedSourceHosts | ForEach-Object { $_.ToLowerInvariant() })
|
|
if ($hosts -notcontains $SourceUri.Host.ToLowerInvariant() -or -not (Test-CatalogPattern $policy.assetPattern $AssetName)) {
|
|
throw "Authenticode source does not match trust policy for $($Component.id)."
|
|
}
|
|
}
|
|
'bundledOnlyNoIndependentProof' {
|
|
Assert-ExactProperties $policy @('type', 'reason') @() "updateTrustPolicy for $($Component.id)"
|
|
if (
|
|
$policy.reason -isnot [string] -or
|
|
[string]::IsNullOrWhiteSpace($policy.reason) -or
|
|
$policy.reason.Trim() -ne $policy.reason -or
|
|
$policy.reason.Length -gt 240 -or
|
|
$policy.reason.IndexOfAny([char[]]@(0..31)) -ge 0
|
|
) {
|
|
throw "Bundled-only trust reason is invalid for $($Component.id)."
|
|
}
|
|
}
|
|
default {
|
|
throw "Unknown update trust policy for $($Component.id)."
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-PinnedTrustPolicy([object]$Component) {
|
|
$policy = $Component.updateTrustPolicy
|
|
switch -CaseSensitive ($Component.id) {
|
|
'proxifyre' {
|
|
if (
|
|
$policy.type -cne 'githubReleaseDigest' -or
|
|
$policy.repository -cne 'wiresock/proxifyre' -or
|
|
$policy.tagPattern -cne 'v*' -or
|
|
$policy.assetPattern -cne 'ProxiFyre-v*-x64-signed.zip' -or
|
|
@($policy.authenticodePublishers).Count -ne 1 -or
|
|
@($policy.authenticodePublishers)[0] -cne 'The Anti-Cloud Corporation'
|
|
) { throw 'Pinned ProxiFyre trust policy mismatch.' }
|
|
}
|
|
'windows-packet-filter' {
|
|
if (
|
|
$policy.type -cne 'githubReleaseDigest' -or
|
|
$policy.repository -cne 'wiresock/ndisapi' -or
|
|
$policy.tagPattern -cne 'v*' -or
|
|
$policy.assetPattern -cne 'Windows.Packet.Filter.*.x64.msi' -or
|
|
@($policy.authenticodePublishers).Count -ne 1 -or
|
|
@($policy.authenticodePublishers)[0] -cne 'The Anti-Cloud Corporation'
|
|
) { throw 'Pinned Windows Packet Filter trust policy mismatch.' }
|
|
}
|
|
'sing-box' {
|
|
if (
|
|
$policy.type -cne 'githubReleaseDigest' -or
|
|
$policy.repository -cne 'SagerNet/sing-box' -or
|
|
$policy.tagPattern -cne 'v*' -or
|
|
$policy.assetPattern -cne 'sing-box-*-windows-amd64.zip' -or
|
|
$policy.PSObject.Properties.Name -ccontains 'authenticodePublishers'
|
|
) { throw 'Pinned sing-box trust policy mismatch.' }
|
|
}
|
|
'vc-runtime' {
|
|
if (
|
|
$policy.type -cne 'buildTimeOnlyAuthenticode' -or
|
|
@($policy.allowedSourceHosts).Count -ne 1 -or
|
|
@($policy.allowedSourceHosts)[0] -cne 'aka.ms' -or
|
|
$policy.assetPattern -cne 'VC_redist.x64.exe' -or
|
|
@($policy.publishers).Count -ne 1 -or
|
|
@($policy.publishers)[0] -cne 'Microsoft Corporation'
|
|
) { throw 'Pinned VC runtime trust policy mismatch.' }
|
|
}
|
|
'winsw' {
|
|
$expectedReason = 'The official v2.12.0 asset is unsigned and has no independent release digest; runtime network update is disabled.'
|
|
if ($policy.type -cne 'bundledOnlyNoIndependentProof' -or $policy.reason -cne $expectedReason) {
|
|
throw 'Pinned WinSW trust policy mismatch.'
|
|
}
|
|
}
|
|
default { throw "Unknown component id '$($Component.id)'." }
|
|
}
|
|
}
|
|
|
|
function Assert-OfficialSource([object]$Component, [Uri]$SourceUri, [string]$AssetName) {
|
|
$repositories = @{
|
|
'proxifyre' = 'wiresock/proxifyre'
|
|
'windows-packet-filter' = 'wiresock/ndisapi'
|
|
'sing-box' = 'SagerNet/sing-box'
|
|
'winsw' = 'winsw/winsw'
|
|
}
|
|
if ($Component.id -ceq 'vc-runtime') {
|
|
if (@('aka.ms', 'download.visualstudio.microsoft.com') -notcontains $SourceUri.Host.ToLowerInvariant()) {
|
|
throw 'VC runtime source is not an approved Microsoft host.'
|
|
}
|
|
return
|
|
}
|
|
if (-not $repositories.ContainsKey($Component.id) -or $SourceUri.Host -ne 'github.com') {
|
|
throw "Component source is not an official GitHub source for $($Component.id)."
|
|
}
|
|
$segments = @($SourceUri.AbsolutePath.Trim('/').Split('/'))
|
|
if (
|
|
$segments.Count -ne 6 -or
|
|
-not [string]::Equals("$($segments[0])/$($segments[1])", $repositories[$Component.id], [StringComparison]::OrdinalIgnoreCase) -or
|
|
$segments[2] -cne 'releases' -or
|
|
$segments[3] -cne 'download' -or
|
|
$segments[4].TrimStart('v') -cne $Component.version -or
|
|
$segments[5] -cne $AssetName
|
|
) {
|
|
throw "Component source is not its pinned official release for $($Component.id)."
|
|
}
|
|
}
|
|
|
|
function Assert-NoReparseTree([string]$Root) {
|
|
$items = @((Get-Item -LiteralPath $Root -Force)) + @(Get-ChildItem -LiteralPath $Root -Recurse -Force)
|
|
foreach ($item in $items) {
|
|
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
throw "Bundle contains a reparse point: $($item.Name)"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Remove-SafeGeneratedDirectory(
|
|
[string]$Path,
|
|
[string]$ExpectedParent,
|
|
[string]$LeafPattern
|
|
) {
|
|
if (-not (Test-Path -LiteralPath $Path)) {
|
|
return
|
|
}
|
|
$fullPath = [IO.Path]::GetFullPath($Path)
|
|
$fullParent = [IO.Path]::GetFullPath((Split-Path -Parent $fullPath)).TrimEnd('\', '/')
|
|
$expectedFullParent = [IO.Path]::GetFullPath($ExpectedParent).TrimEnd('\', '/')
|
|
$leaf = Split-Path -Leaf $fullPath
|
|
if (
|
|
-not [string]::Equals($fullParent, $expectedFullParent, [StringComparison]::OrdinalIgnoreCase) -or
|
|
$leaf -notmatch $LeafPattern -or
|
|
-not (Test-Path -LiteralPath $fullPath -PathType Container)
|
|
) {
|
|
throw "Refusing to remove an unexpected generated directory: $leaf"
|
|
}
|
|
Assert-NoReparseTree $fullPath
|
|
Remove-Item -LiteralPath $fullPath -Recurse -Force
|
|
}
|
|
|
|
function Test-SafeEmptyDirectory([string]$Path) {
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
|
|
return $false
|
|
}
|
|
Assert-NoReparseTree $Path
|
|
return $null -eq (Get-ChildItem -LiteralPath $Path -Force | Select-Object -First 1)
|
|
}
|
|
|
|
function Remove-SafeEmptyDirectory([string]$Path, [string]$ExpectedPath) {
|
|
$fullPath = [IO.Path]::GetFullPath($Path)
|
|
$expectedFullPath = [IO.Path]::GetFullPath($ExpectedPath)
|
|
if (-not [string]::Equals($fullPath, $expectedFullPath, [StringComparison]::OrdinalIgnoreCase)) {
|
|
throw 'Refusing to remove an unexpected empty directory.'
|
|
}
|
|
if (-not (Test-SafeEmptyDirectory $fullPath)) {
|
|
throw 'Refusing to remove a directory that is not a safe empty placeholder.'
|
|
}
|
|
Remove-Item -LiteralPath $fullPath -Force
|
|
}
|
|
|
|
function Get-RelativeBundlePath([string]$Root, [string]$Path) {
|
|
$prefix = $Root.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
|
|
if (-not $Path.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) {
|
|
throw 'Bundle entry escaped its root.'
|
|
}
|
|
return $Path.Substring($prefix.Length).Replace('\', '/')
|
|
}
|
|
|
|
function Test-ComponentBundle([string]$Root) {
|
|
$resolvedRoot = [IO.Path]::GetFullPath($Root)
|
|
if (-not (Test-Path -LiteralPath $resolvedRoot -PathType Container)) {
|
|
throw "Component bundle does not exist: $resolvedRoot"
|
|
}
|
|
Assert-ValidatedParent (Split-Path -Parent $resolvedRoot)
|
|
Assert-NoReparseTree $resolvedRoot
|
|
|
|
$catalogPath = Join-Path $resolvedRoot 'catalog.json'
|
|
if (-not (Test-Path -LiteralPath $catalogPath -PathType Leaf)) {
|
|
throw 'catalog.json is missing.'
|
|
}
|
|
try {
|
|
$catalog = Get-Content -Raw -LiteralPath $catalogPath | ConvertFrom-Json
|
|
} catch {
|
|
throw "catalog.json is invalid: $($_.Exception.Message)"
|
|
}
|
|
Assert-ExactProperties $catalog @('schemaVersion', 'targetArch', 'components') @() 'catalog'
|
|
$schemaIsInteger = $catalog.schemaVersion -is [Int32] -or $catalog.schemaVersion -is [Int64]
|
|
if (
|
|
-not $schemaIsInteger -or
|
|
[Int64]$catalog.schemaVersion -ne 1 -or
|
|
$catalog.targetArch -isnot [string] -or
|
|
$catalog.targetArch -cne 'x64'
|
|
) {
|
|
throw 'Unsupported component catalog schema or target architecture.'
|
|
}
|
|
|
|
$components = @($catalog.components)
|
|
if ($components.Count -ne $ExpectedComponents.Count) {
|
|
throw 'Catalog must contain exactly five components.'
|
|
}
|
|
$seenIds = @{}
|
|
$seenRoles = @{}
|
|
$seenAssets = @{}
|
|
$seenLicenses = @{}
|
|
$expectedFiles = @{ 'catalog.json' = $true }
|
|
|
|
foreach ($component in $components) {
|
|
Assert-ExactProperties $component @(
|
|
'id', 'version', 'assetPath', 'assetArch', 'effectiveTarget', 'sha256', 'size',
|
|
'sourceUrl', 'license', 'installRole', 'updateTrustPolicy'
|
|
) @('fileVersion', 'productVersion') "component"
|
|
$expected = @($ExpectedComponents | Where-Object { $_.id -ceq $component.id })
|
|
if ($expected.Count -ne 1) {
|
|
throw "Unknown or duplicate component id '$($component.id)'."
|
|
}
|
|
if ($seenIds.ContainsKey($component.id)) {
|
|
throw "Duplicate component id '$($component.id)'."
|
|
}
|
|
$seenIds[$component.id] = $true
|
|
|
|
if (
|
|
$component.version -cne $expected[0].version -or
|
|
$component.installRole -cne $expected[0].installRole -or
|
|
$component.assetArch -cne $expected[0].assetArch -or
|
|
$component.effectiveTarget -cne 'x64'
|
|
) {
|
|
throw "Catalog identity does not match the pinned x64 baseline for $($component.id)."
|
|
}
|
|
if ($seenRoles.ContainsKey($component.installRole)) {
|
|
throw "Duplicate installRole '$($component.installRole)'."
|
|
}
|
|
$seenRoles[$component.installRole] = $true
|
|
if (-not (Test-StableNumericVersion $component.version)) {
|
|
throw "Invalid version for $($component.id)."
|
|
}
|
|
if ($component.PSObject.Properties.Name -ccontains 'fileVersion') {
|
|
if (-not (Test-StableNumericVersion $component.fileVersion)) {
|
|
throw "Invalid fileVersion for $($component.id)."
|
|
}
|
|
}
|
|
if ($component.PSObject.Properties.Name -ccontains 'productVersion') {
|
|
if (-not (Test-StableProductVersion $component.productVersion)) {
|
|
throw "Invalid productVersion for $($component.id)."
|
|
}
|
|
}
|
|
$hasFileVersion = $component.PSObject.Properties.Name -ccontains 'fileVersion'
|
|
$hasProductVersion = $component.PSObject.Properties.Name -ccontains 'productVersion'
|
|
if (
|
|
($null -eq $expected[0].fileVersion -and $hasFileVersion) -or
|
|
($null -ne $expected[0].fileVersion -and (-not $hasFileVersion -or $component.fileVersion -cne $expected[0].fileVersion)) -or
|
|
($null -eq $expected[0].productVersion -and $hasProductVersion) -or
|
|
($null -ne $expected[0].productVersion -and (-not $hasProductVersion -or $component.productVersion -cne $expected[0].productVersion))
|
|
) {
|
|
throw "Version metadata does not match the pinned baseline for $($component.id)."
|
|
}
|
|
|
|
Assert-SafeRelativePath $component.assetPath 'assetPath'
|
|
if (
|
|
$component.assetPath.Split('/')[0] -cne $component.id -or
|
|
$component.assetPath.Split('/')[-1] -cne $expected[0].assetName
|
|
) {
|
|
throw "assetPath must be inside the $($component.id) directory."
|
|
}
|
|
if ($seenAssets.ContainsKey($component.assetPath)) {
|
|
throw "Duplicate asset path '$($component.assetPath)'."
|
|
}
|
|
$seenAssets[$component.assetPath] = $true
|
|
|
|
Assert-ExactProperties $component.license @('id', 'path') @() "license for $($component.id)"
|
|
if (
|
|
$component.license.id -isnot [string] -or
|
|
$component.license.id -cnotmatch '^[A-Za-z0-9.+_-]{1,96}$' -or
|
|
$component.license.id -cne $expected[0].licenseId
|
|
) {
|
|
throw "Invalid license id for $($component.id)."
|
|
}
|
|
Assert-SafeRelativePath $component.license.path 'license.path'
|
|
if (
|
|
$component.license.path.Split('/')[0] -cne $component.id -or
|
|
$component.license.path -cne $expected[0].licensePath -or
|
|
$component.license.path -ceq $component.assetPath
|
|
) {
|
|
throw "license.path must be inside the $($component.id) directory."
|
|
}
|
|
if ($seenLicenses.ContainsKey($component.license.path)) {
|
|
throw "Duplicate license path '$($component.license.path)'."
|
|
}
|
|
$seenLicenses[$component.license.path] = $true
|
|
|
|
if (
|
|
$component.sha256 -isnot [string] -or
|
|
$component.sha256 -cnotmatch '^[0-9a-f]{64}$' -or
|
|
$component.sha256 -cne $expected[0].sha256
|
|
) {
|
|
throw "Invalid SHA-256 for $($component.id)."
|
|
}
|
|
$sizeIsInteger = $component.size -is [Int32] -or $component.size -is [Int64]
|
|
if (
|
|
-not $sizeIsInteger -or
|
|
[Int64]$component.size -le 0 -or
|
|
[Int64]$component.size -ne $expected[0].size
|
|
) {
|
|
throw "Invalid size for $($component.id)."
|
|
}
|
|
$size = [Int64]$component.size
|
|
$sourceUri = Assert-PlainHttpsUrl $component.sourceUrl 'sourceUrl'
|
|
if ($component.sourceUrl -cne $expected[0].sourceUrl) {
|
|
throw "sourceUrl does not match the pinned baseline for $($component.id)."
|
|
}
|
|
$assetName = $component.assetPath.Split('/')[-1]
|
|
if ([Uri]::UnescapeDataString($sourceUri.Segments[-1].Trim('/')) -cne $assetName) {
|
|
throw "sourceUrl filename does not match assetPath for $($component.id)."
|
|
}
|
|
Assert-OfficialSource $component $sourceUri $assetName
|
|
Assert-TrustPolicy $component $sourceUri $assetName
|
|
if ($component.updateTrustPolicy.type -cne $expected[0].policyType) {
|
|
throw "Trust policy type does not match the pinned baseline for $($component.id)."
|
|
}
|
|
Assert-PinnedTrustPolicy $component
|
|
|
|
$assetFullPath = [IO.Path]::GetFullPath((Join-Path $resolvedRoot $component.assetPath.Replace('/', '\')))
|
|
$licenseFullPath = [IO.Path]::GetFullPath((Join-Path $resolvedRoot $component.license.path.Replace('/', '\')))
|
|
if (-not (Test-Path -LiteralPath $assetFullPath -PathType Leaf)) {
|
|
throw "Asset is missing for $($component.id)."
|
|
}
|
|
if (-not (Test-Path -LiteralPath $licenseFullPath -PathType Leaf)) {
|
|
throw "License is missing or empty for $($component.id)."
|
|
}
|
|
$licenseItem = Get-Item -LiteralPath $licenseFullPath
|
|
$licenseHash = (Get-FileHash -LiteralPath $licenseFullPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($licenseItem.Length -ne $expected[0].licenseSize -or $licenseHash -cne $expected[0].licenseSha256) {
|
|
throw "License hash or size mismatch for $($component.id)."
|
|
}
|
|
Assert-LocalLicenseIdentity $component.id $licenseFullPath
|
|
$asset = Get-Item -LiteralPath $assetFullPath
|
|
if ($asset.Length -ne $size) {
|
|
throw "Asset size mismatch for $($component.id)."
|
|
}
|
|
$actualHash = (Get-FileHash -LiteralPath $assetFullPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($actualHash -cne $component.sha256) {
|
|
throw "Asset SHA-256 mismatch for $($component.id)."
|
|
}
|
|
Assert-LocalPackageIdentity $component.id $assetFullPath
|
|
$expectedFiles[$component.assetPath] = $true
|
|
$expectedFiles[$component.license.path] = $true
|
|
}
|
|
|
|
$actualFiles = @{}
|
|
foreach ($file in Get-ChildItem -LiteralPath $resolvedRoot -Recurse -File -Force) {
|
|
$relative = Get-RelativeBundlePath $resolvedRoot $file.FullName
|
|
Assert-SafeRelativePath $relative 'bundle entry'
|
|
$actualFiles[$relative] = $true
|
|
}
|
|
$missing = @($expectedFiles.Keys | Where-Object { -not $actualFiles.ContainsKey($_) })
|
|
$extra = @($actualFiles.Keys | Where-Object { -not $expectedFiles.ContainsKey($_) })
|
|
if ($missing.Count -gt 0 -or $extra.Count -gt 0) {
|
|
throw "Bundle file set mismatch (missing: $($missing.Count), extra: $($extra.Count))."
|
|
}
|
|
return $catalog
|
|
}
|
|
|
|
function Assert-ValidatedParent([string]$ParentPath) {
|
|
if (-not (Test-Path -LiteralPath $ParentPath -PathType Container)) {
|
|
throw "Output parent directory does not exist: $ParentPath"
|
|
}
|
|
$current = Get-Item -LiteralPath $ParentPath -Force
|
|
while ($null -ne $current) {
|
|
if (($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
throw "Output parent contains a reparse point: $($current.FullName)"
|
|
}
|
|
$current = $current.Parent
|
|
}
|
|
}
|
|
|
|
function Get-RetryDelaySeconds([object]$Response, [int]$TransientFailures) {
|
|
$delaySeconds = [Math]::Pow(2, $TransientFailures - 1)
|
|
$retryAfter = $Response.Headers.RetryAfter
|
|
if ($null -ne $retryAfter) {
|
|
if ($null -ne $retryAfter.Delta) {
|
|
$delaySeconds = $retryAfter.Delta.TotalSeconds
|
|
} elseif ($null -ne $retryAfter.Date) {
|
|
$delaySeconds = ($retryAfter.Date.UtcDateTime - [DateTime]::UtcNow).TotalSeconds
|
|
}
|
|
}
|
|
return [Math]::Min(30, [Math]::Max(0, [Math]::Ceiling($delaySeconds)))
|
|
}
|
|
|
|
function Invoke-JsonApi([string]$Uri) {
|
|
$parsed = Assert-PlainHttpsUrl $Uri 'API URL'
|
|
if ($parsed.Host -ne 'api.github.com') {
|
|
throw 'Only the official GitHub API is allowed.'
|
|
}
|
|
|
|
Add-Type -AssemblyName System.Net.Http
|
|
$handler = [Net.Http.HttpClientHandler]::new()
|
|
$handler.AllowAutoRedirect = $false
|
|
$handler.AutomaticDecompression = [Net.DecompressionMethods]::GZip -bor [Net.DecompressionMethods]::Deflate
|
|
$client = [Net.Http.HttpClient]::new($handler)
|
|
$client.Timeout = [Threading.Timeout]::InfiniteTimeSpan
|
|
$deadline = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(60))
|
|
[void]$client.DefaultRequestHeaders.UserAgent.ParseAdd('proxywarden-component-bundle-updater')
|
|
[void]$client.DefaultRequestHeaders.Accept.ParseAdd('application/vnd.github+json')
|
|
[void]$client.DefaultRequestHeaders.Add('X-GitHub-Api-Version', '2022-11-28')
|
|
$response = $null
|
|
try {
|
|
try {
|
|
$transientFailures = 0
|
|
while ($true) {
|
|
$response = $client.GetAsync(
|
|
$parsed,
|
|
[Net.Http.HttpCompletionOption]::ResponseHeadersRead,
|
|
$deadline.Token
|
|
).GetAwaiter().GetResult()
|
|
$statusCode = [int]$response.StatusCode
|
|
if ($statusCode -in @(301, 302, 303, 307, 308)) {
|
|
throw 'GitHub API redirect was rejected for api.github.com.'
|
|
}
|
|
if ($statusCode -eq 408 -or $statusCode -eq 429 -or ($statusCode -ge 500 -and $statusCode -le 599)) {
|
|
$transientFailures++
|
|
if ($transientFailures -ge 3) {
|
|
throw "GitHub API failed with transient HTTP status $statusCode after three attempts at api.github.com."
|
|
}
|
|
$delaySeconds = Get-RetryDelaySeconds $response $transientFailures
|
|
$response.Dispose()
|
|
$response = $null
|
|
[void]([Threading.Tasks.Task]::Delay([TimeSpan]::FromSeconds($delaySeconds), $deadline.Token).GetAwaiter().GetResult())
|
|
continue
|
|
}
|
|
if ($statusCode -lt 200 -or $statusCode -gt 299) {
|
|
throw "GitHub API failed with HTTP status $statusCode at api.github.com."
|
|
}
|
|
$contentLength = $response.Content.Headers.ContentLength
|
|
if ($null -ne $contentLength -and [Int64]$contentLength -gt 1048576) {
|
|
throw 'GitHub API response exceeded 1 MiB at api.github.com.'
|
|
}
|
|
$input = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
|
|
$output = [IO.MemoryStream]::new()
|
|
try {
|
|
$buffer = [byte[]]::new(32768)
|
|
$total = 0L
|
|
while (($count = $input.ReadAsync($buffer, 0, $buffer.Length, $deadline.Token).GetAwaiter().GetResult()) -gt 0) {
|
|
$total += $count
|
|
if ($total -gt 1048576) {
|
|
throw 'GitHub API response exceeded 1 MiB at api.github.com.'
|
|
}
|
|
$output.Write($buffer, 0, $count)
|
|
}
|
|
$body = [Text.UTF8Encoding]::new($false, $true).GetString($output.ToArray()).TrimStart([char]0xfeff)
|
|
} finally {
|
|
$output.Dispose()
|
|
$input.Dispose()
|
|
}
|
|
try {
|
|
return $body | ConvertFrom-Json
|
|
} catch {
|
|
throw 'GitHub API returned invalid JSON from api.github.com.'
|
|
}
|
|
}
|
|
} catch {
|
|
if ($_.Exception.Message.StartsWith('GitHub API ', [StringComparison]::Ordinal)) {
|
|
throw
|
|
}
|
|
throw 'GitHub API request failed for api.github.com.'
|
|
}
|
|
} finally {
|
|
if ($null -ne $response) { $response.Dispose() }
|
|
$deadline.Dispose()
|
|
$client.Dispose()
|
|
$handler.Dispose()
|
|
}
|
|
}
|
|
|
|
function Get-PinnedRelease([string]$Repository, [string]$Tag) {
|
|
$release = Invoke-JsonApi "https://api.github.com/repos/$Repository/releases/tags/$Tag"
|
|
if ($release.tag_name -cne $Tag -or [bool]$release.draft -or [bool]$release.prerelease) {
|
|
throw "GitHub release $Repository/$Tag is not the expected stable release."
|
|
}
|
|
return $release
|
|
}
|
|
|
|
function Get-ReleaseEvidence([string]$Repository, [string]$Tag, [bool]$UseFrozen) {
|
|
if (-not $UseFrozen) {
|
|
return Get-PinnedRelease $Repository $Tag
|
|
}
|
|
|
|
$asset = switch ("$Repository@$Tag") {
|
|
'wiresock/proxifyre@v2.4.0' {
|
|
$releaseId = 356296939L
|
|
$publishedAt = '2026-07-19T08:51:37Z'
|
|
$releaseHtmlUrl = 'https://github.com/wiresock/proxifyre/releases/tag/v2.4.0'
|
|
[PSCustomObject]@{
|
|
id = 482601136L
|
|
url = 'https://api.github.com/repos/wiresock/proxifyre/releases/assets/482601136'
|
|
name = 'ProxiFyre-v2.4.0-x64-signed.zip'
|
|
browser_download_url = 'https://github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip'
|
|
size = 1519694L
|
|
digest = 'sha256:eab65fd7d8eeb716abedb5614618c641de3f9eb8326b99cee1da787141e30cac'
|
|
}
|
|
}
|
|
'wiresock/ndisapi@v3.6.2' {
|
|
$releaseId = 256618257L
|
|
$publishedAt = '2025-10-23T09:12:20Z'
|
|
$releaseHtmlUrl = 'https://github.com/wiresock/ndisapi/releases/tag/v3.6.2'
|
|
[PSCustomObject]@{
|
|
id = 307688568L
|
|
url = 'https://api.github.com/repos/wiresock/ndisapi/releases/assets/307688568'
|
|
name = 'Windows.Packet.Filter.3.6.2.1.x64.msi'
|
|
browser_download_url = 'https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi'
|
|
size = 819200L
|
|
digest = 'sha256:9c388c0b7f189f7fa98720bae2caecf7d64f30910838b80b438ecf8956b8502c'
|
|
}
|
|
}
|
|
'SagerNet/sing-box@v1.13.19' {
|
|
$releaseId = 371636056L
|
|
$publishedAt = '2026-08-17T09:47:06Z'
|
|
$releaseHtmlUrl = 'https://github.com/SagerNet/sing-box/releases/tag/v1.13.19'
|
|
[PSCustomObject]@{
|
|
id = 517910532L
|
|
url = 'https://api.github.com/repos/SagerNet/sing-box/releases/assets/517910532'
|
|
name = 'sing-box-1.13.19-windows-amd64.zip'
|
|
browser_download_url = 'https://github.com/SagerNet/sing-box/releases/download/v1.13.19/sing-box-1.13.19-windows-amd64.zip'
|
|
size = 21046252L
|
|
digest = 'sha256:e011a4def2f5e2b143ed54adb2b1a20a6be407806ab4442f3667f1dd817a2c8d'
|
|
}
|
|
}
|
|
'winsw/winsw@v2.12.0' {
|
|
$releaseId = 90528888L
|
|
$publishedAt = '2023-01-28T16:22:38Z'
|
|
$releaseHtmlUrl = 'https://github.com/winsw/winsw/releases/tag/v2.12.0'
|
|
[PSCustomObject]@{
|
|
id = 93386826L
|
|
url = 'https://api.github.com/repos/winsw/winsw/releases/assets/93386826'
|
|
name = 'WinSW.NET461.exe'
|
|
browser_download_url = 'https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe'
|
|
size = 655872L
|
|
digest = $null
|
|
}
|
|
}
|
|
default { throw 'Frozen release evidence does not cover the requested repository and tag.' }
|
|
}
|
|
return [PSCustomObject]@{
|
|
id = $releaseId
|
|
url = "https://api.github.com/repos/$Repository/releases/$releaseId"
|
|
html_url = $releaseHtmlUrl
|
|
tag_name = $Tag
|
|
draft = $false
|
|
prerelease = $false
|
|
published_at = $publishedAt
|
|
assets = @($asset)
|
|
}
|
|
}
|
|
|
|
function Get-ReleaseAsset([object]$Release, [string]$Name) {
|
|
$matches = @($Release.assets | Where-Object { $_.name -ceq $Name })
|
|
if ($matches.Count -ne 1) {
|
|
throw "Expected exactly one release asset named $Name."
|
|
}
|
|
return $matches[0]
|
|
}
|
|
|
|
function Test-AllowedRedirect([Uri]$InitialUri, [Uri]$NextUri) {
|
|
if (
|
|
$NextUri.Scheme -ne 'https' -or
|
|
-not [string]::IsNullOrEmpty($NextUri.UserInfo) -or
|
|
-not $NextUri.IsDefaultPort -or
|
|
-not [string]::IsNullOrEmpty($NextUri.Fragment)
|
|
) {
|
|
return $false
|
|
}
|
|
$initialHost = $InitialUri.Host.ToLowerInvariant()
|
|
$nextHost = $NextUri.Host.ToLowerInvariant()
|
|
switch ($initialHost) {
|
|
'github.com' { return @('github.com', 'release-assets.githubusercontent.com') -contains $nextHost }
|
|
'release-assets.githubusercontent.com' { return $nextHost -eq 'release-assets.githubusercontent.com' }
|
|
'raw.githubusercontent.com' { return $nextHost -eq 'raw.githubusercontent.com' }
|
|
'aka.ms' { return @('aka.ms', 'download.visualstudio.microsoft.com') -contains $nextHost }
|
|
'download.visualstudio.microsoft.com' { return $nextHost -eq 'download.visualstudio.microsoft.com' }
|
|
'visualstudio.microsoft.com' { return $nextHost -eq 'visualstudio.microsoft.com' }
|
|
default { return $false }
|
|
}
|
|
}
|
|
|
|
function Save-Download([string]$Uri, [string]$Path, [Int64]$MaxBytes) {
|
|
$initialUri = Assert-PlainHttpsUrl $Uri 'Download URL'
|
|
if ($MaxBytes -le 0) {
|
|
throw 'Download size limit must be positive.'
|
|
}
|
|
$parent = Split-Path -Parent $Path
|
|
[void](New-Item -ItemType Directory -Path $parent -Force)
|
|
$partial = "$Path.part"
|
|
Add-Type -AssemblyName System.Net.Http
|
|
$handler = [Net.Http.HttpClientHandler]::new()
|
|
$handler.AllowAutoRedirect = $false
|
|
$handler.AutomaticDecompression = [Net.DecompressionMethods]::GZip -bor [Net.DecompressionMethods]::Deflate
|
|
$client = [Net.Http.HttpClient]::new($handler)
|
|
$client.Timeout = [Threading.Timeout]::InfiniteTimeSpan
|
|
$deadline = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(240))
|
|
[void]$client.DefaultRequestHeaders.UserAgent.ParseAdd('proxywarden-component-bundle-updater')
|
|
[void]$client.DefaultRequestHeaders.Accept.ParseAdd('application/octet-stream,*/*')
|
|
$currentUri = $initialUri
|
|
$response = $null
|
|
try {
|
|
$redirectCount = 0
|
|
$transientFailures = 0
|
|
while ($true) {
|
|
$response = $client.GetAsync(
|
|
$currentUri,
|
|
[Net.Http.HttpCompletionOption]::ResponseHeadersRead,
|
|
$deadline.Token
|
|
).GetAwaiter().GetResult()
|
|
$statusCode = [int]$response.StatusCode
|
|
if ($statusCode -in @(301, 302, 303, 307, 308)) {
|
|
if ($redirectCount -ge 5 -or $null -eq $response.Headers.Location) {
|
|
throw "Download exceeded the redirect limit: $Uri"
|
|
}
|
|
$nextUri = if ($response.Headers.Location.IsAbsoluteUri) {
|
|
$response.Headers.Location
|
|
} else {
|
|
[Uri]::new($currentUri, $response.Headers.Location)
|
|
}
|
|
if (-not (Test-AllowedRedirect $currentUri $nextUri)) {
|
|
throw "Download redirect target is not allowed: $($nextUri.Host)"
|
|
}
|
|
$response.Dispose()
|
|
$response = $null
|
|
$currentUri = $nextUri
|
|
$redirectCount++
|
|
continue
|
|
}
|
|
if ($statusCode -eq 408 -or $statusCode -eq 429 -or ($statusCode -ge 500 -and $statusCode -le 599)) {
|
|
$transientFailures++
|
|
if ($transientFailures -ge 3) {
|
|
throw "Download failed with transient HTTP status $statusCode after three attempts."
|
|
}
|
|
$delaySeconds = Get-RetryDelaySeconds $response $transientFailures
|
|
$response.Dispose()
|
|
$response = $null
|
|
[void]([Threading.Tasks.Task]::Delay([TimeSpan]::FromSeconds($delaySeconds), $deadline.Token).GetAwaiter().GetResult())
|
|
continue
|
|
}
|
|
[void]$response.EnsureSuccessStatusCode()
|
|
$contentLength = $response.Content.Headers.ContentLength
|
|
if ($null -ne $contentLength -and [Int64]$contentLength -gt $MaxBytes) {
|
|
throw "Download exceeds the size limit: $Uri"
|
|
}
|
|
$input = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
|
|
$output = [IO.File]::Open($partial, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
|
try {
|
|
$buffer = [byte[]]::new(65536)
|
|
$total = 0L
|
|
while (($count = $input.ReadAsync($buffer, 0, $buffer.Length, $deadline.Token).GetAwaiter().GetResult()) -gt 0) {
|
|
$total += $count
|
|
if ($total -gt $MaxBytes) {
|
|
throw "Download exceeds the size limit: $Uri"
|
|
}
|
|
$output.Write($buffer, 0, $count)
|
|
if ($script:InjectDownloadFailure) {
|
|
$script:InjectDownloadFailure = $false
|
|
throw 'Simulated bundle download failure after a partial write.'
|
|
}
|
|
}
|
|
} finally {
|
|
$output.Dispose()
|
|
$input.Dispose()
|
|
}
|
|
break
|
|
}
|
|
if (-not (Test-Path -LiteralPath $partial -PathType Leaf) -or (Get-Item -LiteralPath $partial).Length -le 0) {
|
|
throw "Downloaded file is empty: $Uri"
|
|
}
|
|
Move-Item -LiteralPath $partial -Destination $Path
|
|
} finally {
|
|
if ($null -ne $response) { $response.Dispose() }
|
|
$deadline.Dispose()
|
|
$client.Dispose()
|
|
$handler.Dispose()
|
|
Remove-Item -LiteralPath $partial -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
function Save-GitHubDigestAsset(
|
|
[object]$Release,
|
|
[string]$Name,
|
|
[string]$ExpectedUrl,
|
|
[string]$Destination,
|
|
[string]$FrozenHash,
|
|
[Int64]$FrozenSize
|
|
) {
|
|
$asset = Get-ReleaseAsset $Release $Name
|
|
if ($asset.browser_download_url -cne $ExpectedUrl) {
|
|
throw "Official asset URL changed for $Name."
|
|
}
|
|
$digestProperty = $asset.PSObject.Properties['digest']
|
|
if ($null -eq $digestProperty -or $digestProperty.Value -cnotmatch '^sha256:([0-9a-f]{64})$') {
|
|
throw "GitHub did not provide an independent SHA-256 digest for $Name."
|
|
}
|
|
$expectedHash = $Matches[1]
|
|
if ($expectedHash -cne $FrozenHash -or [Int64]$asset.size -ne $FrozenSize) {
|
|
throw "GitHub release identity does not match the frozen baseline for $Name."
|
|
}
|
|
Save-Download $ExpectedUrl $Destination $FrozenSize
|
|
$item = Get-Item -LiteralPath $Destination
|
|
if ($item.Length -ne [Int64]$asset.size) {
|
|
throw "GitHub asset size mismatch for $Name."
|
|
}
|
|
$actualHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($actualHash -cne $expectedHash) {
|
|
throw "GitHub digest mismatch for $Name."
|
|
}
|
|
return [PSCustomObject]@{ hash = $actualHash; size = $item.Length; url = $ExpectedUrl }
|
|
}
|
|
|
|
function Save-PinnedAsset(
|
|
[string]$Uri,
|
|
[string]$Destination,
|
|
[string]$ExpectedHash,
|
|
[Int64]$ExpectedSize
|
|
) {
|
|
Save-Download $Uri $Destination $ExpectedSize
|
|
$item = Get-Item -LiteralPath $Destination
|
|
$actualHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($item.Length -ne $ExpectedSize -or $actualHash -cne $ExpectedHash) {
|
|
throw "Pinned asset identity mismatch for $($item.Name)."
|
|
}
|
|
return [PSCustomObject]@{ hash = $actualHash; size = $item.Length; url = $Uri }
|
|
}
|
|
|
|
function Assert-AuthenticodePublisher([string]$Path, [string]$Publisher) {
|
|
$signature = Get-AuthenticodeSignature -LiteralPath $Path
|
|
if ($signature.Status -ne [Management.Automation.SignatureStatus]::Valid -or $null -eq $signature.SignerCertificate) {
|
|
throw "Authenticode signature is not valid for $(Split-Path -Leaf $Path)."
|
|
}
|
|
$subject = $signature.SignerCertificate.Subject
|
|
$escapedPublisher = [Regex]::Escape($Publisher)
|
|
if (
|
|
-not [Regex]::IsMatch($subject, "(?:^|,\s*)CN=$escapedPublisher(?:,|$)", [Text.RegularExpressions.RegexOptions]::IgnoreCase) -or
|
|
-not [Regex]::IsMatch($subject, "(?:^|,\s*)O=$escapedPublisher(?:,|$)", [Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
|
) {
|
|
throw "Authenticode publisher mismatch for $(Split-Path -Leaf $Path)."
|
|
}
|
|
}
|
|
|
|
function Assert-Unsigned([string]$Path) {
|
|
$signature = Get-AuthenticodeSignature -LiteralPath $Path
|
|
if ($signature.Status.ToString() -ne 'NotSigned') {
|
|
throw "Expected an unsigned pinned asset: $(Split-Path -Leaf $Path)."
|
|
}
|
|
}
|
|
|
|
function Assert-ManagedAnyCpu([string]$Path) {
|
|
$bytes = [IO.File]::ReadAllBytes($Path)
|
|
if ($bytes.Length -lt 256 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) {
|
|
throw 'WinSW is not a valid PE file.'
|
|
}
|
|
$peOffset = [BitConverter]::ToInt32($bytes, 0x3c)
|
|
if (
|
|
$peOffset -lt 0 -or $peOffset + 256 -gt $bytes.Length -or
|
|
[BitConverter]::ToUInt32($bytes, $peOffset) -ne 0x00004550 -or
|
|
[BitConverter]::ToUInt16($bytes, $peOffset + 4) -ne 0x014c
|
|
) {
|
|
throw 'WinSW has an invalid PE header.'
|
|
}
|
|
$sectionCount = [BitConverter]::ToUInt16($bytes, $peOffset + 6)
|
|
$optionalSize = [BitConverter]::ToUInt16($bytes, $peOffset + 20)
|
|
$optionalOffset = $peOffset + 24
|
|
if ([BitConverter]::ToUInt16($bytes, $optionalOffset) -ne 0x010b) {
|
|
throw 'WinSW must use the audited PE32 AnyCPU layout.'
|
|
}
|
|
$clrDirectoryOffset = $optionalOffset + 96 + (14 * 8)
|
|
if ($clrDirectoryOffset + 8 -gt $optionalOffset + $optionalSize) {
|
|
throw 'WinSW PE header has no CLR directory.'
|
|
}
|
|
$clrRva = [BitConverter]::ToUInt32($bytes, $clrDirectoryOffset)
|
|
if ($clrRva -eq 0) {
|
|
throw 'WinSW is not a managed assembly.'
|
|
}
|
|
$sectionOffset = $optionalOffset + $optionalSize
|
|
$clrFileOffset = $null
|
|
for ($index = 0; $index -lt $sectionCount; $index++) {
|
|
$offset = $sectionOffset + ($index * 40)
|
|
if ($offset + 40 -gt $bytes.Length) { throw 'WinSW PE section table is truncated.' }
|
|
$virtualSize = [BitConverter]::ToUInt32($bytes, $offset + 8)
|
|
$virtualAddress = [BitConverter]::ToUInt32($bytes, $offset + 12)
|
|
$rawSize = [BitConverter]::ToUInt32($bytes, $offset + 16)
|
|
$rawOffset = [BitConverter]::ToUInt32($bytes, $offset + 20)
|
|
$mappedSize = [Math]::Max([UInt64]$virtualSize, [UInt64]$rawSize)
|
|
if ([UInt64]$clrRva -ge [UInt64]$virtualAddress -and [UInt64]$clrRva -lt ([UInt64]$virtualAddress + $mappedSize)) {
|
|
$clrFileOffset = [Int64]$rawOffset + ([Int64]$clrRva - [Int64]$virtualAddress)
|
|
break
|
|
}
|
|
}
|
|
if ($null -eq $clrFileOffset -or $clrFileOffset + 20 -gt $bytes.Length) {
|
|
throw 'WinSW CLR header is outside the PE sections.'
|
|
}
|
|
$flags = [BitConverter]::ToUInt32($bytes, [int]$clrFileOffset + 16)
|
|
$ilOnly = ($flags -band 0x00000001) -ne 0
|
|
$requires32Bit = ($flags -band 0x00000002) -ne 0
|
|
$prefers32Bit = ($flags -band 0x00020000) -ne 0
|
|
if (-not $ilOnly -or $requires32Bit -or $prefers32Bit) {
|
|
throw 'WinSW must be ILOnly AnyCPU without 32-bit preference flags.'
|
|
}
|
|
$metadataText = [Text.Encoding]::UTF8.GetString($bytes)
|
|
if ($metadataText.IndexOf('.NETFramework,Version=v4.6.1', [StringComparison]::Ordinal) -lt 0) {
|
|
throw 'WinSW must target the audited .NET Framework 4.6.1 runtime.'
|
|
}
|
|
}
|
|
|
|
function Assert-PeBytesMachineX64([byte[]]$Bytes, [string]$Label) {
|
|
$bytes = $Bytes
|
|
if ($bytes.Length -lt 128 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) {
|
|
throw "File is not a valid PE image: $Label"
|
|
}
|
|
$peOffset = [BitConverter]::ToInt32($bytes, 0x3c)
|
|
if (
|
|
$peOffset -lt 0 -or $peOffset + 26 -gt $bytes.Length -or
|
|
[BitConverter]::ToUInt32($bytes, $peOffset) -ne 0x00004550 -or
|
|
[BitConverter]::ToUInt16($bytes, $peOffset + 4) -ne 0x8664
|
|
) {
|
|
throw "PE image is not x64: $Label"
|
|
}
|
|
}
|
|
|
|
function Assert-PeMachineX64([string]$Path) {
|
|
Assert-PeBytesMachineX64 ([IO.File]::ReadAllBytes($Path)) (Split-Path -Leaf $Path)
|
|
}
|
|
|
|
function Get-ZipEntryBytes([string]$Path, [string]$LeafName) {
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
$archive = [IO.Compression.ZipFile]::OpenRead($Path)
|
|
try {
|
|
$matches = @($archive.Entries | Where-Object { $_.Name -ceq $LeafName })
|
|
if ($matches.Count -ne 1) {
|
|
throw "Archive must contain exactly one $LeafName."
|
|
}
|
|
$input = $matches[0].Open()
|
|
$output = [IO.MemoryStream]::new()
|
|
try {
|
|
$input.CopyTo($output)
|
|
return ,$output.ToArray()
|
|
} finally {
|
|
$output.Dispose()
|
|
$input.Dispose()
|
|
}
|
|
} finally {
|
|
$archive.Dispose()
|
|
}
|
|
}
|
|
|
|
function Get-ZipFullEntryBytes([string]$Path, [string]$FullName, [Int64]$MaxBytes) {
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
$archive = [IO.Compression.ZipFile]::OpenRead($Path)
|
|
try {
|
|
$matches = @($archive.Entries | Where-Object { $_.FullName.Replace('\', '/') -ceq $FullName })
|
|
if ($matches.Count -ne 1 -or $matches[0].Length -le 0 -or $matches[0].Length -gt $MaxBytes) {
|
|
throw "Document archive entry is missing or too large: $FullName"
|
|
}
|
|
$input = $matches[0].Open()
|
|
$output = [IO.MemoryStream]::new()
|
|
try {
|
|
$input.CopyTo($output)
|
|
return ,$output.ToArray()
|
|
} finally {
|
|
$output.Dispose()
|
|
$input.Dispose()
|
|
}
|
|
} finally {
|
|
$archive.Dispose()
|
|
}
|
|
}
|
|
|
|
function Invoke-PinnedGit(
|
|
[string[]]$Arguments,
|
|
[string]$EmptyConfigPath,
|
|
[string]$Operation
|
|
) {
|
|
$git = Get-Command git.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1
|
|
$environmentValues = @{
|
|
GIT_CONFIG_GLOBAL = $EmptyConfigPath
|
|
GIT_CONFIG_SYSTEM = $EmptyConfigPath
|
|
GIT_CONFIG_NOSYSTEM = '1'
|
|
GIT_CONFIG_COUNT = '0'
|
|
GIT_TERMINAL_PROMPT = '0'
|
|
GCM_INTERACTIVE = 'Never'
|
|
GIT_LFS_SKIP_SMUDGE = '1'
|
|
GIT_PROTOCOL_FROM_USER = '0'
|
|
}
|
|
$previous = @{}
|
|
foreach ($name in $environmentValues.Keys) {
|
|
$previous[$name] = [PSCustomObject]@{
|
|
exists = Test-Path -LiteralPath "Env:$name"
|
|
value = [Environment]::GetEnvironmentVariable($name, 'Process')
|
|
}
|
|
[Environment]::SetEnvironmentVariable($name, $environmentValues[$name], 'Process')
|
|
}
|
|
$previousPreference = $ErrorActionPreference
|
|
try {
|
|
$ErrorActionPreference = 'Continue'
|
|
$output = @(& $git.Source @Arguments 2>&1)
|
|
$exitCode = $LASTEXITCODE
|
|
if ($exitCode -ne 0) {
|
|
throw "Pinned Git license acquisition failed during $Operation."
|
|
}
|
|
return @($output | ForEach-Object { $_.ToString() })
|
|
} finally {
|
|
$ErrorActionPreference = $previousPreference
|
|
foreach ($name in $environmentValues.Keys) {
|
|
if ($previous[$name].exists) {
|
|
[Environment]::SetEnvironmentVariable($name, $previous[$name].value, 'Process')
|
|
} else {
|
|
[Environment]::SetEnvironmentVariable($name, $null, 'Process')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function Save-LicenseFromPinnedGit(
|
|
[string]$RepositoryUrl,
|
|
[string]$RepositoryKey,
|
|
[string]$Tag,
|
|
[string]$TagObject,
|
|
[string]$Commit,
|
|
[string]$LicenseName,
|
|
[string]$Destination,
|
|
[string]$WorkRoot
|
|
) {
|
|
$identity = "$RepositoryUrl|$RepositoryKey|$Tag|$TagObject|$Commit|$LicenseName"
|
|
$allowed = @(
|
|
'https://github.com/wiresock/proxifyre.git|proxifyre|v2.4.0|dd1512840e1e3bc596b06b80eda4e2dcd6a9c9ed|dd1512840e1e3bc596b06b80eda4e2dcd6a9c9ed|LICENSE',
|
|
'https://github.com/wiresock/ndisapi.git|ndisapi|v3.6.2|417b8734e844083a10236387fba705d94a2d6bc9|417b8734e844083a10236387fba705d94a2d6bc9|LICENSE',
|
|
'https://github.com/SagerNet/sing-box.git|sing-box|v1.13.19|b5ebaa1fc0f2b94256180b95468e73ef53caa27d|b5ebaa1fc0f2b94256180b95468e73ef53caa27d|LICENSE',
|
|
'https://github.com/winsw/winsw.git|winsw|v2.12.0|eef5bade59fca0254e387ac73ed7625ba6aa7147|eef5bade59fca0254e387ac73ed7625ba6aa7147|LICENSE.txt'
|
|
)
|
|
if ($allowed -cnotcontains $identity) {
|
|
throw 'Pinned Git license identity is not allowlisted.'
|
|
}
|
|
Assert-NoReparseTree $WorkRoot
|
|
$emptyConfigPath = Join-Path $WorkRoot 'empty.gitconfig'
|
|
if (-not (Test-Path -LiteralPath $emptyConfigPath)) {
|
|
[IO.File]::WriteAllText($emptyConfigPath, '', [Text.UTF8Encoding]::new($false))
|
|
}
|
|
$repoPath = Join-Path $WorkRoot "$RepositoryKey-repo"
|
|
$archivePath = Join-Path $WorkRoot "$RepositoryKey-license.zip"
|
|
if ((Test-Path -LiteralPath $repoPath) -or (Test-Path -LiteralPath $archivePath)) {
|
|
throw 'Pinned Git work path already exists.'
|
|
}
|
|
|
|
$remoteTag = @(Invoke-PinnedGit @(
|
|
'ls-remote', '--refs', '--', $RepositoryUrl, "refs/tags/$Tag"
|
|
) $emptyConfigPath "verify remote $RepositoryKey tag")
|
|
$remoteTagLines = @($remoteTag | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
|
$expectedRemoteTag = "$TagObject`trefs/tags/$Tag"
|
|
if ($remoteTagLines.Count -ne 1 -or $remoteTagLines[0].TrimEnd() -cne $expectedRemoteTag) {
|
|
throw "Pinned Git remote tag object mismatch for $RepositoryKey."
|
|
}
|
|
|
|
[void](Invoke-PinnedGit @(
|
|
'-c', 'init.templateDir=', 'init', '--quiet', $repoPath
|
|
) $emptyConfigPath "initialize $RepositoryKey")
|
|
[void](Invoke-PinnedGit @(
|
|
'-C', $repoPath, '-c', 'core.hooksPath=NUL', 'remote', 'add', 'origin', $RepositoryUrl
|
|
) $emptyConfigPath "configure $RepositoryKey origin")
|
|
[void](Invoke-PinnedGit @(
|
|
'-C', $repoPath, '-c', 'core.hooksPath=NUL', '-c', 'protocol.file.allow=never',
|
|
'-c', 'http.sslBackend=schannel', 'fetch', '--quiet', '--depth', '1',
|
|
'--no-tags', 'origin', "+refs/tags/$Tag`:refs/tags/$Tag"
|
|
) $emptyConfigPath "fetch exact $RepositoryKey tag")
|
|
Assert-NoReparseTree $repoPath
|
|
$localTag = @(Invoke-PinnedGit @('-C', $repoPath, 'rev-parse', '--verify', "refs/tags/$Tag") $emptyConfigPath "verify fetched $RepositoryKey tag")
|
|
$localTagValue = (@($localTag | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join '').Trim()
|
|
if ($localTagValue -cne $TagObject) {
|
|
throw "Pinned Git fetched tag object mismatch for $RepositoryKey."
|
|
}
|
|
$head = @(Invoke-PinnedGit @('-C', $repoPath, 'rev-parse', '--verify', "refs/tags/$Tag`^{commit}") $emptyConfigPath "verify $RepositoryKey commit")
|
|
$headValue = (@($head | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join '').Trim()
|
|
if ($headValue -cne $Commit) {
|
|
throw "Pinned Git commit mismatch for $RepositoryKey."
|
|
}
|
|
[void](Invoke-PinnedGit @(
|
|
'-C', $repoPath, '-c', 'core.hooksPath=NUL', 'archive', '--format=zip',
|
|
"--output=$archivePath", $Commit, '--', $LicenseName
|
|
) $emptyConfigPath "archive $RepositoryKey license")
|
|
|
|
if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) {
|
|
throw "Pinned Git license archive is missing for $RepositoryKey."
|
|
}
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
$archive = [IO.Compression.ZipFile]::OpenRead($archivePath)
|
|
try {
|
|
if ($archive.Entries.Count -ne 1) {
|
|
throw "Pinned Git archive must contain exactly one license for $RepositoryKey."
|
|
}
|
|
$entry = $archive.Entries[0]
|
|
$unixFileType = (($entry.ExternalAttributes -shr 16) -band 0xf000)
|
|
if (
|
|
$entry.FullName -cne $LicenseName -or
|
|
[string]::IsNullOrEmpty($entry.Name) -or
|
|
$entry.Length -le 0 -or
|
|
$entry.Length -gt 1048576 -or
|
|
($unixFileType -ne 0 -and $unixFileType -ne 0x8000)
|
|
) {
|
|
throw "Pinned Git archive has an invalid license entry for $RepositoryKey."
|
|
}
|
|
$input = $entry.Open()
|
|
$output = [IO.File]::Open($Destination, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
|
try {
|
|
$buffer = [byte[]]::new(32768)
|
|
$total = 0L
|
|
while (($count = $input.Read($buffer, 0, $buffer.Length)) -gt 0) {
|
|
$total += $count
|
|
if ($total -gt 1048576) { throw 'Pinned Git license exceeds the size limit.' }
|
|
$output.Write($buffer, 0, $count)
|
|
}
|
|
} finally {
|
|
$output.Dispose()
|
|
$input.Dispose()
|
|
}
|
|
} finally {
|
|
$archive.Dispose()
|
|
}
|
|
}
|
|
|
|
function Assert-LocalLicenseIdentity([string]$ComponentId, [string]$Path) {
|
|
if ($ComponentId -ceq 'vc-runtime') {
|
|
$stream = $null
|
|
$reader = $null
|
|
try {
|
|
$settings = [Xml.XmlReaderSettings]::new()
|
|
$settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit
|
|
$settings.XmlResolver = $null
|
|
$stream = [IO.MemoryStream]::new((Get-ZipFullEntryBytes $Path 'word/document.xml' 2097152), $false)
|
|
$reader = [Xml.XmlReader]::Create($stream, $settings)
|
|
$document = [Xml.XmlDocument]::new()
|
|
$document.XmlResolver = $null
|
|
$document.Load($reader)
|
|
} catch {
|
|
throw "VC runtime license is not the expected official DOCX: $($_.Exception.Message)"
|
|
} finally {
|
|
if ($null -ne $reader) { $reader.Dispose() }
|
|
if ($null -ne $stream) { $stream.Dispose() }
|
|
}
|
|
if ($null -eq $document.DocumentElement) {
|
|
throw 'VC runtime license DOCX has no document element.'
|
|
}
|
|
$text = [string]$document.DocumentElement.InnerText
|
|
if ([string]::IsNullOrWhiteSpace($text)) {
|
|
throw 'VC runtime license DOCX has no readable text.'
|
|
}
|
|
foreach ($marker in @('Visual C++', 'Redistributable', 'Runtime')) {
|
|
if ($text.IndexOf($marker, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
|
|
throw "VC runtime license is missing the expected '$marker' marker."
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
$content = Get-Content -Raw -LiteralPath $Path
|
|
$identityContent = [Regex]::Replace($content, '\s+', ' ')
|
|
switch -CaseSensitive ($ComponentId) {
|
|
'proxifyre' { $markers = @('GNU AFFERO GENERAL PUBLIC LICENSE') }
|
|
'windows-packet-filter' { $markers = @('MIT License') }
|
|
'sing-box' {
|
|
$markers = @(
|
|
'GNU GENERAL PUBLIC LICENSE',
|
|
'In addition, no derivative work may use the name or imply association with this application without prior consent.'
|
|
)
|
|
}
|
|
'winsw' { $markers = @('MIT License') }
|
|
default { throw "Unknown license identity: $ComponentId" }
|
|
}
|
|
foreach ($marker in $markers) {
|
|
if ($identityContent.IndexOf($marker, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
|
|
throw "License identity mismatch for $ComponentId."
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-MsiSummaryTemplate([string]$Path) {
|
|
$installer = $null
|
|
$database = $null
|
|
$summary = $null
|
|
try {
|
|
$installer = New-Object -ComObject WindowsInstaller.Installer
|
|
$database = $installer.OpenDatabase($Path, 0)
|
|
$summary = $database.SummaryInformation(0)
|
|
return [string]$summary.Property(7)
|
|
} finally {
|
|
foreach ($value in @($summary, $database, $installer)) {
|
|
if ($null -ne $value -and [Runtime.InteropServices.Marshal]::IsComObject($value)) {
|
|
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-LocalPackageIdentity([string]$ComponentId, [string]$Path) {
|
|
switch ($ComponentId) {
|
|
'proxifyre' {
|
|
Assert-ZipEntries $Path @('ProxiFyre.exe', 'socksify.dll')
|
|
Assert-PeBytesMachineX64 (Get-ZipEntryBytes $Path 'ProxiFyre.exe') 'ProxiFyre.exe'
|
|
Assert-PeBytesMachineX64 (Get-ZipEntryBytes $Path 'socksify.dll') 'socksify.dll'
|
|
}
|
|
'windows-packet-filter' {
|
|
if (
|
|
(Get-MsiProperty $Path 'ProductVersion') -cne '3.6.2.1' -or
|
|
(Get-MsiProperty $Path 'ProductName') -cne 'Windows Packet Filter x64' -or
|
|
(Get-MsiProperty $Path 'Manufacturer') -cne 'NT KERNEL' -or
|
|
(Get-MsiSummaryTemplate $Path) -cnotmatch '^x64;'
|
|
) { throw 'Windows Packet Filter MSI local identity mismatch.' }
|
|
}
|
|
'vc-runtime' {
|
|
Assert-FileVersion $Path '14.51.36247.0' '14.51.36247.0' 'Microsoft Visual C++ v14 Redistributable (x64) - 14.51.36247'
|
|
}
|
|
'sing-box' {
|
|
Assert-ZipEntries $Path @('sing-box.exe')
|
|
Assert-PeBytesMachineX64 (Get-ZipEntryBytes $Path 'sing-box.exe') 'sing-box.exe'
|
|
}
|
|
'winsw' {
|
|
Assert-ManagedAnyCpu $Path
|
|
Assert-FileVersion $Path '2.12.0.0' '2.12.0+eef5bade59fca0254e387ac73ed7625ba6aa7147'
|
|
}
|
|
default { throw "Unknown local package identity: $ComponentId" }
|
|
}
|
|
}
|
|
|
|
function Assert-ZipEntries(
|
|
[string]$Path,
|
|
[string[]]$RequiredLeafNames,
|
|
[Int64]$MaxExpandedBytes = 536870912
|
|
) {
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
$archive = [IO.Compression.ZipFile]::OpenRead($Path)
|
|
try {
|
|
$leafNames = @()
|
|
$seenNames = @{}
|
|
$expandedBytes = 0L
|
|
if ($archive.Entries.Count -gt 10000) {
|
|
throw 'Archive contains too many entries.'
|
|
}
|
|
foreach ($entry in $archive.Entries) {
|
|
$name = $entry.FullName.Replace('\', '/')
|
|
$trimmedName = $name.TrimEnd('/')
|
|
$segments = @($trimmedName.Split('/'))
|
|
if (
|
|
[string]::IsNullOrEmpty($trimmedName) -or
|
|
$name.StartsWith('/') -or
|
|
$name.Contains(':') -or
|
|
@($segments | Where-Object {
|
|
$_.Length -eq 0 -or $_ -in @('.', '..') -or $_.Length -gt 128 -or
|
|
$_.EndsWith('.') -or $_.EndsWith(' ') -or (Test-WindowsReservedName $_) -or
|
|
$_ -notmatch '^[A-Za-z0-9._+ -]+$'
|
|
}).Count -gt 0
|
|
) {
|
|
throw "Archive contains an unsafe entry: $name"
|
|
}
|
|
$normalized = $trimmedName.ToLowerInvariant()
|
|
if ($seenNames.ContainsKey($normalized)) {
|
|
throw "Archive contains a duplicate entry: $name"
|
|
}
|
|
$seenNames[$normalized] = $true
|
|
if ($entry.Length -lt 0 -or $expandedBytes -gt ($MaxExpandedBytes - $entry.Length)) {
|
|
throw 'Archive exceeds the expanded size limit.'
|
|
}
|
|
$expandedBytes += $entry.Length
|
|
if (-not [string]::IsNullOrEmpty($entry.Name)) {
|
|
$leafNames += $entry.Name
|
|
}
|
|
}
|
|
foreach ($required in $RequiredLeafNames) {
|
|
if (@($leafNames | Where-Object { $_ -ceq $required }).Count -ne 1) {
|
|
throw "Archive must contain exactly one $required."
|
|
}
|
|
}
|
|
} finally {
|
|
$archive.Dispose()
|
|
}
|
|
}
|
|
|
|
function Get-UniqueFile([string]$Root, [string]$Name) {
|
|
$matches = @(Get-ChildItem -LiteralPath $Root -Recurse -File | Where-Object { $_.Name -ceq $Name })
|
|
if ($matches.Count -ne 1) {
|
|
throw "Expected exactly one $Name in the archive."
|
|
}
|
|
return $matches[0].FullName
|
|
}
|
|
|
|
function Get-MsiProperty([string]$Path, [string]$Name) {
|
|
$installer = $null
|
|
$database = $null
|
|
$view = $null
|
|
$record = $null
|
|
try {
|
|
$installer = New-Object -ComObject WindowsInstaller.Installer
|
|
$database = $installer.OpenDatabase($Path, 0)
|
|
$query = "SELECT ``Value`` FROM ``Property`` WHERE ``Property``='$Name'"
|
|
$view = $database.OpenView($query)
|
|
$view.Execute()
|
|
$record = $view.Fetch()
|
|
if ($null -eq $record) {
|
|
throw "MSI property is missing: $Name"
|
|
}
|
|
return [string]$record.StringData(1)
|
|
} finally {
|
|
foreach ($value in @($record, $view, $database, $installer)) {
|
|
if ($null -ne $value -and [Runtime.InteropServices.Marshal]::IsComObject($value)) {
|
|
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-FileVersion(
|
|
[string]$Path,
|
|
[string]$FileVersion,
|
|
[string]$ProductVersion,
|
|
[string]$ProductName = '',
|
|
[string]$CompanyName = ''
|
|
) {
|
|
$info = [Diagnostics.FileVersionInfo]::GetVersionInfo($Path)
|
|
if ($info.FileVersion.Trim() -cne $FileVersion -or $info.ProductVersion.Trim() -cne $ProductVersion) {
|
|
throw "Version metadata mismatch for $(Split-Path -Leaf $Path)."
|
|
}
|
|
if (-not [string]::IsNullOrEmpty($ProductName) -and $info.ProductName.Trim() -cne $ProductName) {
|
|
throw "Product name mismatch for $(Split-Path -Leaf $Path)."
|
|
}
|
|
if (-not [string]::IsNullOrEmpty($CompanyName) -and $info.CompanyName.Trim() -cne $CompanyName) {
|
|
throw "Company name mismatch for $(Split-Path -Leaf $Path)."
|
|
}
|
|
}
|
|
|
|
function Write-DeterministicJson([object]$Value, [string]$Path) {
|
|
$json = ($Value | ConvertTo-Json -Depth 20).Replace("`r`n", "`n") + "`n"
|
|
[IO.File]::WriteAllText($Path, $json, [Text.UTF8Encoding]::new($false))
|
|
}
|
|
|
|
function Test-DirectoryContentEqual([string]$First, [string]$Second) {
|
|
if (-not (Test-Path -LiteralPath $First -PathType Container) -or -not (Test-Path -LiteralPath $Second -PathType Container)) {
|
|
return $false
|
|
}
|
|
try {
|
|
[void](Test-ComponentBundle $First)
|
|
[void](Test-ComponentBundle $Second)
|
|
} catch {
|
|
return $false
|
|
}
|
|
$firstFiles = @{}
|
|
foreach ($file in Get-ChildItem -LiteralPath $First -Recurse -File) {
|
|
$relative = Get-RelativeBundlePath ([IO.Path]::GetFullPath($First)) $file.FullName
|
|
$firstFiles[$relative] = "{0}:{1}" -f $file.Length, (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
|
|
}
|
|
$secondFiles = @{}
|
|
foreach ($file in Get-ChildItem -LiteralPath $Second -Recurse -File) {
|
|
$relative = Get-RelativeBundlePath ([IO.Path]::GetFullPath($Second)) $file.FullName
|
|
$secondFiles[$relative] = "{0}:{1}" -f $file.Length, (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
|
|
}
|
|
if ($firstFiles.Count -ne $secondFiles.Count) {
|
|
return $false
|
|
}
|
|
foreach ($name in $firstFiles.Keys) {
|
|
if (-not $secondFiles.ContainsKey($name) -or $firstFiles[$name] -ne $secondFiles[$name]) {
|
|
return $false
|
|
}
|
|
}
|
|
return $true
|
|
}
|
|
|
|
if ($PlanOnly -and $CheckOnly) {
|
|
throw '-PlanOnly and -CheckOnly are mutually exclusive.'
|
|
}
|
|
if ($CheckOnly -and $UseFrozenReleaseEvidence) {
|
|
throw '-UseFrozenReleaseEvidence is not applicable to local-only CheckOnly validation.'
|
|
}
|
|
if (($PlanOnly -or $CheckOnly) -and $SimulateFailure -ne 'None') {
|
|
throw '-SimulateFailure is only available for the update path.'
|
|
}
|
|
|
|
$resolvedOutputDir = [IO.Path]::GetFullPath($OutputDir)
|
|
if ($resolvedOutputDir -eq [IO.Path]::GetPathRoot($resolvedOutputDir)) {
|
|
throw 'OutputDir must not be a filesystem root.'
|
|
}
|
|
$canonicalOutputDir = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\src-tauri\bundled\components'))
|
|
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\', '/')
|
|
$outputLeaf = Split-Path -Leaf $resolvedOutputDir
|
|
$isCanonicalOutput = [string]::Equals($resolvedOutputDir, $canonicalOutputDir, [StringComparison]::OrdinalIgnoreCase)
|
|
$isTestOutput = (
|
|
[string]::Equals((Split-Path -Parent $resolvedOutputDir).TrimEnd('\', '/'), $tempRoot, [StringComparison]::OrdinalIgnoreCase) -and
|
|
$outputLeaf -match '^proxywarden-component-bundle-test-[0-9a-f]{32}$'
|
|
)
|
|
if (-not $isCanonicalOutput -and -not $isTestOutput) {
|
|
throw 'OutputDir must be the canonical bundle or an isolated ProxyWarden test directory under the system temp root.'
|
|
}
|
|
if ($SimulateFailure -ne 'None' -and -not $isTestOutput) {
|
|
throw '-SimulateFailure is allowed only with an isolated test OutputDir.'
|
|
}
|
|
$releaseEvidenceLabel = if ($UseFrozenReleaseEvidence) { 'frozen-audited-2026-08-17' } else { 'live-official-api' }
|
|
|
|
if ($PlanOnly) {
|
|
ConvertTo-ResultJson ([ordered]@{
|
|
mode = 'plan'
|
|
changed = $false
|
|
network = $false
|
|
writes = $false
|
|
releaseEvidence = $releaseEvidenceLabel
|
|
schemaVersion = 1
|
|
targetArch = 'x64'
|
|
outputDir = $resolvedOutputDir
|
|
components = @($ExpectedComponents | ForEach-Object { [ordered]@{ id = $_.id; version = $_.version } })
|
|
})
|
|
return
|
|
}
|
|
|
|
if ($CheckOnly) {
|
|
$catalog = Test-ComponentBundle $resolvedOutputDir
|
|
ConvertTo-ResultJson ([ordered]@{
|
|
mode = 'check'
|
|
changed = $false
|
|
network = $false
|
|
writes = $false
|
|
releaseEvidence = 'local-bundle-only'
|
|
valid = $true
|
|
schemaVersion = $catalog.schemaVersion
|
|
targetArch = $catalog.targetArch
|
|
outputDir = $resolvedOutputDir
|
|
componentCount = @($catalog.components).Count
|
|
})
|
|
return
|
|
}
|
|
|
|
$outputParent = Split-Path -Parent $resolvedOutputDir
|
|
Assert-ValidatedParent $outputParent
|
|
if (Test-Path -LiteralPath $resolvedOutputDir) {
|
|
if (-not (Test-Path -LiteralPath $resolvedOutputDir -PathType Container)) {
|
|
throw 'OutputDir exists but is not a directory.'
|
|
}
|
|
Assert-NoReparseTree $resolvedOutputDir
|
|
}
|
|
|
|
$operationId = [Guid]::NewGuid().ToString('N')
|
|
$stagingDir = Join-Path $outputParent ".proxywarden-components-staging-$operationId"
|
|
$backupDir = "$resolvedOutputDir.previous"
|
|
$retiredBackupDir = Join-Path $outputParent ".proxywarden-components-previous-$operationId"
|
|
$lockPath = "$resolvedOutputDir.update.lock"
|
|
$lock = $null
|
|
$lockOwned = $false
|
|
$activeWasEmptyPlaceholder = $false
|
|
$backupWasEmptyPlaceholder = $false
|
|
$activeMoved = $false
|
|
$oldBackupMoved = $false
|
|
$stagingMoved = $false
|
|
$promoted = $false
|
|
$preserveRecoveryArtifacts = $false
|
|
$script:InjectDownloadFailure = ($SimulateFailure -eq 'Download')
|
|
|
|
try {
|
|
$lock = [IO.File]::Open($lockPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
|
|
$lockOwned = $true
|
|
if (Test-Path -LiteralPath $resolvedOutputDir) {
|
|
if (Test-SafeEmptyDirectory $resolvedOutputDir) {
|
|
$activeWasEmptyPlaceholder = $true
|
|
} else {
|
|
[void](Test-ComponentBundle $resolvedOutputDir)
|
|
}
|
|
}
|
|
if (Test-Path -LiteralPath $backupDir) {
|
|
if (-not (Test-Path -LiteralPath $backupDir -PathType Container)) {
|
|
throw 'The previous bundle backup is not a directory.'
|
|
}
|
|
if (Test-SafeEmptyDirectory $backupDir) {
|
|
$backupWasEmptyPlaceholder = $true
|
|
} else {
|
|
[void](Test-ComponentBundle $backupDir)
|
|
}
|
|
}
|
|
[void](New-Item -ItemType Directory -Path $stagingDir)
|
|
|
|
$proxifyreName = 'ProxiFyre-v2.4.0-x64-signed.zip'
|
|
$proxifyreUrl = "https://github.com/wiresock/proxifyre/releases/download/v2.4.0/$proxifyreName"
|
|
$proxifyrePath = Join-Path $stagingDir "proxifyre\$proxifyreName"
|
|
$proxifyreRelease = Get-ReleaseEvidence 'wiresock/proxifyre' 'v2.4.0' ([bool]$UseFrozenReleaseEvidence)
|
|
$proxifyreAsset = Save-GitHubDigestAsset $proxifyreRelease $proxifyreName $proxifyreUrl $proxifyrePath 'eab65fd7d8eeb716abedb5614618c641de3f9eb8326b99cee1da787141e30cac' 1519694
|
|
|
|
$packetFilterName = 'Windows.Packet.Filter.3.6.2.1.x64.msi'
|
|
$packetFilterUrl = "https://github.com/wiresock/ndisapi/releases/download/v3.6.2/$packetFilterName"
|
|
$packetFilterPath = Join-Path $stagingDir "windows-packet-filter\$packetFilterName"
|
|
$packetFilterRelease = Get-ReleaseEvidence 'wiresock/ndisapi' 'v3.6.2' ([bool]$UseFrozenReleaseEvidence)
|
|
$packetFilterAsset = Save-GitHubDigestAsset $packetFilterRelease $packetFilterName $packetFilterUrl $packetFilterPath '9c388c0b7f189f7fa98720bae2caecf7d64f30910838b80b438ecf8956b8502c' 819200
|
|
|
|
$vcName = 'VC_redist.x64.exe'
|
|
$vcUrl = 'https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe'
|
|
$vcPath = Join-Path $stagingDir "vc-runtime\$vcName"
|
|
$vcAsset = Save-PinnedAsset $vcUrl $vcPath '843068991daaa1f73ad9f6239bce4d0f6a07a51f18c37ea2a867e9beca71295c' 18731856
|
|
|
|
$singBoxName = 'sing-box-1.13.19-windows-amd64.zip'
|
|
$singBoxUrl = "https://github.com/SagerNet/sing-box/releases/download/v1.13.19/$singBoxName"
|
|
$singBoxPath = Join-Path $stagingDir "sing-box\$singBoxName"
|
|
$singBoxRelease = Get-ReleaseEvidence 'SagerNet/sing-box' 'v1.13.19' ([bool]$UseFrozenReleaseEvidence)
|
|
$singBoxAsset = Save-GitHubDigestAsset $singBoxRelease $singBoxName $singBoxUrl $singBoxPath 'e011a4def2f5e2b143ed54adb2b1a20a6be407806ab4442f3667f1dd817a2c8d' 21046252
|
|
|
|
$winswName = 'WinSW.NET461.exe'
|
|
$winswUrl = "https://github.com/winsw/winsw/releases/download/v2.12.0/$winswName"
|
|
$winswPath = Join-Path $stagingDir "winsw\$winswName"
|
|
$winswRelease = Get-ReleaseEvidence 'winsw/winsw' 'v2.12.0' ([bool]$UseFrozenReleaseEvidence)
|
|
$winswReleaseAsset = Get-ReleaseAsset $winswRelease $winswName
|
|
$winswDigestProperty = $winswReleaseAsset.PSObject.Properties['digest']
|
|
if (
|
|
$winswReleaseAsset.browser_download_url -cne $winswUrl -or
|
|
[Int64]$winswReleaseAsset.size -ne 655872 -or
|
|
($null -ne $winswDigestProperty -and -not [string]::IsNullOrWhiteSpace([string]$winswDigestProperty.Value))
|
|
) {
|
|
throw 'Official WinSW asset identity changed.'
|
|
}
|
|
$winswAsset = Save-PinnedAsset $winswUrl $winswPath 'b5066b7bbdfba1293e5d15cda3caaea88fbeab35bd5b38c41c913d492aadfc4f' 655872
|
|
|
|
$licenseSources = Join-Path $stagingDir '.license-sources'
|
|
[void](New-Item -ItemType Directory -Path $licenseSources)
|
|
try {
|
|
Save-LicenseFromPinnedGit 'https://github.com/wiresock/proxifyre.git' 'proxifyre' 'v2.4.0' 'dd1512840e1e3bc596b06b80eda4e2dcd6a9c9ed' 'dd1512840e1e3bc596b06b80eda4e2dcd6a9c9ed' 'LICENSE' (Join-Path $stagingDir 'proxifyre\LICENSE') $licenseSources
|
|
Save-LicenseFromPinnedGit 'https://github.com/wiresock/ndisapi.git' 'ndisapi' 'v3.6.2' '417b8734e844083a10236387fba705d94a2d6bc9' '417b8734e844083a10236387fba705d94a2d6bc9' 'LICENSE' (Join-Path $stagingDir 'windows-packet-filter\LICENSE') $licenseSources
|
|
Save-LicenseFromPinnedGit 'https://github.com/SagerNet/sing-box.git' 'sing-box' 'v1.13.19' 'b5ebaa1fc0f2b94256180b95468e73ef53caa27d' 'b5ebaa1fc0f2b94256180b95468e73ef53caa27d' 'LICENSE' (Join-Path $stagingDir 'sing-box\LICENSE') $licenseSources
|
|
Save-LicenseFromPinnedGit 'https://github.com/winsw/winsw.git' 'winsw' 'v2.12.0' 'eef5bade59fca0254e387ac73ed7625ba6aa7147' 'eef5bade59fca0254e387ac73ed7625ba6aa7147' 'LICENSE.txt' (Join-Path $stagingDir 'winsw\LICENSE.txt') $licenseSources
|
|
} finally {
|
|
Remove-SafeGeneratedDirectory $licenseSources $stagingDir '^\.license-sources$'
|
|
}
|
|
Save-Download 'https://visualstudio.microsoft.com/wp-content/uploads/2025/10/Visual-C-V14-License-Redistributable_and_Runtime_ENU.docx' (Join-Path $stagingDir 'vc-runtime\LICENSE.docx') 5242880
|
|
|
|
Assert-ZipEntries $proxifyrePath @('ProxiFyre.exe', 'socksify.dll')
|
|
Assert-ZipEntries $singBoxPath @('sing-box.exe')
|
|
$verificationRoot = Join-Path $stagingDir '.verification'
|
|
[void](New-Item -ItemType Directory -Path $verificationRoot)
|
|
try {
|
|
$proxifyreExtract = Join-Path $verificationRoot 'proxifyre'
|
|
Expand-Archive -LiteralPath $proxifyrePath -DestinationPath $proxifyreExtract
|
|
$proxifyreExe = Get-UniqueFile $proxifyreExtract 'ProxiFyre.exe'
|
|
$socksifyDll = Get-UniqueFile $proxifyreExtract 'socksify.dll'
|
|
Assert-AuthenticodePublisher $proxifyreExe 'The Anti-Cloud Corporation'
|
|
Assert-AuthenticodePublisher $socksifyDll 'The Anti-Cloud Corporation'
|
|
Assert-PeMachineX64 $proxifyreExe
|
|
Assert-PeMachineX64 $socksifyDll
|
|
Assert-FileVersion $proxifyreExe '2.4.0' '2.4.0' 'ProxiFyre' 'NT KERNEL'
|
|
|
|
$singBoxExtract = Join-Path $verificationRoot 'sing-box'
|
|
Expand-Archive -LiteralPath $singBoxPath -DestinationPath $singBoxExtract
|
|
Assert-PeMachineX64 (Get-UniqueFile $singBoxExtract 'sing-box.exe')
|
|
} finally {
|
|
try {
|
|
Remove-SafeGeneratedDirectory $verificationRoot $stagingDir '^\.verification$'
|
|
} catch {
|
|
throw "Could not safely remove the package verification directory: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
Assert-AuthenticodePublisher $packetFilterPath 'The Anti-Cloud Corporation'
|
|
if (
|
|
(Get-MsiProperty $packetFilterPath 'ProductVersion') -cne '3.6.2.1' -or
|
|
(Get-MsiProperty $packetFilterPath 'ProductName') -cne 'Windows Packet Filter x64' -or
|
|
(Get-MsiProperty $packetFilterPath 'Manufacturer') -cne 'NT KERNEL'
|
|
) {
|
|
throw 'Windows Packet Filter MSI product identity mismatch.'
|
|
}
|
|
Assert-AuthenticodePublisher $vcPath 'Microsoft Corporation'
|
|
Assert-FileVersion $vcPath '14.51.36247.0' '14.51.36247.0' 'Microsoft Visual C++ v14 Redistributable (x64) - 14.51.36247'
|
|
Assert-Unsigned $winswPath
|
|
Assert-ManagedAnyCpu $winswPath
|
|
Assert-FileVersion $winswPath '2.12.0.0' '2.12.0+eef5bade59fca0254e387ac73ed7625ba6aa7147'
|
|
|
|
Assert-LocalLicenseIdentity 'proxifyre' (Join-Path $stagingDir 'proxifyre\LICENSE')
|
|
Assert-LocalLicenseIdentity 'windows-packet-filter' (Join-Path $stagingDir 'windows-packet-filter\LICENSE')
|
|
Assert-LocalLicenseIdentity 'vc-runtime' (Join-Path $stagingDir 'vc-runtime\LICENSE.docx')
|
|
Assert-LocalLicenseIdentity 'sing-box' (Join-Path $stagingDir 'sing-box\LICENSE')
|
|
Assert-LocalLicenseIdentity 'winsw' (Join-Path $stagingDir 'winsw\LICENSE.txt')
|
|
|
|
$catalog = [ordered]@{
|
|
schemaVersion = 1
|
|
targetArch = 'x64'
|
|
components = @(
|
|
[ordered]@{
|
|
id = 'proxifyre'; version = '2.4.0'; fileVersion = '2.4.0'; productVersion = '2.4.0'
|
|
assetPath = "proxifyre/$proxifyreName"; assetArch = 'x64'; effectiveTarget = 'x64'
|
|
sha256 = $proxifyreAsset.hash; size = $proxifyreAsset.size; sourceUrl = $proxifyreUrl
|
|
license = [ordered]@{ id = 'AGPL-3.0-only'; path = 'proxifyre/LICENSE' }
|
|
installRole = 'proxifyre-runtime'
|
|
updateTrustPolicy = [ordered]@{
|
|
type = 'githubReleaseDigest'; repository = 'wiresock/proxifyre'; tagPattern = 'v*'
|
|
assetPattern = 'ProxiFyre-v*-x64-signed.zip'; requireStable = $true
|
|
authenticodePublishers = @('The Anti-Cloud Corporation')
|
|
}
|
|
}
|
|
[ordered]@{
|
|
id = 'windows-packet-filter'; version = '3.6.2'; fileVersion = '3.6.2.1'; productVersion = '3.6.2.1'
|
|
assetPath = "windows-packet-filter/$packetFilterName"; assetArch = 'x64'; effectiveTarget = 'x64'
|
|
sha256 = $packetFilterAsset.hash; size = $packetFilterAsset.size; sourceUrl = $packetFilterUrl
|
|
license = [ordered]@{ id = 'MIT'; path = 'windows-packet-filter/LICENSE' }
|
|
installRole = 'packet-filter-driver'
|
|
updateTrustPolicy = [ordered]@{
|
|
type = 'githubReleaseDigest'; repository = 'wiresock/ndisapi'; tagPattern = 'v*'
|
|
assetPattern = 'Windows.Packet.Filter.*.x64.msi'; requireStable = $true
|
|
authenticodePublishers = @('The Anti-Cloud Corporation')
|
|
}
|
|
}
|
|
[ordered]@{
|
|
id = 'vc-runtime'; version = '14.51.36247.0'; fileVersion = '14.51.36247.0'; productVersion = '14.51.36247.0'
|
|
assetPath = "vc-runtime/$vcName"; assetArch = 'x64'; effectiveTarget = 'x64'
|
|
sha256 = $vcAsset.hash; size = $vcAsset.size; sourceUrl = $vcUrl
|
|
license = [ordered]@{ id = 'LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026'; path = 'vc-runtime/LICENSE.docx' }
|
|
installRole = 'vc-runtime-prerequisite'
|
|
updateTrustPolicy = [ordered]@{
|
|
type = 'buildTimeOnlyAuthenticode'; allowedSourceHosts = @('aka.ms')
|
|
assetPattern = 'VC_redist.x64.exe'; publishers = @('Microsoft Corporation')
|
|
}
|
|
}
|
|
[ordered]@{
|
|
id = 'sing-box'; version = '1.13.19'
|
|
assetPath = "sing-box/$singBoxName"; assetArch = 'x64'; effectiveTarget = 'x64'
|
|
sha256 = $singBoxAsset.hash; size = $singBoxAsset.size; sourceUrl = $singBoxUrl
|
|
license = [ordered]@{ id = 'LicenseRef-Sing-Box-Project'; path = 'sing-box/LICENSE' }
|
|
installRole = 'sing-box-runtime'
|
|
updateTrustPolicy = [ordered]@{
|
|
type = 'githubReleaseDigest'; repository = 'SagerNet/sing-box'; tagPattern = 'v*'
|
|
assetPattern = 'sing-box-*-windows-amd64.zip'; requireStable = $true
|
|
}
|
|
}
|
|
[ordered]@{
|
|
id = 'winsw'; version = '2.12.0'; fileVersion = '2.12.0.0'
|
|
productVersion = '2.12.0+eef5bade59fca0254e387ac73ed7625ba6aa7147'
|
|
assetPath = "winsw/$winswName"; assetArch = 'anycpu'; effectiveTarget = 'x64'
|
|
sha256 = $winswAsset.hash; size = $winswAsset.size; sourceUrl = $winswUrl
|
|
license = [ordered]@{ id = 'MIT'; path = 'winsw/LICENSE.txt' }
|
|
installRole = 'sing-box-service-wrapper'
|
|
updateTrustPolicy = [ordered]@{
|
|
type = 'bundledOnlyNoIndependentProof'
|
|
reason = 'The official v2.12.0 asset is unsigned and has no independent release digest; runtime network update is disabled.'
|
|
}
|
|
}
|
|
)
|
|
}
|
|
Write-DeterministicJson $catalog (Join-Path $stagingDir 'catalog.json')
|
|
|
|
if ($SimulateFailure -eq 'Validation') {
|
|
$corrupt = [IO.File]::Open($proxifyrePath, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
|
|
try {
|
|
$firstByte = $corrupt.ReadByte()
|
|
$corrupt.Position = 0
|
|
$corrupt.WriteByte([byte]($firstByte -bxor 0xff))
|
|
} finally {
|
|
$corrupt.Dispose()
|
|
}
|
|
}
|
|
[void](Test-ComponentBundle $stagingDir)
|
|
|
|
if ($SimulateFailure -ne 'Promotion' -and (Test-DirectoryContentEqual $resolvedOutputDir $stagingDir)) {
|
|
if ($backupWasEmptyPlaceholder -and (Test-Path -LiteralPath $backupDir)) {
|
|
Remove-SafeEmptyDirectory $backupDir $backupDir
|
|
$backupWasEmptyPlaceholder = $false
|
|
}
|
|
ConvertTo-ResultJson ([ordered]@{
|
|
mode = 'update'; changed = $false; schemaVersion = 1; targetArch = 'x64'
|
|
outputDir = $resolvedOutputDir; componentCount = 5; backupDir = $null
|
|
releaseEvidence = $releaseEvidenceLabel
|
|
})
|
|
return
|
|
}
|
|
|
|
if (Test-Path -LiteralPath $backupDir) {
|
|
if (-not (Test-Path -LiteralPath $backupDir -PathType Container)) {
|
|
throw 'The previous bundle backup is not a directory.'
|
|
}
|
|
if ($backupWasEmptyPlaceholder) {
|
|
if (-not (Test-SafeEmptyDirectory $backupDir)) {
|
|
throw 'The empty previous bundle placeholder changed during the update.'
|
|
}
|
|
} else {
|
|
[void](Test-ComponentBundle $backupDir)
|
|
}
|
|
[IO.Directory]::Move($backupDir, $retiredBackupDir)
|
|
$oldBackupMoved = $true
|
|
}
|
|
if (Test-Path -LiteralPath $resolvedOutputDir) {
|
|
if ($activeWasEmptyPlaceholder) {
|
|
if (-not (Test-SafeEmptyDirectory $resolvedOutputDir)) {
|
|
throw 'The empty active bundle placeholder changed during the update.'
|
|
}
|
|
} else {
|
|
[void](Test-ComponentBundle $resolvedOutputDir)
|
|
}
|
|
[IO.Directory]::Move($resolvedOutputDir, $backupDir)
|
|
$activeMoved = $true
|
|
}
|
|
[IO.Directory]::Move($stagingDir, $resolvedOutputDir)
|
|
$stagingMoved = $true
|
|
if ($SimulateFailure -eq 'Promotion') {
|
|
throw 'Simulated bundle promotion failure after activating the candidate.'
|
|
}
|
|
[void](Test-ComponentBundle $resolvedOutputDir)
|
|
if ($activeMoved -and $activeWasEmptyPlaceholder) {
|
|
Remove-SafeEmptyDirectory $backupDir $backupDir
|
|
$activeMoved = $false
|
|
$activeWasEmptyPlaceholder = $false
|
|
}
|
|
$promoted = $true
|
|
if ($oldBackupMoved -and (Test-Path -LiteralPath $retiredBackupDir)) {
|
|
try {
|
|
Remove-SafeGeneratedDirectory $retiredBackupDir $outputParent '^\.proxywarden-components-previous-[0-9a-f]{32}$'
|
|
} catch {
|
|
# The new active bundle and its immediate backup are already valid. Preserve an older
|
|
# recovery directory if safe cleanup cannot be proven.
|
|
}
|
|
if (-not (Test-Path -LiteralPath $retiredBackupDir)) {
|
|
$oldBackupMoved = $false
|
|
}
|
|
}
|
|
|
|
ConvertTo-ResultJson ([ordered]@{
|
|
mode = 'update'; changed = $true; schemaVersion = 1; targetArch = 'x64'
|
|
outputDir = $resolvedOutputDir; componentCount = 5
|
|
backupDir = $(if ($activeMoved) { $backupDir } else { $null })
|
|
releaseEvidence = $releaseEvidenceLabel
|
|
})
|
|
} catch {
|
|
$updateError = $_
|
|
try {
|
|
if (-not $promoted) {
|
|
if ($stagingMoved) {
|
|
if (-not (Test-Path -LiteralPath $resolvedOutputDir) -or (Test-Path -LiteralPath $stagingDir)) {
|
|
throw 'Cannot preserve the failed candidate before rollback.'
|
|
}
|
|
[IO.Directory]::Move($resolvedOutputDir, $stagingDir)
|
|
$stagingMoved = $false
|
|
}
|
|
if ($activeMoved) {
|
|
if ((Test-Path -LiteralPath $resolvedOutputDir) -or -not (Test-Path -LiteralPath $backupDir)) {
|
|
throw 'Cannot restore the previous active bundle.'
|
|
}
|
|
[IO.Directory]::Move($backupDir, $resolvedOutputDir)
|
|
$activeMoved = $false
|
|
}
|
|
if ($oldBackupMoved) {
|
|
if ((Test-Path -LiteralPath $backupDir) -or -not (Test-Path -LiteralPath $retiredBackupDir)) {
|
|
throw 'Cannot restore the older recovery bundle.'
|
|
}
|
|
[IO.Directory]::Move($retiredBackupDir, $backupDir)
|
|
$oldBackupMoved = $false
|
|
}
|
|
}
|
|
} catch {
|
|
$preserveRecoveryArtifacts = $true
|
|
throw [InvalidOperationException]::new(
|
|
"Component bundle update failed and rollback could not be completed. Recovery artifacts were preserved. $($_.Exception.Message)",
|
|
$_.Exception
|
|
)
|
|
}
|
|
throw $updateError
|
|
} finally {
|
|
if ($lockOwned) {
|
|
if ($null -ne $lock) {
|
|
$lock.Dispose()
|
|
}
|
|
if (-not $preserveRecoveryArtifacts -and (Test-Path -LiteralPath $lockPath)) {
|
|
$lockItem = Get-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
|
|
if ($null -ne $lockItem -and ($lockItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) {
|
|
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
$lockOwned = $false
|
|
}
|
|
if (-not $preserveRecoveryArtifacts -and (Test-Path -LiteralPath $stagingDir)) {
|
|
try {
|
|
Remove-SafeGeneratedDirectory $stagingDir $outputParent '^\.proxywarden-components-staging-[0-9a-f]{32}$'
|
|
} catch {
|
|
# Refuse unsafe recursive cleanup and leave the generated directory for inspection.
|
|
}
|
|
}
|
|
}
|