Release v2.0.0
CI / Windows baseline (push) Canceled after 0s

This commit is contained in:
2026-09-10 20:59:52 +03:00
parent 9c987df6e9
commit efda8eb98f
142 changed files with 68308 additions and 9333 deletions
+34 -5
View File
@@ -2,8 +2,9 @@ param(
[ValidateSet("PlanOnly", "Capture")]
[string]$Mode = "PlanOnly",
[string]$DataRoot = "C:\ProgramData\ProxyWarden",
[string]$ProxiFyreRoot = "C:\Tools\ProxiFyre",
[string]$SingBoxRoot = "C:\Program Files\ProxyWarden\sing-box",
[string]$AppRoot = "C:\Program Files\ProxyWarden",
[string]$ProxiFyreRoot = "C:\Program Files\ProxyWarden\components\ProxiFyre",
[string]$SingBoxRoot = "C:\Program Files\ProxyWarden\components\sing-box",
[string]$ForeignServiceName = "",
[string]$OutputPath = ""
)
@@ -110,10 +111,35 @@ function Get-SecretFindingCategories {
return $findings
}
function Get-InternalStateEvidence {
param([string]$Root)
$categories = [ordered]@{
cutoverJournal = ".proxywarden-cutover"
cutoverQuarantine = ".proxywarden-quarantine"
packageStaging = ".proxywarden-package-staging"
privilegedJobs = ".proxywarden-privileged-jobs"
serviceLogs = ".proxywarden-service-logs"
singBoxCleanupTombstone = ".proxywarden-sing-box-cleanup"
}
$result = @()
foreach ($entry in $categories.GetEnumerator()) {
$path = Join-Path $Root $entry.Value
$item = Get-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
$result += [ordered]@{
category = $entry.Key
present = $null -ne $item
itemType = if ($null -eq $item) { $null } elseif ($item.PSIsContainer) { "directory" } else { "file" }
}
}
return $result
}
try {
$quotedServiceFixture = '"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe" -service'
$quotedServiceFixture = '"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe" -service'
$quotedExecutable = Get-ServiceExecutablePath -PathName $quotedServiceFixture
if (-not (Test-PathUnderRoot -Path $quotedExecutable -Root "C:\Program Files\ProxyWarden\sing-box")) {
if (-not (Test-PathUnderRoot -Path $quotedExecutable -Root "C:\Program Files\ProxyWarden\components\sing-box")) {
throw "Quoted service PathName ownership self-test failed."
}
@@ -122,11 +148,12 @@ try {
serviceNames = @("ProxiFyreService", "ProxyWardenSingBox")
foreignServiceName = $ForeignServiceName
roots = [ordered]@{
app = [IO.Path]::GetFullPath($AppRoot)
data = [IO.Path]::GetFullPath($DataRoot)
proxifyre = [IO.Path]::GetFullPath($ProxiFyreRoot)
singbox = [IO.Path]::GetFullPath($SingBoxRoot)
}
checks = @("service-state-and-path", "managed-root-membership", "file-metadata", "secret-category-scan")
checks = @("service-state-and-path", "managed-root-membership", "file-metadata", "secret-category-scan", "internal-state-presence-only")
}
if ($Mode -eq "PlanOnly") {
@@ -167,6 +194,7 @@ try {
ownership = $ownership
files = @(Get-FileEvidence -Root $DataRoot)
secretFindingCategories = @(Get-SecretFindingCategories -Root $DataRoot)
internalState = @(Get-InternalStateEvidence -Root $AppRoot)
}
$report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $outputFullPath -Encoding UTF8
@@ -175,6 +203,7 @@ try {
serviceCount = @($services | Where-Object found).Count
fileCount = @($report.files).Count
secretFindingCount = @($report.secretFindingCategories).Count
internalStateCategoryCount = @($report.internalState).Count
}
} catch {
New-Result -Success $false -Action "audit-windows-smoke.$($Mode.ToLowerInvariant())" -Changed $false -Message $_.Exception.Message -Details @{}
@@ -0,0 +1,253 @@
[CmdletBinding()]
param(
[switch]$CheckOnly
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
$AllowedPowerShellFiles = @(
"scripts/audit-windows-smoke.ps1",
"scripts/check-runtime-powershell-boundary.ps1",
"scripts/prepare-release.ps1",
"scripts/update-component-bundle.ps1"
)
$ExpectedNsisFlags = @(
"--nsis-uninstall-managed",
"--nsis-verify-upgrade"
)
$IgnoredPathPattern = '^(?:\.git|node_modules|dist|releases|src-tauri/target)(?:/|$)'
function Get-RelativeRepoPath {
param([string]$Path)
$rootUri = [Uri]($RepoRoot.TrimEnd("\", "/") + [IO.Path]::DirectorySeparatorChar)
$pathUri = [Uri][IO.Path]::GetFullPath($Path)
[Uri]::UnescapeDataString($rootUri.MakeRelativeUri($pathUri).ToString()).Replace("\", "/")
}
function New-Violation {
param(
[string]$Rule,
[string]$Path,
[string]$Message,
[int]$Line = 0
)
[ordered]@{
rule = $Rule
path = $Path
line = $Line
message = $Message
}
}
function Get-ProductionLines {
param([string]$Path)
$lines = @(Get-Content -LiteralPath $Path)
for ($index = 0; $index -lt $lines.Count; $index++) {
if ($lines[$index] -match '^\s*#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]') {
if ($index -eq 0) { return @() }
return @($lines[0..($index - 1)])
}
}
return $lines
}
function Write-Result {
param(
[bool]$Success,
[string]$Message,
[object[]]$Violations,
[int]$PowerShellFileCount,
[int]$ProductionFileCount,
[string[]]$ObservedNsisFlags
)
[ordered]@{
success = $Success
action = "runtime-powershell-boundary.check"
changed = $false
message = $Message
details = [ordered]@{
allowlistedPowerShellFiles = $AllowedPowerShellFiles
scannedPowerShellFileCount = $PowerShellFileCount
scannedProductionFileCount = $ProductionFileCount
expectedNsisFlags = $ExpectedNsisFlags
observedNsisFlags = $ObservedNsisFlags
violations = $Violations
}
} | ConvertTo-Json -Depth 8
}
$violations = New-Object System.Collections.Generic.List[object]
$powerShellFileCount = 0
$productionFileCount = 0
$observedNsisFlags = @()
try {
if (-not $CheckOnly) {
[void]$violations.Add((New-Violation `
-Rule "check-only-required" `
-Path "scripts/check-runtime-powershell-boundary.ps1" `
-Message "Invoke this read-only boundary as -CheckOnly."))
}
$powerShellFiles = @(
Get-ChildItem -LiteralPath $RepoRoot -Recurse -File |
Where-Object { $_.Extension -in @(".ps1", ".psm1", ".psd1") } |
ForEach-Object {
[ordered]@{
fullPath = $_.FullName
relativePath = Get-RelativeRepoPath -Path $_.FullName
}
} |
Where-Object { $_.relativePath -notmatch $IgnoredPathPattern } |
Sort-Object relativePath
)
$powerShellFileCount = $powerShellFiles.Count
foreach ($file in $powerShellFiles) {
if ($file.relativePath -notin $AllowedPowerShellFiles) {
[void]$violations.Add((New-Violation `
-Rule "unexpected-powershell-file" `
-Path $file.relativePath `
-Message "PowerShell is allowed only for the exact build/release/QA allowlist."))
}
}
foreach ($allowedPath in $AllowedPowerShellFiles) {
if ($allowedPath -notin $powerShellFiles.relativePath) {
[void]$violations.Add((New-Violation `
-Rule "missing-allowlisted-tool" `
-Path $allowedPath `
-Message "Required build/release/QA tool is missing."))
}
}
$forbiddenRuntimeFiles = @(
"src-tauri/src/elevated_scripts.rs",
"src-tauri/src/helper.rs",
"src-tauri/src/powershell.rs",
"src-tauri/src/proxifyre_scripts.rs",
"src-tauri/bundled/cleanup/uninstall-managed-components.ps1"
)
foreach ($relativePath in $forbiddenRuntimeFiles) {
if (Test-Path -LiteralPath (Join-Path $RepoRoot $relativePath.Replace("/", "\"))) {
[void]$violations.Add((New-Violation `
-Rule "legacy-runtime-file" `
-Path $relativePath `
-Message "Legacy runtime PowerShell owner must be deleted after the native cutover."))
}
}
$tauriConfigPath = Join-Path $RepoRoot "src-tauri\tauri.conf.json"
if ((Get-Content -LiteralPath $tauriConfigPath -Raw) -match '(?i)bundled[\\/]cleanup') {
[void]$violations.Add((New-Violation `
-Rule "bundled-cleanup-resource" `
-Path "src-tauri/tauri.conf.json" `
-Message "The installer must not package the displaced PowerShell cleanup resource."))
}
$productionFiles = @(
Get-ChildItem -LiteralPath (Join-Path $RepoRoot "src-tauri\src") -Recurse -File -Filter "*.rs"
Get-ChildItem -LiteralPath (Join-Path $RepoRoot "src-tauri\bundled\installer-hooks") -Recurse -File | Where-Object { $_.Extension -in @(".nsh", ".nsi") }
)
$productionFileCount = $productionFiles.Count
$rules = @(
[ordered]@{ name = "powershell-process"; pattern = '(?i)(?:command_no_window|Command::new).*\b(?:powershell|pwsh)(?:\.exe)?\b' },
[ordered]@{ name = "powershell-command-line"; pattern = '(?i)\b(?:powershell|pwsh)(?:\.exe)?\b\s+-[A-Za-z]' },
[ordered]@{ name = "powershell-policy-bypass"; pattern = '(?i)-ExecutionPolicy\b' },
[ordered]@{ name = "powershell-script-path"; pattern = '(?i)\.ps1\b' },
[ordered]@{ name = "powershell-runtime-helper"; pattern = '(?i)\b(?:run|write)_powershell_(?:command|file|script)\b' },
[ordered]@{ name = "legacy-module-declaration"; pattern = '(?i)\b(?:pub\s+)?mod\s+(?:elevated_scripts|helper|powershell|proxifyre_scripts)\s*;' },
[ordered]@{ name = "legacy-module-reexport"; pattern = '(?i)\bpub\s+use\s+crate::(?:elevated_scripts|helper|powershell|proxifyre_scripts)\b' }
)
$productionTextParts = New-Object System.Collections.Generic.List[string]
$rustTextParts = New-Object System.Collections.Generic.List[string]
foreach ($file in $productionFiles) {
$relativePath = Get-RelativeRepoPath -Path $file.FullName
$lines = @(Get-ProductionLines -Path $file.FullName)
for ($index = 0; $index -lt $lines.Count; $index++) {
$line = [string]$lines[$index]
[void]$productionTextParts.Add($line)
if ($file.Extension -ieq ".rs") {
[void]$rustTextParts.Add($line)
}
foreach ($rule in $rules) {
if ($line -match $rule.pattern) {
[void]$violations.Add((New-Violation `
-Rule $rule.name `
-Path $relativePath `
-Line ($index + 1) `
-Message "Production code still contains a PowerShell runtime boundary."))
}
}
}
}
$productionText = $productionTextParts -join "`n"
$rustText = $rustTextParts -join "`n"
$observedNsisFlags = @(
[regex]::Matches($productionText, '--nsis-[a-z0-9-]+', [Text.RegularExpressions.RegexOptions]::IgnoreCase) |
ForEach-Object { $_.Value.ToLowerInvariant() } |
Sort-Object -Unique
)
foreach ($flag in $ExpectedNsisFlags) {
if (-not $rustText.Contains($flag)) {
[void]$violations.Add((New-Violation `
-Rule "missing-nsis-runtime-mode" `
-Path "src-tauri/src" `
-Message "Rust early-mode parser is missing fixed NSIS mode: $flag"))
}
}
foreach ($flag in $observedNsisFlags) {
if ($flag -notin $ExpectedNsisFlags) {
[void]$violations.Add((New-Violation `
-Rule "unexpected-nsis-mode" `
-Path "src-tauri" `
-Message "Unexpected reserved NSIS early mode: $flag"))
}
}
$hookPath = Join-Path $RepoRoot "src-tauri\bundled\installer-hooks\proxywarden-hooks.nsh"
$hookText = Get-Content -LiteralPath $hookPath -Raw
foreach ($flag in $ExpectedNsisFlags) {
if (-not $hookText.Contains($flag)) {
[void]$violations.Add((New-Violation `
-Rule "missing-nsis-hook-mode" `
-Path "src-tauri/bundled/installer-hooks/proxywarden-hooks.nsh" `
-Message "Installer hook does not call fixed early mode: $flag"))
}
}
$success = $violations.Count -eq 0
$message = if ($success) {
"Runtime PowerShell boundary is clean."
} else {
"Runtime PowerShell boundary has $($violations.Count) violation(s)."
}
Write-Result `
-Success $success `
-Message $message `
-Violations $violations.ToArray() `
-PowerShellFileCount $powerShellFileCount `
-ProductionFileCount $productionFileCount `
-ObservedNsisFlags $observedNsisFlags
if (-not $success) { exit 1 }
} catch {
$failure = New-Violation `
-Rule "checker-error" `
-Path "scripts/check-runtime-powershell-boundary.ps1" `
-Message $_.Exception.Message
Write-Result `
-Success $false `
-Message "Runtime PowerShell boundary check could not complete." `
-Violations @($failure) `
-PowerShellFileCount $powerShellFileCount `
-ProductionFileCount $productionFileCount `
-ObservedNsisFlags $observedNsisFlags
exit 1
}
-79
View File
@@ -1,79 +0,0 @@
param(
[string]$InstallRoot = "C:\Program Files\ProxyWarden\ControlApp",
[string]$DataRoot = "C:\ProgramData\ProxyWarden",
[switch]$PlanOnly,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function New-Result {
param(
[bool]$Success,
[string]$Action,
[bool]$Changed,
[string]$Message,
[hashtable]$Details = @{}
)
[ordered]@{
success = $Success
action = $Action
changed = $Changed
message = $Message
details = $Details
} | ConvertTo-Json -Depth 6
}
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Ensure-Directory {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
return $true
}
return $false
}
try {
$details = @{
installRoot = $InstallRoot
dataRoot = $DataRoot
planOnly = [bool]$PlanOnly
}
if ($PlanOnly) {
New-Result -Success $true -Action "install-control-app" -Changed $false -Message "Control App install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-control-app" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
$changed = $false
$changed = (Ensure-Directory -Path $InstallRoot) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "config")) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "state")) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "generated")) -or $changed
$markerPath = Join-Path $InstallRoot "install-control-app.marker.json"
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
@{ component = "control-app"; installedAt = (Get-Date).ToString("o") } |
ConvertTo-Json -Depth 4 |
Set-Content -LiteralPath $markerPath -Encoding UTF8
$changed = $true
}
$details.markerPath = $markerPath
New-Result -Success $true -Action "install-control-app" -Changed $changed -Message "Control App directories are installed." -Details $details
} catch {
New-Result -Success $false -Action "install-control-app" -Changed $false -Message $_.Exception.Message
exit 1
}
-96
View File
@@ -1,96 +0,0 @@
param(
[string]$InstallRoot = "C:\Program Files\ProxyWarden\components\ProxiFyre",
[string]$PackagePath = "",
[string]$ServiceName = "ProxiFyreService",
[switch]$PlanOnly,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function New-Result {
param(
[bool]$Success,
[string]$Action,
[bool]$Changed,
[string]$Message,
[hashtable]$Details = @{}
)
[ordered]@{
success = $Success
action = $Action
changed = $Changed
message = $Message
details = $Details
} | ConvertTo-Json -Depth 6
}
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Backup-File {
param([string]$Path)
if (Test-Path -LiteralPath $Path) {
$backup = "$Path.bak"
Copy-Item -LiteralPath $Path -Destination $backup -Force
return $backup
}
return $null
}
try {
$details = @{
installRoot = $InstallRoot
packagePath = $PackagePath
serviceName = $ServiceName
planOnly = [bool]$PlanOnly
}
if ($PlanOnly) {
New-Result -Success $true -Action "install-proxyfier" -Changed $false -Message "Proxyfier install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
if ([string]::IsNullOrWhiteSpace($PackagePath) -or -not (Test-Path -LiteralPath $PackagePath)) {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "PackagePath is required and must point to a local ProxiFyre package." -Details $details
exit 2
}
$changed = $false
if (-not (Test-Path -LiteralPath $InstallRoot)) {
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
$changed = $true
}
$configPath = Join-Path $InstallRoot "app-config.json"
$backupPath = Backup-File -Path $configPath
if ($backupPath) {
$details.backupPath = $backupPath
}
$markerPath = Join-Path $InstallRoot "install-proxyfier.marker.json"
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
@{
component = "proxyfier"
packagePath = $PackagePath
serviceName = $ServiceName
installedAt = (Get-Date).ToString("o")
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
$changed = $true
}
$details.markerPath = $markerPath
New-Result -Success $true -Action "install-proxyfier" -Changed $changed -Message "Proxyfier install boundary completed." -Details $details
} catch {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message $_.Exception.Message
exit 1
}
-270
View File
@@ -1,270 +0,0 @@
param(
[string]$InstallRoot = "C:\Program Files\ProxyWarden\components\sing-box",
[string]$ServiceName = "ProxyWardenSingBox",
[string]$ConfigSource = "C:\ProgramData\ProxyWarden\generated\sing-box-config.json",
[switch]$PlanOnly,
[switch]$Force,
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$SingBoxReleaseApi = "https://api.github.com/repos/SagerNet/sing-box/releases/latest"
$WinSwReleaseApi = "https://api.github.com/repos/winsw/winsw/releases/latest"
$WrapperFile = "$ServiceName.exe"
$ConfigFile = "config.json"
function New-Result {
param(
[bool]$Success,
[string]$Action,
[bool]$Changed,
[string]$Message,
[hashtable]$Details = @{}
)
[ordered]@{
success = $Success
action = $Action
changed = $Changed
message = $Message
details = $Details
} | ConvertTo-Json -Depth 8
}
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Get-NativeArchitecture {
$processor = Get-CimInstance Win32_Processor | Select-Object -First 1
if ($null -ne $processor -and $processor.Architecture -eq 12) { return "arm64" }
if ([Environment]::Is64BitOperatingSystem) { return "amd64" }
return "386"
}
function Get-WinSwArchitecture {
param([string]$Arch)
if ($Arch -eq "arm64") { return "arm64" }
if ($Arch -eq "386") { return "x86" }
return "x64"
}
function Invoke-Download {
param([string]$Uri, [string]$Path)
Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $Path -Headers @{ "User-Agent" = "proxywarden" }
}
function Select-Asset {
param(
[object[]]$Assets,
[string]$Pattern,
[string]$Label
)
$asset = $Assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1
if ($null -eq $asset) {
throw "Не найден release asset для $Label по шаблону $Pattern."
}
return $asset
}
function Test-SafeInstallRoot {
param([string]$Path)
$full = [System.IO.Path]::GetFullPath($Path).TrimEnd("\")
$leaf = Split-Path -Leaf $full
$parent = Split-Path -Parent $full
if ($leaf -ne "sing-box") { return $false }
return $parent -match "\\ProxyWarden\\components$|\\proxywarden\\components$|\\ProxyWarden$|\\proxywarden$"
}
function Backup-File {
param([string]$Path)
if (Test-Path -LiteralPath $Path) {
$backup = "$Path.bak"
Copy-Item -LiteralPath $Path -Destination $backup -Force
return $backup
}
return $null
}
function Write-Utf8NoBomFile {
param(
[string]$Path,
[string]$Value
)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($Path, $Value, $encoding)
}
function Write-WinSwConfig {
param(
[string]$Root,
[string]$Name
)
$xmlPath = Join-Path $Root "$Name.xml"
$logDir = Join-Path $Root "logs"
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
$xml = @"
<service>
<id>$Name</id>
<name>ProxyWarden Local sing-box</name>
<description>Local sing-box runtime managed by ProxyWarden.</description>
<executable>%BASE%\sing-box.exe</executable>
<arguments>run -c "%BASE%\config.json"</arguments>
<logpath>%BASE%\logs</logpath>
<log mode="roll-by-size">
<sizeThreshold>10485760</sizeThreshold>
<keepFiles>4</keepFiles>
</log>
<onfailure action="restart" delay="5 sec"/>
</service>
"@
Write-Utf8NoBomFile -Path $xmlPath -Value $xml
return $xmlPath
}
function Stop-And-Uninstall-Service {
param(
[string]$Root,
[string]$Name
)
$wrapper = Join-Path $Root "$Name.exe"
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
if ($null -ne $service -and $service.Status -ne "Stopped") {
Stop-Service -Name $Name -Force -ErrorAction SilentlyContinue
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
if ($null -ne $service) {
try { $service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(15)) } catch {}
}
}
if (Test-Path -LiteralPath $wrapper) {
Push-Location $Root
try { & $wrapper uninstall | Out-Null } finally { Pop-Location }
}
$service = Get-Service -Name $Name -ErrorAction SilentlyContinue
if ($null -ne $service) {
sc.exe delete $Name | Out-Null
}
}
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$installRootFull = [System.IO.Path]::GetFullPath($InstallRoot)
$details = @{
installRoot = $installRootFull
serviceName = $ServiceName
configSource = $ConfigSource
singboxReleaseApi = $SingBoxReleaseApi
winswReleaseApi = $WinSwReleaseApi
planOnly = [bool]$PlanOnly
uninstall = [bool]$Uninstall
}
if ($PlanOnly) {
$details.items = @(
@{ id = "sing-box-binary"; name = "sing-box.exe"; source = $SingBoxReleaseApi; target = (Join-Path $installRootFull "sing-box.exe") },
@{ id = "winsw-wrapper"; name = $WrapperFile; source = $WinSwReleaseApi; target = (Join-Path $installRootFull $WrapperFile) },
@{ id = "windows-service"; name = $ServiceName; target = "Windows Service" },
@{ id = "config"; name = $ConfigFile; source = $ConfigSource; target = (Join-Path $installRootFull $ConfigFile) }
)
New-Result -Success $true -Action "install-singbox.plan" -Changed $false -Message "Local sing-box install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
if ($Uninstall) {
if (-not (Test-SafeInstallRoot -Path $installRootFull)) {
New-Result -Success $false -Action "uninstall-singbox" -Changed $false -Message "Unsafe InstallRoot for recursive uninstall." -Details $details
exit 2
}
Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName
if (Test-Path -LiteralPath $installRootFull) {
Remove-Item -LiteralPath $installRootFull -Recurse -Force
}
New-Result -Success $true -Action "uninstall-singbox" -Changed $true -Message "Local sing-box service and install folder were removed." -Details $details
exit 0
}
$changed = $false
New-Item -ItemType Directory -Path $installRootFull -Force | Out-Null
$workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("proxywarden-singbox-" + [guid]::NewGuid().ToString("N"))
$extractDir = Join-Path $workDir "extract"
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
try {
$arch = Get-NativeArchitecture
$winswArch = Get-WinSwArchitecture -Arch $arch
$details.architecture = $arch
$details.winswArchitecture = $winswArch
$singboxRelease = Invoke-RestMethod -Uri $SingBoxReleaseApi -Headers @{ "User-Agent" = "proxywarden" }
$singboxAsset = Select-Asset $singboxRelease.assets "windows-$arch\.zip$" "sing-box"
$singboxZip = Join-Path $workDir $singboxAsset.name
Invoke-Download $singboxAsset.browser_download_url $singboxZip
Expand-Archive -LiteralPath $singboxZip -DestinationPath $extractDir -Force
$singboxExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter "sing-box.exe" | Select-Object -First 1
if ($null -eq $singboxExe) { throw "В архиве sing-box не найден sing-box.exe." }
Copy-Item -LiteralPath $singboxExe.FullName -Destination (Join-Path $installRootFull "sing-box.exe") -Force
$changed = $true
$winswRelease = Invoke-RestMethod -Uri $WinSwReleaseApi -Headers @{ "User-Agent" = "proxywarden" }
$winswAsset = Select-Asset $winswRelease.assets "WinSW-$winswArch\.exe$" "WinSW"
Invoke-Download $winswAsset.browser_download_url (Join-Path $installRootFull $WrapperFile)
$changed = $true
$configTarget = Join-Path $installRootFull $ConfigFile
$backupPath = Backup-File -Path $configTarget
if ($backupPath) { $details.backupPath = $backupPath }
if (Test-Path -LiteralPath $ConfigSource) {
Copy-Item -LiteralPath $ConfigSource -Destination $configTarget -Force
} elseif (-not (Test-Path -LiteralPath $configTarget)) {
Write-Utf8NoBomFile -Path $configTarget -Value '{"log":{"level":"info","timestamp":true},"inbounds":[],"outbounds":[{"type":"direct","tag":"direct"}],"route":{"final":"direct"}}'
}
$xmlPath = Write-WinSwConfig -Root $installRootFull -Name $ServiceName
$details.configPath = $configTarget
$details.wrapperConfigPath = $xmlPath
if ($Force) {
Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName
}
Push-Location $installRootFull
try {
$service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($null -eq $service) {
& ".\$WrapperFile" install
if ($LASTEXITCODE -ne 0) { throw "WinSW install завершился с кодом $LASTEXITCODE." }
$changed = $true
}
& ".\$WrapperFile" start
if ($LASTEXITCODE -ne 0) {
Start-Service -Name $ServiceName -ErrorAction Stop
}
} finally {
Pop-Location
}
} finally {
if (Test-Path -LiteralPath $workDir) {
Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box service is installed and started." -Details $details
} catch {
New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message
exit 1
}
+262
View File
@@ -0,0 +1,262 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
readFileSync,
writeFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
const source = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "prepare-release.ps1"),
"utf8",
).replace(/^\ufeff/, "");
const entry = source.lastIndexOf("try {\n Push-Location $RepoRoot");
const crlfEntry = source.lastIndexOf("try {\r\n Push-Location $RepoRoot");
const entryOffset = Math.max(entry, crlfEntry);
assert.ok(entryOffset > 0);
function run(cwd, command, args, ok = true) {
const result = spawnSync(command, args, {
cwd,
encoding: "utf8",
timeout: 60000,
windowsHide: true,
});
if (ok)
assert.equal(
result.status,
0,
`${command}: ${result.stdout}\n${result.stderr}`,
);
return result;
}
function fixture(t, build = "") {
const root = mkdtempSync(join(tmpdir(), "proxywarden-release-test-"));
t.after(() => {
assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep));
assert.ok(root.includes("proxywarden-release-test-"));
rmSync(root, { recursive: true, force: true });
});
const repo = join(root, "repo");
mkdirSync(repo);
const write = (path, text) => {
mkdirSync(dirname(join(repo, path)), { recursive: true });
writeFileSync(join(repo, path), text);
};
const git = (...args) => run(repo, "git", args).stdout.trim();
write("package.json", '{"name":"proxywarden","version":"1.2.0"}\n');
write(
"package-lock.json",
'{"name":"proxywarden","version":"1.2.0","packages":{"":{"name":"proxywarden","version":"1.2.0"}}}\n',
);
write("src-tauri/tauri.conf.json", '{"version":"1.2.0"}\n');
write(
"src-tauri/Cargo.toml",
'[package]\nname = "proxywarden"\nversion = "1.2.0"\n',
);
write(
"src-tauri/Cargo.lock",
'[[package]]\nname = "proxywarden"\nversion = "1.2.0"\n',
);
write(".gitignore", "node_modules/\nsrc-tauri/target/\nreleases/\n");
for (const cli of [
"typescript/bin/tsc",
"vite/bin/vite.js",
"@tauri-apps/cli/tauri.js",
"prettier/bin/prettier.cjs",
"eslint/bin/eslint.js",
"vitest/vitest.mjs",
])
write(`node_modules/${cli}`, "fixture");
// Replace only expensive checks/build in this isolated copy. Git/version/artifact/push code is real.
const stub = `
function Invoke-ReleaseChecks {}
function Invoke-ReleaseBuild {
${build}
$output = Join-Path $BundleRoot 'nsis'
New-Item -ItemType Directory -Path $output -Force | Out-Null
[IO.File]::WriteAllText((Join-Path $output "ProxyWarden_$($targetVersion)_x64-setup.exe"), 'test artifact')
}
`;
write(
"scripts/prepare-release.ps1",
"\ufeff" + source.slice(0, entryOffset) + stub + source.slice(entryOffset),
);
git("init", "-b", "master");
git("config", "user.name", "Release Test");
git("config", "user.email", "release-test@example.invalid");
git("config", "core.autocrlf", "false");
git("add", ".");
git("commit", "-m", "initial");
const remote = join(root, "origin.git");
run(root, "git", ["init", "--bare", remote]);
git("remote", "add", "origin", remote);
git("push", "-u", "origin", "master");
const release = (...args) =>
run(
repo,
"pwsh",
[
"-NoProfile",
"-File",
"scripts/prepare-release.ps1",
"-Publish",
...args,
],
false,
);
const manifest = () =>
JSON.parse(
readFileSync(
join(repo, "releases/proxywarden-v1.2.1/release-manifest.json"),
"utf8",
).replace(/^\ufeff/, ""),
);
return { root, repo, remote, git, write, release, manifest };
}
test("PlanOnly is offline and leaves versions/index/refs unchanged", (t) => {
const f = fixture(t);
f.git("remote", "set-url", "origin", join(f.root, "absent.git"));
const before = f.git("status", "--porcelain");
const head = f.git("rev-parse", "HEAD");
const result = f.release("-PlanOnly");
assert.equal(result.status, 0, result.stderr);
const plan = JSON.parse(result.stdout);
assert.equal(plan.changed, false);
assert.equal(plan.details.targetVersion, "1.2.1");
assert.equal(f.git("status", "--porcelain"), before);
assert.equal(f.git("rev-parse", "HEAD"), head);
});
test("release commits exact dirty source, versions both locks, tags and atomically pushes", (t) => {
const f = fixture(t);
f.write("feature.txt", "new feature");
const result = f.release("-Version", "1.2.1");
assert.equal(result.status, 0, result.stdout + result.stderr);
const head = f.git("rev-parse", "HEAD");
assert.equal(f.git("rev-parse", "v1.2.1^{commit}"), head);
assert.equal(
f.git("ls-remote", "origin", "refs/heads/master").split(/\s/)[0],
head,
);
assert.equal(f.git("status", "--porcelain"), "");
assert.equal(f.manifest().gitCommit, head);
assert.equal(f.manifest().gitRelease.status, "pushed");
assert.equal(f.manifest().artifacts.length, 1);
assert.match(
readFileSync(join(f.repo, "src-tauri/Cargo.lock"), "utf8"),
/version = "1.2.1"/,
);
const repeat = f.release("-Version", "1.2.1");
assert.notEqual(repeat.status, 0);
assert.equal(f.git("rev-parse", "HEAD"), head);
});
test("failed build creates no commit/tag/push and preserves existing staging", (t) => {
const f = fixture(t, "throw 'Synthetic build failure'");
f.write("staged.txt", "staged");
f.git("add", "staged.txt");
const index = f.git("write-tree"),
head = f.git("rev-parse", "HEAD");
assert.notEqual(f.release("-Version", "1.2.1").status, 0);
assert.equal(f.git("write-tree"), index);
assert.equal(f.git("rev-parse", "HEAD"), head);
assert.equal(f.git("tag", "--list"), "");
});
test("source edit during build refuses to tag an artifact from another tree", (t) => {
const f = fixture(
t,
"[IO.File]::WriteAllText((Join-Path $RepoRoot 'concurrent.txt'), 'changed during build')",
);
const head = f.git("rev-parse", "HEAD");
const result = f.release("-Version", "1.2.1");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /changed during the build/);
assert.match(result.stderr, /concurrent\.txt/);
assert.equal(f.git("rev-parse", "HEAD"), head);
assert.equal(f.git("tag", "--list"), "");
});
test("failed atomic push keeps artifacts and resumes without rebuilding", (t) => {
const f = fixture(t);
const hook = join(f.remote, "hooks/pre-receive");
writeFileSync(hook, "#!/bin/sh\nexit 1\n");
const before = f.git("ls-remote", "origin", "refs/heads/master");
const failed = f.release("-Version", "1.2.1");
assert.notEqual(failed.status, 0);
assert.equal(f.manifest().gitRelease.status, "pending-push");
assert.equal(f.git("ls-remote", "origin", "refs/heads/master"), before);
assert.equal(f.git("ls-remote", "origin", "refs/tags/v1.2.1"), "");
const commit = f.git("rev-parse", "HEAD");
rmSync(hook);
const resumed = f.release("-Version", "1.2.1", "-Resume");
assert.equal(resumed.status, 0, resumed.stdout + resumed.stderr);
assert.equal(f.git("rev-parse", "HEAD"), commit);
assert.equal(f.manifest().gitRelease.status, "pushed");
f.write(
"releases/proxywarden-v1.2.1/artifacts/nsis/ProxyWarden_1.2.1_x64-setup.exe",
"tampered",
);
assert.notEqual(f.release("-Version", "1.2.1", "-Resume").status, 0);
});
test("remote-only version tag and diverged branch are refused before version edits", (t) => {
const f = fixture(t);
f.git("tag", "v1.2.1");
f.git("push", "origin", "refs/tags/v1.2.1");
f.git("tag", "-d", "v1.2.1");
const version = readFileSync(join(f.repo, "package.json"), "utf8");
assert.notEqual(f.release("-Version", "1.2.1").status, 0);
assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version);
const clone = join(f.root, "other");
run(f.root, "git", ["clone", "--branch", "master", f.remote, clone]);
run(clone, "git", ["config", "user.name", "Other"]);
run(clone, "git", ["config", "user.email", "other@example.invalid"]);
writeFileSync(join(clone, "remote-change.txt"), "remote");
run(clone, "git", ["add", "."]);
run(clone, "git", ["commit", "-m", "remote change"]);
run(clone, "git", ["push"]);
const result = f.release("-Version", "1.2.2");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Integrate them before releasing/);
assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version);
});
test("invalid Windows versions and mismatched Cargo.lock fail without mutations", (t) => {
const f = fixture(t);
for (const version of ["01.2.3", "1.2.65536", "1.2.3-rc.1"])
assert.notEqual(f.release("-Version", version, "-PlanOnly").status, 0);
f.write(
"src-tauri/Cargo.lock",
'[[package]]\nname = "proxywarden"\nversion = "0.0.0"\n',
);
const result = f.release("-PlanOnly");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Version mismatch/);
});
test("unreachable origin reports the Git cause and diagnostic command without a PowerShell stack", (t) => {
const f = fixture(t);
f.git("remote", "set-url", "origin", join(f.root, "absent.git"));
const head = f.git("rev-parse", "HEAD");
const version = readFileSync(join(f.repo, "package.json"), "utf8");
const result = f.release("-Version", "1.2.1");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /git ls-remote origin/);
assert.match(result.stderr, /does not appear to be a git repository/);
assert.doesNotMatch(
result.stderr,
/prepare-release\.ps1:\d|ScriptStackTrace|Line \|/,
);
assert.equal(f.git("rev-parse", "HEAD"), head);
assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version);
});
+301 -21
View File
@@ -1,4 +1,4 @@
param(
param(
[string]$Version = "",
[ValidateSet("", "patch", "minor", "major")]
[string]$Bump = "",
@@ -6,9 +6,12 @@ param(
[switch]$SkipTests,
[switch]$SkipBuild,
[switch]$PlanOnly,
[switch]$Publish,
[switch]$Resume,
[switch]$Force
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
@@ -16,7 +19,11 @@ $PackageJsonPath = Join-Path $RepoRoot "package.json"
$PackageLockPath = Join-Path $RepoRoot "package-lock.json"
$TauriConfigPath = Join-Path $RepoRoot "src-tauri\tauri.conf.json"
$CargoTomlPath = Join-Path $RepoRoot "src-tauri\Cargo.toml"
$CargoLockPath = Join-Path $RepoRoot "src-tauri\Cargo.lock"
$BundleRoot = Join-Path $RepoRoot "src-tauri\target\release\bundle"
$RuntimeBoundaryCheckPath = Join-Path $RepoRoot "scripts\check-runtime-powershell-boundary.ps1"
$ComponentBundleScriptPath = Join-Path $RepoRoot "scripts\update-component-bundle.ps1"
$WindowsAuditScriptPath = Join-Path $RepoRoot "scripts\audit-windows-smoke.ps1"
function Write-Utf8NoBomFile {
param(
@@ -143,9 +150,12 @@ function Set-PackageLockVersions {
function Assert-Semver {
param([string]$Value)
if ($Value -notmatch "^\d+\.\d+\.\d+$") {
if ($Value -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$') {
throw "Version '$Value' is not supported. Use numeric SemVer like 0.1.0."
}
foreach ($part in $Value.Split('.')) {
if ([long]$part -gt 65535) { throw "Version components must be between 0 and 65535 for Windows." }
}
}
function ConvertTo-VersionParts {
@@ -206,6 +216,9 @@ function Get-CargoPackageVersion {
function Get-VersionState {
$packageLock = Get-PackageLockVersions
$cargoLock = Get-Content -Raw -LiteralPath $CargoLockPath
$cargoMatch = [regex]::Match($cargoLock, '(?m)^name = "proxywarden"\r?\nversion = "([^"]+)"')
if (-not $cargoMatch.Success) { throw 'Cannot find ProxyWarden in Cargo.lock.' }
[ordered]@{
packageJson = [string](Get-FirstJsonVersion -Path $PackageJsonPath -Label "package.json")
@@ -213,6 +226,7 @@ function Get-VersionState {
packageLockRoot = [string]$packageLock.packageLockRoot
tauriConfig = [string](Get-FirstJsonVersion -Path $TauriConfigPath -Label "tauri.conf.json")
cargoToml = [string](Get-CargoPackageVersion)
cargoLock = $cargoMatch.Groups[1].Value
}
}
@@ -223,7 +237,8 @@ function Get-CurrentVersion {
$state.packageLock,
$state.packageLockRoot,
$state.tauriConfig,
$state.cargoToml
$state.cargoToml,
$state.cargoLock
) | Select-Object -Unique)
if ($versions.Count -ne 1) {
@@ -247,23 +262,24 @@ function Resolve-TargetVersion {
return Get-NextVersion -Current $Current -Kind $Bump
}
if ($PlanOnly -or -not [Environment]::UserInteractive) {
if ($PlanOnly) {
return Get-NextVersion -Current $Current -Kind "patch"
}
if (-not [Environment]::UserInteractive) { throw "Specify -Version or -Bump in non-interactive mode." }
$patch = Get-NextVersion -Current $Current -Kind "patch"
$minor = Get-NextVersion -Current $Current -Kind "minor"
$major = Get-NextVersion -Current $Current -Kind "major"
Write-Host ""
Write-Host "Current version: $Current"
Write-Host "Choose release version:"
Write-Host "Текущая версия: $Current"
Write-Host "Выбери номер или введи версию, например $patch :"
Write-Host " 1) patch $patch"
Write-Host " 2) minor $minor"
Write-Host " 3) major $major"
Write-Host " 4) custom"
Write-Host " 5) keep current $Current"
$choice = Read-Host "Selection [1]"
Write-Host " 4) другая версия"
Write-Host " 5) текущая $Current (если ещё не выпущена)"
$choice = Read-Host "Версия [1]"
if ([string]::IsNullOrWhiteSpace($choice)) { $choice = "1" }
@@ -277,7 +293,7 @@ function Resolve-TargetVersion {
return $custom
}
"5" { return $Current }
default { throw "Unknown selection '$choice'." }
default { Assert-Semver -Value $choice.Trim(); return $choice.Trim() }
}
}
@@ -313,6 +329,9 @@ function Set-ManifestVersions {
Set-PackageLockVersions -TargetVersion $TargetVersion
Set-FirstJsonVersion -Path $TauriConfigPath -TargetVersion $TargetVersion -Label "tauri.conf.json"
Set-CargoPackageVersion -TargetVersion $TargetVersion
$lock = Get-Content -Raw -LiteralPath $CargoLockPath
$lock = Replace-RegexGroup -Content $lock -Pattern '(?m)^name = "proxywarden"\r?\nversion = "(?<value>[^"]+)"' -GroupName "value" -Value $TargetVersion -Label "ProxyWarden version in Cargo.lock"
Write-Utf8NoBomFile -Path $CargoLockPath -Value $lock
}
function Get-FullPath {
@@ -354,6 +373,7 @@ function New-ReleaseDirectory {
$releaseDir = Join-Path $root "proxywarden-v$TargetVersion"
if (Test-Path -LiteralPath $releaseDir) {
if ($Publish -or -not $Force) { throw "Release directory already exists: $releaseDir. Use -Resume for a failed push, or choose another version." }
if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) {
throw "Refusing to remove release directory outside OutputRoot: $releaseDir"
}
@@ -412,17 +432,51 @@ function Invoke-ReleaseBuild {
return
}
Invoke-NativeCommand -Name "Frontend build" -FilePath "npm" -Arguments @("run", "build")
Invoke-NativeCommand -Name "Frontend types" -FilePath "node" -Arguments @("node_modules/typescript/bin/tsc", "--noEmit")
if (-not $SkipTests) {
Invoke-NativeCommand -Name "Rust tests" -FilePath "cargo" -Arguments @("test") -WorkingDirectory (Join-Path $RepoRoot "src-tauri")
Invoke-NativeCommand -Name "Frontend formatting" -FilePath "node" -Arguments @("node_modules/prettier/bin/prettier.cjs", "--check", "src/**/*.{ts,tsx,css}")
Invoke-NativeCommand -Name "Frontend lint" -FilePath "node" -Arguments @("node_modules/eslint/bin/eslint.js", "src")
Invoke-NativeCommand -Name "Frontend tests" -FilePath "node" -Arguments @("node_modules/vitest/vitest.mjs", "run")
Invoke-NativeCommand -Name "Rust formatting" -FilePath "cargo" -Arguments @("fmt", "--all", "--", "--check") -WorkingDirectory (Join-Path $RepoRoot "src-tauri")
Invoke-NativeCommand -Name "Rust lint" -FilePath "cargo" -Arguments @("clippy", "--locked", "--all-targets", "--all-features", "--", "-D", "warnings") -WorkingDirectory (Join-Path $RepoRoot "src-tauri")
Invoke-NativeCommand -Name "Rust tests" -FilePath "cargo" -Arguments @("test", "--locked", "--all-targets") -WorkingDirectory (Join-Path $RepoRoot "src-tauri")
} else {
Write-Host ""
Write-Host "Skipping Rust tests because -SkipTests was provided."
}
Invoke-NativeCommand -Name "Frontend build" -FilePath "node" -Arguments @("node_modules/vite/bin/vite.js", "build")
Clear-ReleaseBundleOutput
Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build")
# Use a temporary config file: JSON command-line quoting differs between Windows PowerShell and pwsh.
$config = Join-Path ([IO.Path]::GetTempPath()) ("proxywarden-build-" + [guid]::NewGuid().ToString('N') + '.json')
try {
Write-Utf8NoBomFile -Path $config -Value '{"build":{"beforeBuildCommand":""}}'
Invoke-NativeCommand -Name "Tauri release build" -FilePath "node" -Arguments @("node_modules/@tauri-apps/cli/tauri.js", "build", "--config", $config, "--bundles", "nsis")
} finally { if (Test-Path -LiteralPath $config) { Remove-Item -LiteralPath $config } }
}
function Invoke-ScriptCheck {
param(
[string]$Name,
[string]$ScriptPath,
[hashtable]$Parameters
)
Write-Host ""
Write-Host "==> $Name"
$output = & $ScriptPath @Parameters
$succeeded = $?
$output | Write-Output
if (-not $succeeded) {
throw "$Name failed."
}
}
function Invoke-ReleaseChecks {
Invoke-ScriptCheck -Name "Runtime PowerShell boundary" -ScriptPath $RuntimeBoundaryCheckPath -Parameters @{ CheckOnly = $true }
Invoke-ScriptCheck -Name "Offline component bundle" -ScriptPath $ComponentBundleScriptPath -Parameters @{ CheckOnly = $true }
Invoke-ScriptCheck -Name "Windows smoke evidence plan" -ScriptPath $WindowsAuditScriptPath -Parameters @{ Mode = "PlanOnly" }
}
function Get-ArtifactVersionPattern {
@@ -503,7 +557,7 @@ function Get-GitValue {
param([string[]]$Arguments)
try {
$value = & git @Arguments 2>$null
$value = & git --no-optional-locks @Arguments 2>$null
if ($LASTEXITCODE -eq 0) {
return ($value -join [Environment]::NewLine).Trim()
}
@@ -511,11 +565,176 @@ function Get-GitValue {
return ""
}
function Get-GitFailureMessage {
param([string]$Operation, [int]$ExitCode, [string]$Diagnostic)
$reason = if ($Diagnostic -match 'Too many authentication failures') {
'SSH-сервер отклонил слишком много попыток входа. Укажи правильный ключ и IdentitiesOnly yes для этого Git-сервера.'
} elseif ($Diagnostic -match 'Permission denied \(publickey|Authentication failed|could not read Username|terminal prompts disabled') {
'Сервер Git отклонил вход. Проверь SSH-ключ или HTTPS-аутентификацию и доступ к репозиторию.'
} elseif ($Diagnostic -match 'Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED') {
'Не подтверждён SSH-ключ сервера. Проверь его отпечаток перед повторным подключением.'
} elseif ($Diagnostic -match 'Could not resolve|Connection timed out|Connection refused|Network is unreachable|connect to host.*Permission denied|Failed to connect') {
'Не удалось подключиться к Git-серверу. Проверь сеть/VPN, адрес и порт origin.'
} elseif ($Diagnostic -match 'not found|does not appear to be a git repository') {
'Репозиторий недоступен по адресу origin. Проверь URL и права доступа.'
} else {
"Git не выполнил операцию $Operation (код $ExitCode)."
}
$details = "$Diagnostic".Trim() -replace '(https?://)[^/\s@]+@', '$1[redacted]@' -replace '(https?://[^\s?#]+)[?#][^\s]*', '$1'
if ($details.Length -gt 2500) { $details = $details.Substring(0, 2500) + '...' }
$next = if ($Operation -in @('ls-remote', 'fetch')) {
'Проверка origin завершилась до изменения версии, сборки, commit, tag и push. Для диагностики запусти: git ls-remote origin'
} else { 'Подробности ответа Git приведены ниже.' }
return "$reason`n$next`n`nОтвет Git:`n$details"
}
function Invoke-Git {
param([string[]]$Arguments)
$stderrPath = Join-Path ([IO.Path]::GetTempPath()) ("proxywarden-git-" + [guid]::NewGuid().ToString('N') + '.log')
$previousPreference = $ErrorActionPreference
try {
# Windows PowerShell wraps redirected stderr as NativeCommandError; preserve it,
# then classify by the actual exit code instead of losing the original cause.
$ErrorActionPreference = 'Continue'
$output = & git @Arguments 2>$stderrPath
$exitCode = $LASTEXITCODE
$ErrorActionPreference = $previousPreference
[string]$diagnostic = ''
if (Test-Path -LiteralPath $stderrPath) { $diagnostic = [string](Get-Content -Raw -LiteralPath $stderrPath) }
if ($exitCode -ne 0) { throw (Get-GitFailureMessage -Operation $Arguments[0] -ExitCode $exitCode -Diagnostic $diagnostic) }
if (-not [string]::IsNullOrWhiteSpace($diagnostic)) { Write-Host $diagnostic.Trim() }
return ([string]($output -join "`n")).Trim()
} finally {
$ErrorActionPreference = $previousPreference
if (Test-Path -LiteralPath $stderrPath) { Remove-Item -LiteralPath $stderrPath }
}
}
function Get-SourceTree {
# Snapshot tracked + non-ignored new files without touching the user's staging area.
$previousIndex = $env:GIT_INDEX_FILE
$index = Join-Path ([IO.Path]::GetTempPath()) ("proxywarden-index-" + [guid]::NewGuid().ToString('N'))
try {
$env:GIT_INDEX_FILE = $index
Invoke-Git @('read-tree', 'HEAD') | Out-Null
Invoke-Git @('add', '-A', '--', '.') | Out-Null
return Invoke-Git @('write-tree')
} finally {
$env:GIT_INDEX_FILE = $previousIndex
foreach ($path in @($index, "$index.lock")) {
if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path }
}
}
}
function Get-ReleasePath {
param([string]$TargetVersion)
$root = if ([IO.Path]::IsPathRooted($OutputRoot)) { $OutputRoot } else { Join-Path $RepoRoot $OutputRoot }
return [IO.Path]::GetFullPath((Join-Path $root "proxywarden-v$TargetVersion"))
}
function Test-GitTag {
param([string]$Tag)
& git show-ref --verify --quiet "refs/tags/$Tag"
if ($LASTEXITCODE -eq 0) { return $true }
if ($LASTEXITCODE -ne 1) { throw "Cannot inspect local tag $Tag." }
return $false
}
function Get-ReleaseGitContext {
param([string]$TargetVersion)
$branch = Invoke-Git @('symbolic-ref', '--quiet', '--short', 'HEAD')
$headCommit = Invoke-Git @('rev-parse', 'HEAD')
foreach ($marker in @('MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply')) {
$path = Invoke-Git @('rev-parse', '--git-path', $marker)
if (Test-Path -LiteralPath $path) { throw "Finish the active Git operation before releasing ($marker)." }
}
if (Invoke-Git @('diff', '--name-only', '--diff-filter=U')) { throw 'Resolve Git conflicts before releasing.' }
Invoke-Git @('var', 'GIT_AUTHOR_IDENT') | Out-Null
Invoke-Git @('var', 'GIT_COMMITTER_IDENT') | Out-Null
$remote = Invoke-Git @('remote', 'get-url', '--push', 'origin')
$tag = "v$TargetVersion"
if (-not $Resume -and (Test-GitTag $tag)) { throw "Tag $tag already exists. Use -Version $TargetVersion -Resume for a failed push, or choose another version." }
$remoteTag = Invoke-Git @('ls-remote', '--tags', 'origin', "refs/tags/$tag", "refs/tags/$tag^{}")
if (-not $Resume -and $remoteTag) { throw "Remote tag $tag already exists. Choose another version." }
$remoteBranch = Invoke-Git @('ls-remote', '--heads', 'origin', "refs/heads/$branch")
if ($remoteBranch) {
Invoke-Git @('fetch', '--no-tags', 'origin', "refs/heads/$branch") | Out-Null
& git merge-base --is-ancestor FETCH_HEAD HEAD
if ($LASTEXITCODE -ne 0) { throw "The origin/$branch branch has changes not in HEAD. Integrate them before releasing; automatic merge is not performed." }
}
return @{ branch = $branch; head = $headCommit; remote = $remote; tag = $tag }
}
function Complete-ReleaseGit {
param([hashtable]$Context, [string]$SourceTree, [string]$TargetVersion)
$currentTree = Get-SourceTree
if ((Invoke-Git @('rev-parse', 'HEAD')) -ne $Context.head -or
(Invoke-Git @('symbolic-ref', '--quiet', '--short', 'HEAD')) -ne $Context.branch -or
$currentTree -ne $SourceTree) {
$changed = Invoke-Git @('-c', 'core.quotepath=false', 'diff', '--name-only', $SourceTree, $currentTree)
throw "Source files or HEAD changed during the build. No release commit/tag was created.`nИсходники изменились во время сборки. Повтори сборку после завершения правок.`nИзменённые файлы:`n$changed"
}
if ((Invoke-Git @('rev-parse', 'HEAD^{tree}')) -ne $SourceTree) {
Invoke-Git @('add', '-A', '--', '.') | Out-Null
if ((Invoke-Git @('write-tree')) -ne $SourceTree) { throw 'Staged source changed. Rebuild before releasing.' }
Invoke-Git @('commit', '-m', "Release v$TargetVersion") | Write-Host
}
if ((Invoke-Git @('rev-parse', 'HEAD^{tree}')) -ne $SourceTree -or
(Get-SourceTree) -ne $SourceTree) { throw 'A Git hook changed source files. Rebuild before tagging.' }
return Invoke-Git @('rev-parse', 'HEAD')
}
function Push-Release {
param([hashtable]$Context, [string]$Commit)
if ((Invoke-Git @('rev-parse', 'HEAD')) -ne $Commit -or
(Invoke-Git @('symbolic-ref', '--quiet', '--short', 'HEAD')) -ne $Context.branch -or
(Invoke-Git @('remote', 'get-url', '--push', 'origin')) -ne $Context.remote) {
throw 'HEAD, branch or origin changed before push.'
}
if (Test-GitTag $Context.tag) {
if ((Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit) { throw 'Existing tag points to another commit.' }
} else {
Invoke-Git @('tag', '-a', $Context.tag, $Commit, '-m', "ProxyWarden $($Context.tag)") | Out-Null
}
# One atomic push; never force or push unrelated tags. A failure leaves a resumable local release.
Invoke-Git @('push', '--atomic', 'origin', "${Commit}:refs/heads/$($Context.branch)", "refs/tags/$($Context.tag):refs/tags/$($Context.tag)") | Write-Host
}
function Resume-Release {
param([string]$TargetVersion, [hashtable]$Context)
$releaseDir = Get-ReleasePath $TargetVersion
$manifestPath = Join-Path $releaseDir 'release-manifest.json'
$manifest = Read-JsonFile $manifestPath
if (-not $manifest.PSObject.Properties['gitRelease'] -or -not $manifest.gitRelease) {
throw 'This folder has no completed release commit. Resume only retries a failed push; choose a new version and rebuild.'
}
if ($manifest.version -ne $TargetVersion -or $manifest.gitRelease.branch -ne $Context.branch -or
$manifest.gitRelease.remote -ne $Context.remote -or $manifest.gitRelease.tag -ne $Context.tag -or
$manifest.gitRelease.status -notin @('pending-push', 'pushed') -or
$manifest.gitCommit -ne $Context.head -or
(Invoke-Git @('rev-parse', 'HEAD^{tree}')) -ne $manifest.gitRelease.sourceTree -or
(Get-SourceTree) -ne $manifest.gitRelease.sourceTree) {
throw 'This release no longer matches HEAD/source/origin. Resume refused; use a new version.'
}
if (@($manifest.artifacts).Count -eq 0) { throw 'No artifacts to resume.' }
foreach ($artifact in $manifest.artifacts) {
$path = [IO.Path]::GetFullPath((Join-Path $releaseDir $artifact.path))
if (-not (Test-IsSubPath $releaseDir $path) -or
(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $artifact.sha256) { throw 'Release artifact checksum mismatch.' }
}
Push-Release -Context $Context -Commit $manifest.gitCommit
$manifest.gitRelease.status = 'pushed'
Write-JsonFile -Path $manifestPath -Value $manifest
Write-Host "Релиз отправлен. Файлы для сайта: $releaseDir"
}
function Write-ReleaseMetadata {
param(
[string]$ReleaseDir,
[string]$TargetVersion,
[object[]]$Artifacts
[object[]]$Artifacts,
[object]$GitRelease = $null
)
$artifactDir = Join-Path $ReleaseDir "artifacts"
@@ -534,6 +753,8 @@ function Write-ReleaseMetadata {
source = "local"
gitCommit = Get-GitValue -Arguments @("rev-parse", "HEAD")
gitStatus = Get-GitValue -Arguments @("status", "--short")
gitRelease = $GitRelease
windowsAcceptance = "not-verified-by-this-command"
artifacts = @($artifactItems)
}
@@ -560,7 +781,7 @@ See `SHA256SUMS.txt`.
## Release boundary
This release contains the ProxyWarden Control App only. ProxiFyre and Local sing-box remain explicit user-managed components.
The ProxyWarden installer contains pinned offline payloads for ProxiFyre, Windows Packet Filter, VC++ Runtime, sing-box, WinSW, and WebView2. Installing, updating, starting, stopping, or removing routing components remains an explicit user action.
"@
@@ -590,16 +811,31 @@ function New-PlanResult {
releaseDirectory = (Join-Path $outputRootFull "proxywarden-v$Target")
skipTests = [bool]$SkipTests
skipBuild = [bool]$SkipBuild
publish = [bool]$Publish
resume = [bool]$Resume
git = [ordered]@{
branch = Get-GitValue @('symbolic-ref', '--quiet', '--short', 'HEAD')
remote = 'origin'
tag = "v$Target"
includedChanges = Get-GitValue @('status', '--short')
commitAfterSuccessfulBuild = [bool]$Publish
atomicPush = [bool]$Publish
}
manifests = @(
$PackageJsonPath,
$PackageLockPath,
$TauriConfigPath,
$CargoTomlPath
$CargoTomlPath,
$CargoLockPath
)
commands = @(
"npm run build",
"cd src-tauri; cargo test",
"npm run tauri -- build"
".\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly",
".\scripts\update-component-bundle.ps1 -CheckOnly",
".\scripts\audit-windows-smoke.ps1 -Mode PlanOnly",
"node: TypeScript, Prettier, ESLint, Vitest, Vite",
"cargo fmt / clippy --locked / test --locked --all-targets",
"node: Tauri build --bundles nsis",
"if -Publish: commit source, annotated version tag, atomic branch+tag push to origin"
)
}
} | ConvertTo-Json -Depth 8
@@ -608,6 +844,15 @@ function New-PlanResult {
try {
Push-Location $RepoRoot
if ($Resume -and (-not $Publish -or -not $Version -or $Bump)) { throw '-Resume requires -Publish -Version X.Y.Z.' }
if ($Version -and $Bump) { throw 'Use either -Version or -Bump.' }
if ($Publish -and -not $PlanOnly -and ($SkipTests -or $SkipBuild -or $Force)) { throw 'A published release requires checks and a fresh build; SkipTests, SkipBuild and Force are not allowed.' }
if ($Publish -and -not $PlanOnly -and -not $Resume) {
Write-Host 'В релиз войдут все изменения Git ниже (кроме игнорируемых файлов).'
Write-Host 'После успешной сборки: commit, тег версии и push текущей ветки в origin.'
Write-Host 'Файлы установщика останутся локально для загрузки на сайт.'
Write-Host (Invoke-Git @('status', '--short'))
}
$currentVersion = Get-CurrentVersion
$targetVersion = Resolve-TargetVersion -Current $currentVersion
Assert-Semver -Value $targetVersion
@@ -625,12 +870,34 @@ try {
Write-Host "Preparing ProxyWarden release $targetVersion..."
Write-Host "Repository: $RepoRoot"
$gitContext = $null
if ($Publish) {
$gitContext = Get-ReleaseGitContext $targetVersion
if ($Resume) { Resume-Release -TargetVersion $targetVersion -Context $gitContext; return }
}
$releasePath = Get-ReleasePath $targetVersion
if ((Test-Path -LiteralPath $releasePath) -and ($Publish -or -not $Force)) {
throw "Release directory already exists: $releasePath. Use -Resume for a failed push, or choose another version."
}
if ($Publish -and (Test-IsSubPath $RepoRoot $releasePath)) {
& git check-ignore --quiet -- (Join-Path $releasePath 'release-manifest.json')
if ($LASTEXITCODE -ne 0) { throw 'OutputRoot must be ignored by Git, or outside the repository.' }
}
if (-not $SkipBuild) {
Get-Command node, cargo -ErrorAction Stop | Out-Null
foreach ($cli in @('typescript/bin/tsc', 'vite/bin/vite.js', '@tauri-apps/cli/tauri.js', 'prettier/bin/prettier.cjs', 'eslint/bin/eslint.js', 'vitest/vitest.mjs')) {
if (-not (Test-Path -LiteralPath (Join-Path $RepoRoot "node_modules/$cli"))) { throw 'Frontend dependencies are missing. Run npm ci once, then retry release.' }
}
}
Invoke-ReleaseChecks
Set-ManifestVersions -TargetVersion $targetVersion
$afterUpdateVersion = Get-CurrentVersion
if ($afterUpdateVersion -ne $targetVersion) {
throw "Version update failed. Current version is $afterUpdateVersion."
}
$sourceTree = if ($Publish) { Get-SourceTree } else { $null }
Invoke-ReleaseBuild
$releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion
@@ -638,11 +905,24 @@ try {
Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts
if ($Publish) {
$commit = Complete-ReleaseGit -Context $gitContext -SourceTree $sourceTree -TargetVersion $targetVersion
$gitRelease = [ordered]@{ branch = $gitContext.branch; remote = $gitContext.remote; tag = $gitContext.tag; sourceTree = $sourceTree; status = 'pending-push' }
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts -GitRelease $gitRelease
try { Push-Release -Context $gitContext -Commit $commit }
catch { throw "Push failed; local release is preserved. Retry: .\release.cmd -Version $targetVersion -Resume. $($_.Exception.Message)" }
$gitRelease.status = 'pushed'
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts -GitRelease $gitRelease
}
Write-Host ""
Write-Host "Release folder is ready:"
Write-Host $releaseDir
Write-Host ""
Write-Host "Upload the files from the release folder to GitHub release v$targetVersion."
Write-Host 'Загрузи EXE из artifacts\nsis на сайт. SHA256SUMS.txt содержит контрольную сумму.'
} catch {
[Console]::Error.WriteLine("`nРелиз не завершён.`n" + $_.Exception.Message)
exit 1
} finally {
Pop-Location
}
File diff suppressed because it is too large Load Diff
-143
View File
@@ -1,143 +0,0 @@
param(
[string]$OutputDir = (Join-Path $PSScriptRoot '..\src-tauri\bundled\proxifyre'),
[ValidateSet('x64', 'x86', 'ARM64')]
[string[]]$Architectures = @('x64'),
[switch]$SkipVcRuntime
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$ProgressPreference = 'SilentlyContinue'
function Invoke-JsonApi([string]$Uri) {
Invoke-RestMethod -Uri $Uri -Headers @{
'User-Agent' = 'proxywarden-bundle-updater'
'Accept' = 'application/vnd.github+json'
} -TimeoutSec 60 -MaximumRedirection 10
}
function Invoke-FileDownload([string]$Uri, [string]$Path) {
$partialPath = "$Path.part"
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
try {
Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $partialPath -Headers @{
'User-Agent' = 'proxywarden-bundle-updater'
'Accept' = 'application/octet-stream,*/*'
} -TimeoutSec 240 -MaximumRedirection 10
} catch {
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
throw
}
$item = Get-Item -LiteralPath $partialPath
if ($item.Length -le 0) {
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
throw "Downloaded file is empty: $Uri"
}
Move-Item -LiteralPath $partialPath -Destination $Path -Force
}
function Select-ReleaseAsset($Release, [string]$Pattern, [string]$Label) {
$asset = $Release.assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1
if ($null -eq $asset) {
throw "No asset found for $Label using pattern $Pattern"
}
$asset
}
function Save-Asset([string]$Id, [string]$Name, [string]$Url, [string]$ExpectedDigest = '') {
$path = Join-Path $OutputDir $Name
if (Test-Path -LiteralPath $path) {
$existing = Get-Item -LiteralPath $path
if ($existing.Length -gt 0) {
$existingHash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
$expectedHash = ''
if (-not [string]::IsNullOrWhiteSpace($ExpectedDigest) -and $ExpectedDigest -match '^sha256:(.+)$') {
$expectedHash = $Matches[1].ToLowerInvariant()
}
if ([string]::IsNullOrWhiteSpace($expectedHash) -or $existingHash -eq $expectedHash) {
Write-Host "Using existing $Name"
return [PSCustomObject]@{
id = $Id
name = $Name
sha256 = $existingHash
size = $existing.Length
sourceUrl = $Url
}
}
}
}
Write-Host "Downloading $Name"
Invoke-FileDownload $Url $path
$hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
if (-not [string]::IsNullOrWhiteSpace($ExpectedDigest) -and $ExpectedDigest -match '^sha256:(.+)$') {
$expected = $Matches[1].ToLowerInvariant()
if ($hash -ne $expected) {
throw "SHA256 mismatch for $Name. Expected $expected, got $hash."
}
}
[PSCustomObject]@{
id = $Id
name = $Name
sha256 = $hash
size = (Get-Item -LiteralPath $path).Length
sourceUrl = $Url
}
}
$resolvedOutputDir = [System.IO.Path]::GetFullPath($OutputDir)
New-Item -ItemType Directory -Force -Path $resolvedOutputDir | Out-Null
$OutputDir = $resolvedOutputDir
$selectedArchitectures = $Architectures |
ForEach-Object {
if ($_ -eq 'ARM64') { 'ARM64' } elseif ($_ -eq 'x86') { 'x86' } else { 'x64' }
} |
Select-Object -Unique
$proxifyreRelease = Invoke-JsonApi 'https://api.github.com/repos/wiresock/proxifyre/releases/latest'
$ndisapiRelease = Invoke-JsonApi 'https://api.github.com/repos/wiresock/ndisapi/releases/latest'
$files = New-Object System.Collections.Generic.List[object]
foreach ($arch in $selectedArchitectures) {
$proxifyreAsset = Select-ReleaseAsset $proxifyreRelease "ProxiFyre-.*-$arch-signed\.zip$" "ProxiFyre $arch"
$files.Add((Save-Asset "proxifyre-$($arch.ToLowerInvariant())" $proxifyreAsset.name $proxifyreAsset.browser_download_url $proxifyreAsset.digest))
$ndisAsset = Select-ReleaseAsset $ndisapiRelease "Windows\.Packet\.Filter\..*\.$arch\.msi$" "Windows Packet Filter $arch"
$files.Add((Save-Asset "packet-filter-$($arch.ToLowerInvariant())" $ndisAsset.name $ndisAsset.browser_download_url $ndisAsset.digest))
}
if (-not $SkipVcRuntime) {
if ($selectedArchitectures | Where-Object { $_ -ne 'x86' }) {
$files.Add((Save-Asset 'vc-runtime-x64' 'vc_redist.x64.exe' 'https://aka.ms/vc14/vc_redist.x64.exe'))
}
if ($selectedArchitectures -contains 'x86') {
$files.Add((Save-Asset 'vc-runtime-x86' 'vc_redist.x86.exe' 'https://aka.ms/vc14/vc_redist.x86.exe'))
}
}
$manifest = [PSCustomObject]@{
generatedAt = (Get-Date).ToUniversalTime().ToString('o')
architectures = @($selectedArchitectures)
proxifyreRelease = $proxifyreRelease.tag_name
windowsPacketFilterRelease = $ndisapiRelease.tag_name
files = $files
}
$manifestPath = Join-Path $OutputDir 'manifest.json'
$manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
$keepNames = @($files | ForEach-Object { $_.name }) + 'manifest.json'
Get-ChildItem -LiteralPath $OutputDir -File |
Where-Object { $keepNames -notcontains $_.Name } |
ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force }
Write-Host "Bundle updated: $OutputDir"