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