Files
ProxyWarden/scripts/update-component-bundle.ps1
dokril efda8eb98f
CI / Windows baseline (push) Canceled after 0s
Release v2.0.0
2026-09-10 20:59:52 +03:00

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