Files
waggle-os/app/scripts/new-windows-signing-handoff.ps1
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

1060 lines
41 KiB
PowerShell

[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$SourceTargetRoot,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$DestinationRoot,
[string]$GitPath = 'C:\Program Files\Git\cmd\git.exe',
[string]$NodePath = ''
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ApprovedRepository = 'marolinik/waggle-os'
$TargetTriple = 'x86_64-pc-windows-msvc'
$ExpectedNodeVersion = 'v22.23.2'
$ExpectedNsisFileCount = 442
$ExpectedNsisInventorySha256 = '1FC822D1A183552A80ADEA01B0BF456F462B90518256EF1FE9EDFA22D76CD85A'
$OpenJsPublisher = 'CN=OpenJS Foundation, O=OpenJS Foundation, L=San Francisco, S=California, C=US'
$GitPublisher = 'CN=Johannes Schindelin, O=Johannes Schindelin, S=Nordrhein-Westfalen, C=DE'
$CodeSigningOid = '1.3.6.1.5.5.7.3.3'
$MaxTargetFileCount = 18000
$MaxTargetBytes = 768MB
$MaxResourceFileCount = 17500
$MaxResourceBytes = 600MB
function Get-HandoffHex {
param([Parameter(Mandatory = $true)] [byte[]]$Bytes)
return ([BitConverter]::ToString($Bytes) -replace '-', '')
}
function Get-HandoffBytesSha256 {
param([Parameter(Mandatory = $true)] [byte[]]$Bytes)
$algorithm = [Security.Cryptography.SHA256]::Create()
try {
return Get-HandoffHex ($algorithm.ComputeHash($Bytes))
} finally {
$algorithm.Dispose()
}
}
function Get-HandoffNormalizedGitBlobSha1 {
param([Parameter(Mandatory = $true)] [string]$Path)
$strictUtf8 = [Text.UTF8Encoding]::new($false, $true)
try {
$text = $strictUtf8.GetString([IO.File]::ReadAllBytes($Path))
} catch {
throw 'Tracked handoff input is not canonical UTF-8 text.'
}
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) {
throw 'Tracked handoff input contains a UTF-8 BOM.'
}
$normalized = $text.Replace("`r`n", "`n")
if ($normalized.Contains("`r")) {
throw 'Tracked handoff input contains a non-canonical carriage return.'
}
$contentBytes = [Text.UTF8Encoding]::new($false).GetBytes($normalized)
$headerBytes = [Text.Encoding]::ASCII.GetBytes("blob $($contentBytes.Length)`0")
$algorithm = [Security.Cryptography.SHA1]::Create()
try {
return Get-HandoffHex ($algorithm.ComputeHash([byte[]]($headerBytes + $contentBytes)))
} finally {
$algorithm.Dispose()
}
}
function Get-HandoffFileSha256 {
param([Parameter(Mandatory = $true)] [string]$Path)
$stream = [IO.File]::Open(
$Path,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
[IO.FileShare]::Read
)
$algorithm = [Security.Cryptography.SHA256]::Create()
try {
return Get-HandoffHex ($algorithm.ComputeHash($stream))
} finally {
$algorithm.Dispose()
$stream.Dispose()
}
}
function Assert-HandoffSignedExecutable {
param(
[Parameter(Mandatory = $true)] [string]$Path,
[Parameter(Mandatory = $true)] [string]$ExpectedPublisher,
[Parameter(Mandatory = $true)] [string]$Label
)
$signature = Get-AuthenticodeSignature -LiteralPath $Path
if ($signature.Status -ne [Management.Automation.SignatureStatus]::Valid -or
[string]$signature.SignatureType -cne 'Authenticode' -or
$null -eq $signature.SignerCertificate -or
-not [string]::Equals(
[string]$signature.SignerCertificate.Subject,
$ExpectedPublisher,
[StringComparison]::Ordinal
)) {
throw "$Label does not have the approved Authenticode publisher."
}
$hasCodeSigningEku = @(
$signature.SignerCertificate.Extensions |
Where-Object {
$_ -is [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]
} |
ForEach-Object { $_.EnhancedKeyUsages } |
Where-Object { $_.Value -eq $CodeSigningOid }
).Count -gt 0
if (-not $hasCodeSigningEku) { throw "$Label lacks the Code Signing EKU." }
}
function Get-HandoffTrustedPath {
param(
[Parameter(Mandatory = $true)] [string]$Path,
[Parameter(Mandatory = $true)] [string]$Label,
[ValidateSet('Leaf', 'Container')] [string]$PathType = 'Leaf',
[switch]$AllowHardLink
)
if ([string]::IsNullOrWhiteSpace($Path) -or
$Path -match '[\x00-\x1F\x7F]' -or
$Path -notmatch '^[A-Za-z]:[\\/]' -or
$Path -match '^[\\/]{2}' -or
$Path.Substring(2) -match ':' -or
$Path -match '(^|[\\/])\.\.?(?:[\\/]|$)' -or
@($Path -split '[\\/]' | Where-Object { $_ -match '[. ]$' }).Count -ne 0) {
throw "$Label must use a safe, fully qualified local Windows path."
}
$fullPath = [IO.Path]::GetFullPath($Path)
$testPathType = if ($PathType -ceq 'Leaf') { 'Leaf' } else { 'Container' }
if (-not (Test-Path -LiteralPath $fullPath -PathType $testPathType)) {
throw "$Label does not exist as a $($PathType.ToLowerInvariant()): $fullPath"
}
$root = [IO.Path]::GetPathRoot($fullPath)
$relative = $fullPath.Substring($root.Length)
$current = $root
foreach ($component in @($relative -split '[\\/]' | Where-Object { $_ })) {
$current = Join-Path $current $component
$item = Get-Item -LiteralPath $current -Force
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "$Label traverses a reparse point: $current"
}
$linkTypeProperty = $item.PSObject.Properties['LinkType']
if ($null -eq $linkTypeProperty) {
throw "$Label filesystem provider does not expose link topology."
}
$linkType = [string]$linkTypeProperty.Value
if (-not [string]::IsNullOrEmpty($linkType) -and
-not ($AllowHardLink -and $linkType -ceq 'HardLink')) {
throw "$Label traverses an unsupported linked filesystem object: $current"
}
}
$resolved = (Resolve-Path -LiteralPath $fullPath).ProviderPath
if (-not [string]::Equals(
[IO.Path]::GetFullPath($resolved),
$fullPath,
[StringComparison]::OrdinalIgnoreCase
)) {
throw "$Label resolves to an unexpected path."
}
return $fullPath
}
function Get-HandoffContainedRelativePath {
param(
[Parameter(Mandatory = $true)] [string]$Path,
[Parameter(Mandatory = $true)] [string]$Root
)
$fullPath = [IO.Path]::GetFullPath($Path)
$fullRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\')
$prefix = "$fullRoot\"
if (-not $fullPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) {
return $null
}
return $fullPath.Substring($prefix.Length).Replace('/', '\')
}
function Assert-HandoffCanonicalRelativePath {
param(
[Parameter(Mandatory = $true)] [string]$Path,
[Parameter(Mandatory = $true)] [string]$Label
)
if ([string]::IsNullOrWhiteSpace($Path) -or
$Path -match '[\x00-\x1F\x7F]' -or
$Path -match '(^|[\\/])\.\.?(?:[\\/]|$)' -or
[IO.Path]::IsPathRooted($Path) -or
$Path.Contains('/') -or
$Path.Contains(':') -or
@($Path -split '\\' | Where-Object { $_ -match '[. ]$' }).Count -ne 0) {
throw "$Label contains an unsafe or non-canonical relative path."
}
}
function Get-HandoffTreeFiles {
param(
[Parameter(Mandatory = $true)] [string]$Root,
[Parameter(Mandatory = $true)] [string]$Label,
[switch]$AllowCargoHardLinkPair,
[string]$CargoReleaseRoot = '',
[int]$MaxFileCount = [int]::MaxValue,
[long]$MaxBytes = [long]::MaxValue,
[int]$MaxDirectoryCount = [int]::MaxValue
)
if ($MaxFileCount -lt 1 -or $MaxBytes -lt 1 -or $MaxDirectoryCount -lt 1) {
throw "$Label inventory bound is invalid."
}
$trustedRoot = Get-HandoffTrustedPath $Root $Label 'Container'
$pending = [Collections.Generic.Queue[string]]::new()
$files = [Collections.Generic.List[object]]::new()
$exactPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
$foldedPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$totalBytes = [long]0
$directoryCount = 1
$pending.Enqueue($trustedRoot)
while ($pending.Count -gt 0) {
$directory = $pending.Dequeue()
$enumerator = [IO.Directory]::EnumerateFileSystemEntries($directory).GetEnumerator()
try {
while ($enumerator.MoveNext()) {
$item = Get-Item -LiteralPath ([string]$enumerator.Current) -Force
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "$Label contains a reparse point: $($item.FullName)"
}
$linkTypeProperty = $item.PSObject.Properties['LinkType']
if ($null -eq $linkTypeProperty) {
throw "$Label filesystem provider does not expose link topology."
}
$linkType = [string]$linkTypeProperty.Value
if ($item.PSIsContainer) {
if (-not [string]::IsNullOrEmpty($linkType)) {
throw "$Label contains a linked directory."
}
$childDirectory = [IO.Path]::GetFullPath($item.FullName)
if ($null -eq (Get-HandoffContainedRelativePath $childDirectory $trustedRoot)) {
throw "$Label directory escaped its root."
}
$directoryCount++
$directoryRelative = Get-HandoffContainedRelativePath $childDirectory $trustedRoot
if ($directoryCount -gt $MaxDirectoryCount -or
@($directoryRelative -split '\\').Count -gt 32) {
throw "$Label exceeds its deterministic directory-count or depth bound."
}
$pending.Enqueue($childDirectory)
continue
}
$path = [IO.Path]::GetFullPath($item.FullName)
$relative = Get-HandoffContainedRelativePath $path $trustedRoot
if ($null -eq $relative) { throw "$Label file escaped its root." }
Assert-HandoffCanonicalRelativePath $relative $Label
if (-not $exactPaths.Add($relative) -or -not $foldedPaths.Add($relative)) {
throw "$Label contains a duplicate path or case-insensitive collision."
}
$streams = @(Get-Item -LiteralPath $path -Stream * -ErrorAction Stop)
if ($streams.Count -ne 1 -or [string]$streams[0].Stream -cne ':$DATA') {
throw "$Label contains an alternate data stream."
}
if (-not [string]::IsNullOrEmpty($linkType)) {
if (-not $AllowCargoHardLinkPair -or $linkType -cne 'HardLink') {
throw "$Label contains an unsupported hard link."
}
$releaseRoot = [IO.Path]::GetFullPath($CargoReleaseRoot)
$main = [IO.Path]::GetFullPath((Join-Path $releaseRoot 'waggle.exe'))
$dependency = [IO.Path]::GetFullPath((Join-Path $releaseRoot 'deps\waggle.exe'))
if (-not [string]::Equals($path, $main, [StringComparison]::OrdinalIgnoreCase) -and
-not [string]::Equals($path, $dependency, [StringComparison]::OrdinalIgnoreCase)) {
throw "$Label permits only the exact Cargo executable hard-link pair."
}
}
$fileSize = [long]$item.Length
if ($files.Count -ge $MaxFileCount -or $fileSize -lt 0 -or
$totalBytes -gt $MaxBytes - $fileSize) {
throw "$Label exceeds its deterministic file-count or byte bound."
}
$totalBytes += $fileSize
$files.Add([pscustomobject]@{ FullName = $path; RelativePath = $relative })
}
} finally {
if ($enumerator -is [IDisposable]) { $enumerator.Dispose() }
}
}
return @($files)
}
function Assert-HandoffMappingBounds {
param(
[Parameter(Mandatory = $true)] [object[]]$Mappings,
[Parameter(Mandatory = $true)] [int]$MaxFileCount,
[Parameter(Mandatory = $true)] [long]$MaxBytes,
[Parameter(Mandatory = $true)] [string]$Label
)
if ($MaxFileCount -lt 1 -or $MaxBytes -lt 1 -or $Mappings.Count -gt $MaxFileCount) {
throw "$Label exceeds its deterministic file-count or byte bound."
}
$totalBytes = [long]0
foreach ($mapping in $Mappings) {
$size = [long](Get-Item -LiteralPath ([string]$mapping.Source) -Force).Length
if ($size -lt 0 -or $totalBytes -gt $MaxBytes - $size) {
throw "$Label exceeds its deterministic file-count or byte bound."
}
$totalBytes += $size
}
return $totalBytes
}
function Assert-HandoffDefaultIndexFlags {
param(
[Parameter(Mandatory = $true)] [string]$Git,
[Parameter(Mandatory = $true)] [string]$RepoRoot
)
foreach ($flag in @('-v', '-f')) {
$entries = @(& $Git -C $RepoRoot ls-files $flag --full-name)
if ($LASTEXITCODE -ne 0 -or $entries.Count -lt 100 -or
@($entries | Where-Object { [string]$_ -notmatch '^H [^\x00-\x1F\x7F]+$' }).Count -ne 0) {
throw 'Repository index contains assume-unchanged, skip-worktree, fsmonitor-valid, or non-default tracked state.'
}
}
$fsmonitor = @(& $Git -C $RepoRoot config --get-all core.fsmonitor)
$fsmonitorExitCode = $LASTEXITCODE
if (($fsmonitorExitCode -ne 0 -and $fsmonitorExitCode -ne 1) -or
$fsmonitor.Count -ne 0) {
throw 'Repository must not enable core.fsmonitor for the signing handoff.'
}
}
function Assert-HandoffRepositoryState {
param(
[Parameter(Mandatory = $true)] [string]$Git,
[Parameter(Mandatory = $true)] [string]$RepoRoot,
[Parameter(Mandatory = $true)] [string]$Revision,
[Parameter(Mandatory = $true)] [string]$AppVersion,
[Parameter(Mandatory = $true)] [string[]]$TrackedPaths
)
$actualRoot = [string](& $Git -C $RepoRoot rev-parse --show-toplevel)
$head = [string](& $Git -C $RepoRoot rev-parse --verify HEAD)
$tagCommit = [string](& $Git -C $RepoRoot rev-parse --verify "refs/tags/v$AppVersion`^{commit}")
$status = @(& $Git -C $RepoRoot status --porcelain=v1 --untracked-files=all)
if ($LASTEXITCODE -ne 0 -or
-not [string]::Equals(
[IO.Path]::GetFullPath($actualRoot),
[IO.Path]::GetFullPath($RepoRoot),
[StringComparison]::OrdinalIgnoreCase
) -or $head -cne $Revision -or $tagCommit -cne $Revision -or
$status.Count -ne 0) {
throw 'Windows signing handoff requires the clean exact tagged GITHUB_SHA.'
}
Assert-HandoffDefaultIndexFlags $Git $RepoRoot
foreach ($trackedPath in $TrackedPaths) {
& $Git -C $RepoRoot ls-files --error-unmatch -- $trackedPath | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Windows signing handoff input is not tracked: $trackedPath"
}
$expectedBlob = [string](& $Git -C $RepoRoot rev-parse --verify "HEAD:$trackedPath")
$actualBlob = Get-HandoffNormalizedGitBlobSha1 (Join-Path $RepoRoot $trackedPath)
if ($LASTEXITCODE -ne 0 -or $expectedBlob -notmatch '^[0-9a-f]{40}$' -or
-not [string]::Equals(
$expectedBlob,
$actualBlob,
[StringComparison]::OrdinalIgnoreCase
)) {
throw "Tracked handoff input differs from its exact HEAD blob: $trackedPath"
}
}
}
function Get-HandoffHardLinkPaths {
param([Parameter(Mandatory = $true)] [string]$Path)
$fsutil = Get-HandoffTrustedPath 'C:\Windows\System32\fsutil.exe' 'fsutil' -AllowHardLink
$raw = @(& $fsutil hardlink list $Path)
if ($LASTEXITCODE -ne 0 -or $raw.Count -ne 2) {
throw 'Cargo executable must have exactly two hard-link paths.'
}
$volumeRoot = [IO.Path]::GetPathRoot($Path).TrimEnd('\')
return @($raw | ForEach-Object {
if ([string]$_ -notmatch '^\\[^\\]') {
throw 'fsutil returned a non-canonical hard-link path.'
}
[IO.Path]::GetFullPath($volumeRoot + [string]$_)
})
}
function Assert-HandoffCargoPair {
param(
[Parameter(Mandatory = $true)] [string]$ReleaseRoot,
[Parameter(Mandatory = $true)] [string]$Label
)
$release = Get-HandoffTrustedPath $ReleaseRoot "$Label release root" 'Container'
$main = Get-HandoffTrustedPath (Join-Path $release 'waggle.exe') "$Label main" -AllowHardLink
$dependency = Get-HandoffTrustedPath `
(Join-Path $release 'deps\waggle.exe') "$Label dependency" -AllowHardLink
$links = @(Get-HandoffHardLinkPaths $main)
$set = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($link in $links) { [void]$set.Add($link) }
if ($set.Count -ne 2 -or -not $set.Contains($main) -or -not $set.Contains($dependency) -or
(Get-HandoffFileSha256 $main) -cne (Get-HandoffFileSha256 $dependency)) {
throw "$Label does not have the exact equal-byte release\waggle.exe and release\deps\waggle.exe hard-link pair."
}
}
function New-HandoffInventory {
param(
[Parameter(Mandatory = $true)] [object[]]$Mappings,
[Parameter(Mandatory = $true)] [string]$Label
)
$entries = [Collections.Generic.List[object]]::new()
$exactPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
$foldedPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($mapping in $Mappings) {
$relative = [string]$mapping.Path
Assert-HandoffCanonicalRelativePath $relative $Label
if (-not $exactPaths.Add($relative) -or -not $foldedPaths.Add($relative)) {
throw "$Label contains a duplicate path or case-insensitive collision."
}
$source = [IO.Path]::GetFullPath([string]$mapping.Source)
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
throw "$Label source file is missing."
}
$item = Get-Item -LiteralPath $source -Force
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "$Label source file became a reparse point."
}
$linkTypeProperty = $item.PSObject.Properties['LinkType']
if ($null -eq $linkTypeProperty -or
-not ([string]::IsNullOrEmpty([string]$linkTypeProperty.Value) -or
[string]$linkTypeProperty.Value -ceq 'HardLink')) {
throw "$Label source file has an unsupported link topology."
}
$streams = @(Get-Item -LiteralPath $source -Stream * -ErrorAction Stop)
if ($streams.Count -ne 1 -or [string]$streams[0].Stream -cne ':$DATA') {
throw "$Label source file contains an alternate data stream."
}
$lock = [IO.File]::Open(
$source,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
[IO.FileShare]::Read
)
try {
$entries.Add([pscustomobject][ordered]@{
path = $relative
size = [long]$item.Length
sha256 = Get-HandoffFileSha256 $source
})
} finally {
$lock.Dispose()
}
}
$sorted = @($entries)
[Array]::Sort($sorted, [Comparison[object]]{
param($left, $right)
return [StringComparer]::Ordinal.Compare([string]$left.path, [string]$right.path)
})
$canonical = @($sorted | ForEach-Object {
[ordered]@{
path = [string]$_.path
size = [long]$_.size
sha256 = ([string]$_.sha256).ToUpperInvariant()
}
}) | ConvertTo-Json -Depth 8 -Compress
return [pscustomobject][ordered]@{
entries = $sorted
sha256 = Get-HandoffBytesSha256 ([Text.Encoding]::UTF8.GetBytes($canonical))
}
}
function Assert-HandoffInventoriesEqual {
param(
[Parameter(Mandatory = $true)] [object]$Expected,
[Parameter(Mandatory = $true)] [object]$Actual,
[Parameter(Mandatory = $true)] [string]$Label
)
if (-not [string]::Equals(
[string]$Expected.sha256,
[string]$Actual.sha256,
[StringComparison]::OrdinalIgnoreCase
) -or @($Expected.entries).Count -ne @($Actual.entries).Count) {
throw "$Label inventory changed during handoff staging."
}
for ($index = 0; $index -lt @($Expected.entries).Count; $index++) {
$left = @($Expected.entries)[$index]
$right = @($Actual.entries)[$index]
if ([string]$left.path -cne [string]$right.path -or
[long]$left.size -ne [long]$right.size -or
-not [string]::Equals(
[string]$left.sha256,
[string]$right.sha256,
[StringComparison]::OrdinalIgnoreCase
)) {
throw "$Label inventory changed during handoff staging."
}
}
}
function Copy-HandoffMappings {
param(
[Parameter(Mandatory = $true)] [object[]]$Mappings,
[Parameter(Mandatory = $true)] [string]$Root,
[string]$SkipRelativePath = ''
)
foreach ($mapping in $Mappings) {
$relative = [string]$mapping.Path
if (-not [string]::IsNullOrEmpty($SkipRelativePath) -and
$relative -ceq $SkipRelativePath) {
continue
}
$destination = Join-Path $Root $relative
[IO.Directory]::CreateDirectory((Split-Path $destination -Parent)) | Out-Null
if (Test-Path -LiteralPath $destination) {
throw 'Handoff destination contains an unexpected file or directory collision.'
}
[IO.File]::Copy([string]$mapping.Source, $destination, $false)
}
}
function Get-HandoffMappings {
param(
[Parameter(Mandatory = $true)] [string]$ReleaseRoot,
[Parameter(Mandatory = $true)] [string]$ResourcesRoot
)
$mappings = [Collections.Generic.List[object]]::new()
foreach ($relative in @('waggle.exe', 'deps\waggle.exe')) {
$mappings.Add([pscustomobject]@{
Source = Join-Path $ReleaseRoot $relative
Path = "$TargetTriple\release\$relative"
})
}
foreach ($treeSpec in @(
[pscustomobject]@{
Root = Join-Path $ReleaseRoot 'nsis'
Prefix = "$TargetTriple\release\nsis"
},
[pscustomobject]@{
Root = Join-Path $ReleaseRoot 'bundle\nsis'
Prefix = "$TargetTriple\release\bundle\nsis"
}
)) {
$treeMaxCount = if ([string]$treeSpec.Prefix -like '*\bundle\nsis') { 1 } else { 128 }
$treeMaxBytes = if ([string]$treeSpec.Prefix -like '*\bundle\nsis') { 256MB } else { 64MB }
foreach ($file in @(Get-HandoffTreeFiles `
([string]$treeSpec.Root) 'Unsigned NSIS output' `
-MaxFileCount $treeMaxCount -MaxBytes $treeMaxBytes `
-MaxDirectoryCount 16)) {
$mappings.Add([pscustomobject]@{
Source = [string]$file.FullName
Path = "$([string]$treeSpec.Prefix)\$([string]$file.RelativePath)"
})
}
}
foreach ($file in @(Get-HandoffTreeFiles `
$ResourcesRoot 'Tauri sidecar resources' `
-MaxFileCount $MaxResourceFileCount -MaxBytes $MaxResourceBytes `
-MaxDirectoryCount 2400)) {
$mappings.Add([pscustomobject]@{
Source = [string]$file.FullName
Path = "resources\$([string]$file.RelativePath)"
})
}
return @($mappings)
}
function Get-HandoffResourceMappings {
param([Parameter(Mandatory = $true)] [object[]]$TargetMappings)
return @($TargetMappings | Where-Object {
([string]$_.Path).StartsWith('resources\', [StringComparison]::Ordinal)
} | ForEach-Object {
[pscustomobject]@{
Source = [string]$_.Source
Path = ([string]$_.Path).Substring('resources\'.Length)
}
})
}
function Get-HandoffTreeMappings {
param(
[Parameter(Mandatory = $true)] [string]$Root,
[Parameter(Mandatory = $true)] [string]$Label,
[switch]$AllowCargoHardLinkPair,
[string]$CargoReleaseRoot = '',
[int]$MaxFileCount = [int]::MaxValue,
[long]$MaxBytes = [long]::MaxValue,
[int]$MaxDirectoryCount = [int]::MaxValue
)
return @(Get-HandoffTreeFiles `
$Root $Label `
-AllowCargoHardLinkPair:$AllowCargoHardLinkPair `
-CargoReleaseRoot $CargoReleaseRoot `
-MaxFileCount $MaxFileCount -MaxBytes $MaxBytes `
-MaxDirectoryCount $MaxDirectoryCount | ForEach-Object {
[pscustomobject]@{ Source = [string]$_.FullName; Path = [string]$_.RelativePath }
})
}
function Write-HandoffJsonNoBom {
param(
[Parameter(Mandatory = $true)] [string]$Path,
[Parameter(Mandatory = $true)] [object]$Value
)
[IO.File]::WriteAllText(
$Path,
($Value | ConvertTo-Json -Depth 32),
[Text.UTF8Encoding]::new($false)
)
}
function Write-HandoffOutput {
param(
[Parameter(Mandatory = $true)] [string]$Name,
[Parameter(Mandatory = $true)] [string]$Value
)
if ($Name -notmatch '^[a-z_]+$' -or $Value -match '[\r\n\x00]') {
throw 'GitHub output contains an unsafe name or value.'
}
$line = "$Name=$Value"
Write-Host $line
$outputPath = [Environment]::GetEnvironmentVariable('GITHUB_OUTPUT')
if (-not [string]::IsNullOrWhiteSpace($outputPath)) {
$trustedOutput = Get-HandoffTrustedPath $outputPath 'GITHUB_OUTPUT'
[IO.File]::AppendAllText(
$trustedOutput,
$line + [Environment]::NewLine,
[Text.UTF8Encoding]::new($false)
)
}
}
function New-HandoffBuildReceipt {
param(
[Parameter(Mandatory = $true)] [string]$SourceRevision,
[Parameter(Mandatory = $true)] [object]$TargetInventory,
[Parameter(Mandatory = $true)] [object]$ResourcesInventory,
[Parameter(Mandatory = $true)] [object]$NsisInventory,
[Parameter(Mandatory = $true)] [string]$CheckerSha256
)
if ($SourceRevision -notmatch '^[0-9a-f]{40}$' -or
$CheckerSha256 -notmatch '^[0-9A-F]{64}$') {
throw 'Handoff receipt requires an exact source revision and checker SHA-256.'
}
return [ordered]@{
schemaVersion = 1
repository = $ApprovedRepository
sourceRevision = $SourceRevision
targetTriple = $TargetTriple
targetInventory = $TargetInventory
resourcesInventory = $ResourcesInventory
nsisInventory = $NsisInventory
checker = [ordered]@{
exitCode = 0
sha256 = $CheckerSha256
}
}
}
function Invoke-HandoffDestinationTransaction {
param(
[Parameter(Mandatory = $true)] [string]$DestinationRoot,
[Parameter(Mandatory = $true)] [string]$DestinationParentRoot,
[Parameter(Mandatory = $true)] [object[]]$TargetMappings,
[Parameter(Mandatory = $true)] [object[]]$ResourceMappings,
[Parameter(Mandatory = $true)] [object[]]$NsisMappings,
[Parameter(Mandatory = $true)] [object]$ExpectedTargetInventory,
[Parameter(Mandatory = $true)] [object]$ExpectedResourcesInventory,
[Parameter(Mandatory = $true)] [object]$ExpectedNsisInventory,
[Parameter(Mandatory = $true)] [string]$SourceRevision,
[Parameter(Mandatory = $true)] [string]$CheckerSha256,
[scriptblock]$FinalSourceAssertion = {},
[int]$TargetFileCountLimit = $MaxTargetFileCount,
[long]$TargetByteLimit = $MaxTargetBytes,
[int]$ResourceFileCountLimit = $MaxResourceFileCount,
[long]$ResourceByteLimit = $MaxResourceBytes,
[int]$NsisFileCountLimit = $ExpectedNsisFileCount,
[long]$NsisByteLimit = 16MB
)
$destinationParent = Get-HandoffTrustedPath `
$DestinationParentRoot 'Handoff destination parent' 'Container'
if ([string]::IsNullOrWhiteSpace($DestinationRoot) -or
-not [IO.Path]::IsPathRooted($DestinationRoot) -or
$DestinationRoot -match '[\x00-\x1F\x7F]' -or
$DestinationRoot -match '(^|[\\/])\.\.?(?:[\\/]|$)') {
throw 'DestinationRoot must be a safe, fully qualified local Windows path.'
}
$destination = [IO.Path]::GetFullPath($DestinationRoot).TrimEnd('\')
if (-not [string]::Equals(
(Split-Path $destination -Parent),
$destinationParent.TrimEnd('\'),
[StringComparison]::OrdinalIgnoreCase
) -or (Split-Path $destination -Leaf) -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or
(Test-Path -LiteralPath $destination)) {
throw 'DestinationRoot must be an absent direct child of its trusted parent.'
}
[void](Assert-HandoffMappingBounds `
$TargetMappings $TargetFileCountLimit $TargetByteLimit 'Hosted target inventory')
[void](Assert-HandoffMappingBounds `
$ResourceMappings $ResourceFileCountLimit $ResourceByteLimit `
'Hosted resources inventory')
[void](Assert-HandoffMappingBounds `
$NsisMappings $NsisFileCountLimit $NsisByteLimit 'Hosted NSIS inventory')
$prebuiltRoot = Join-Path $destination 'prebuilt'
$stagedNsisRoot = Join-Path $destination 'nsis-toolchain'
$receiptPath = Join-Path $destination 'build-receipt.json'
try {
[IO.Directory]::CreateDirectory($prebuiltRoot) | Out-Null
[IO.Directory]::CreateDirectory($stagedNsisRoot) | Out-Null
$mainRelative = "$TargetTriple\release\waggle.exe"
Copy-HandoffMappings $TargetMappings $prebuiltRoot $mainRelative
$stagedDependency = Join-Path `
$prebuiltRoot "$TargetTriple\release\deps\waggle.exe"
$stagedMain = Join-Path $prebuiltRoot $mainRelative
New-Item -ItemType HardLink -Path $stagedMain -Target $stagedDependency | Out-Null
Copy-HandoffMappings $NsisMappings $stagedNsisRoot
Assert-HandoffCargoPair `
(Join-Path $prebuiltRoot "$TargetTriple\release") 'Staged Cargo output'
$stagedTargetMappings = @(Get-HandoffTreeMappings `
$prebuiltRoot 'Staged prebuilt tree' -AllowCargoHardLinkPair `
-CargoReleaseRoot (Join-Path $prebuiltRoot "$TargetTriple\release") `
-MaxFileCount $TargetFileCountLimit -MaxBytes $TargetByteLimit `
-MaxDirectoryCount 2500)
$stagedResourceMappings = @(Get-HandoffTreeMappings `
(Join-Path $prebuiltRoot 'resources') 'Staged sidecar resources' `
-MaxFileCount $ResourceFileCountLimit -MaxBytes $ResourceByteLimit `
-MaxDirectoryCount 2400)
$stagedNsisMappings = @(Get-HandoffTreeMappings `
$stagedNsisRoot 'Staged NSIS toolchain' `
-MaxFileCount $NsisFileCountLimit -MaxBytes $NsisByteLimit `
-MaxDirectoryCount 128)
& $FinalSourceAssertion $prebuiltRoot $stagedNsisRoot
$targetInventory = New-HandoffInventory $stagedTargetMappings 'Staged target inventory'
$resourcesInventory = New-HandoffInventory `
$stagedResourceMappings 'Staged resources inventory'
$nsisInventory = New-HandoffInventory $stagedNsisMappings 'Staged NSIS inventory'
Assert-HandoffInventoriesEqual `
$ExpectedTargetInventory $targetInventory 'Hosted target'
Assert-HandoffInventoriesEqual `
$ExpectedResourcesInventory $resourcesInventory 'Hosted resources'
Assert-HandoffInventoriesEqual `
$ExpectedNsisInventory $nsisInventory 'Hosted NSIS toolchain'
& $FinalSourceAssertion $prebuiltRoot $stagedNsisRoot
$receipt = New-HandoffBuildReceipt `
$SourceRevision $targetInventory $resourcesInventory $nsisInventory $CheckerSha256
Write-HandoffJsonNoBom $receiptPath $receipt
$receiptSha256 = Get-HandoffFileSha256 $receiptPath
return [pscustomobject][ordered]@{
ArtifactRoot = $destination
PrebuiltRoot = $prebuiltRoot
NsisToolchainRoot = $stagedNsisRoot
ReceiptPath = $receiptPath
ReceiptSha256 = $receiptSha256
Receipt = $receipt
}
} catch {
if (Test-Path -LiteralPath $destination -PathType Container) {
[IO.Directory]::Delete($destination, $true)
}
throw
}
}
function Invoke-WindowsSigningHandoff {
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or
-not [Environment]::Is64BitProcess) {
throw 'Windows signing handoff requires 64-bit Windows.'
}
$repoRoot = Get-HandoffTrustedPath `
(Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) `
'Repository root' 'Container'
$expectedBoundary = [ordered]@{
GITHUB_ACTIONS = 'true'
RUNNER_ENVIRONMENT = 'github-hosted'
RUNNER_OS = 'Windows'
GITHUB_REPOSITORY = $ApprovedRepository
GITHUB_REF_TYPE = 'tag'
}
foreach ($entry in $expectedBoundary.GetEnumerator()) {
if ([Environment]::GetEnvironmentVariable([string]$entry.Key) -cne [string]$entry.Value) {
throw "Windows signing handoff requires exact $($entry.Key) boundary evidence."
}
}
$workspace = Get-HandoffTrustedPath `
([Environment]::GetEnvironmentVariable('GITHUB_WORKSPACE')) `
'GITHUB_WORKSPACE' 'Container'
if (-not [string]::Equals($workspace, $repoRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Windows signing handoff must run from the exact GitHub workspace.'
}
$revision = [Environment]::GetEnvironmentVariable('GITHUB_SHA')
if ($revision -notmatch '^[0-9a-f]{40}$') {
throw 'GITHUB_SHA must be one exact lowercase commit revision.'
}
$expectedGitPath = 'C:\Program Files\Git\cmd\git.exe'
if (-not [string]::Equals(
[IO.Path]::GetFullPath($GitPath),
$expectedGitPath,
[StringComparison]::OrdinalIgnoreCase
)) {
throw 'Git executable must use the canonical Git for Windows path.'
}
$git = Get-HandoffTrustedPath $expectedGitPath 'Git executable' -AllowHardLink
$runnerToolCache = Get-HandoffTrustedPath `
([Environment]::GetEnvironmentVariable('RUNNER_TOOL_CACHE')) `
'RUNNER_TOOL_CACHE' 'Container'
$expectedNodePath = Join-Path $runnerToolCache 'node\22.23.2\x64\node.exe'
if ([string]::IsNullOrWhiteSpace($NodePath)) {
$NodePath = $expectedNodePath
} elseif (-not [string]::Equals(
[IO.Path]::GetFullPath($NodePath),
[IO.Path]::GetFullPath($expectedNodePath),
[StringComparison]::OrdinalIgnoreCase
)) {
throw 'Node.js runtime must use the exact setup-node tool-cache path.'
}
$node = Get-HandoffTrustedPath $NodePath 'Node.js runtime' -AllowHardLink
$configPath = Get-HandoffTrustedPath `
(Join-Path $repoRoot 'app\src-tauri\tauri.conf.json') 'Tauri config'
$checkerPath = Get-HandoffTrustedPath `
(Join-Path $repoRoot 'scripts\check-sidecar-resources.mjs') `
'Sidecar resource checker'
$toolLocks = [Collections.Generic.List[IDisposable]]::new()
try {
foreach ($toolPath in @($git, $node, $configPath, $checkerPath)) {
$toolLocks.Add([IO.File]::Open(
$toolPath,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
[IO.FileShare]::Read
))
}
Assert-HandoffSignedExecutable $git $GitPublisher 'Git executable'
Assert-HandoffSignedExecutable $node $OpenJsPublisher 'Node.js runtime'
$nodeVersion = [string](& $node --version)
if ($LASTEXITCODE -ne 0 -or $nodeVersion -cne $ExpectedNodeVersion) {
throw "Node.js runtime must be exactly $ExpectedNodeVersion."
}
$appVersion = [string](
Get-Content -Raw -LiteralPath $configPath | ConvertFrom-Json
).version
if ($appVersion -notmatch '^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$' -or
[Environment]::GetEnvironmentVariable('GITHUB_REF_NAME') -cne "v$appVersion") {
throw 'Windows signing handoff requires the exact app-version release tag.'
}
$trackedHandoffPaths = @(
'app/scripts/new-windows-signing-handoff.ps1',
'scripts/check-sidecar-resources.mjs',
'app/src-tauri/tauri.conf.json'
)
Assert-HandoffRepositoryState `
$git $repoRoot $revision $appVersion $trackedHandoffPaths
$expectedSourceTarget = [IO.Path]::GetFullPath(
(Join-Path $repoRoot 'app\src-tauri\target')
)
$sourceTargetCandidate = if ([IO.Path]::IsPathRooted($SourceTargetRoot)) {
$SourceTargetRoot
} else {
Join-Path $repoRoot $SourceTargetRoot
}
$sourceTarget = Get-HandoffTrustedPath `
$sourceTargetCandidate 'Unsigned Cargo target root' 'Container'
if (-not [string]::Equals(
$sourceTarget,
$expectedSourceTarget,
[StringComparison]::OrdinalIgnoreCase
)) {
throw 'Unsigned Cargo target root must be the canonical repository target root.'
}
$sourceRelease = Get-HandoffTrustedPath `
(Join-Path $sourceTarget "$TargetTriple\release") `
'Unsigned Cargo release root' 'Container'
Assert-HandoffCargoPair $sourceRelease 'Unsigned Cargo output'
$sourceNsis = Get-HandoffTrustedPath `
(Join-Path $sourceRelease 'nsis') 'Generated NSIS input root' 'Container'
$sourceBundleNsis = Get-HandoffTrustedPath `
(Join-Path $sourceRelease 'bundle\nsis') 'Generated NSIS bundle root' 'Container'
if (@(Get-HandoffTreeFiles `
$sourceNsis 'Generated NSIS input root' `
-MaxFileCount 128 -MaxBytes 64MB -MaxDirectoryCount 16).Count -eq 0 -or
@(Get-HandoffTreeFiles `
$sourceBundleNsis 'Generated NSIS bundle root' `
-MaxFileCount 1 -MaxBytes 256MB -MaxDirectoryCount 1).Count -eq 0) {
throw 'A completed full unsigned NSIS build is required before handoff.'
}
$expectedInstaller = Get-HandoffTrustedPath `
(Join-Path $sourceBundleNsis "Waggle_${appVersion}_x64-setup.exe") `
'Unsigned NSIS installer'
$bundleFiles = @(Get-HandoffTreeFiles `
$sourceBundleNsis 'Generated NSIS bundle root' `
-MaxFileCount 1 -MaxBytes 256MB -MaxDirectoryCount 1)
if ($bundleFiles.Count -ne 1 -or
-not [string]::Equals(
[string]$bundleFiles[0].FullName,
$expectedInstaller,
[StringComparison]::OrdinalIgnoreCase
)) {
throw 'Generated NSIS bundle root must contain only the exact versioned installer.'
}
foreach ($unsignedPath in @(
(Join-Path $sourceRelease 'waggle.exe'),
(Join-Path $sourceRelease 'deps\waggle.exe'),
$expectedInstaller
)) {
$signature = Get-AuthenticodeSignature -LiteralPath $unsignedPath
if ($signature.Status -ne [Management.Automation.SignatureStatus]::NotSigned -or
[string]$signature.SignatureType -cne 'None') {
throw 'Windows signing handoff accepts only an unsigned executable and NSIS installer.'
}
}
$resourcesRoot = Get-HandoffTrustedPath `
(Join-Path $repoRoot 'app\src-tauri\resources') `
'Tauri sidecar resources' 'Container'
$localAppData = [Environment]::GetFolderPath('LocalApplicationData')
$nsisToolchainRoot = Get-HandoffTrustedPath `
(Join-Path $localAppData 'tauri\NSIS') 'Tauri NSIS toolchain' 'Container'
$checkerSha256 = Get-HandoffFileSha256 $checkerPath
$targetMappings = @(Get-HandoffMappings $sourceRelease $resourcesRoot)
$resourceMappings = @(Get-HandoffResourceMappings $targetMappings)
$targetBytes = [long](Assert-HandoffMappingBounds `
$targetMappings $MaxTargetFileCount $MaxTargetBytes 'Hosted target inventory')
[void](Assert-HandoffMappingBounds `
$resourceMappings $MaxResourceFileCount $MaxResourceBytes 'Hosted resources inventory')
$nsisMappings = @(Get-HandoffTreeMappings `
$nsisToolchainRoot 'Tauri NSIS toolchain' `
-MaxFileCount $ExpectedNsisFileCount -MaxBytes 16MB -MaxDirectoryCount 128)
$nsisBytes = [long](Assert-HandoffMappingBounds `
$nsisMappings $ExpectedNsisFileCount 16MB 'Hosted NSIS inventory')
$expectedTargetInventory = New-HandoffInventory $targetMappings 'Hosted target inventory'
$expectedResourcesInventory = New-HandoffInventory `
$resourceMappings 'Hosted resources inventory'
$expectedNsisInventory = New-HandoffInventory $nsisMappings 'Hosted NSIS inventory'
if (@($expectedNsisInventory.entries).Count -ne $ExpectedNsisFileCount -or
[string]$expectedNsisInventory.sha256 -cne $ExpectedNsisInventorySha256) {
throw 'Tauri NSIS toolchain does not match the pinned 442-file closure.'
}
$savedTargetArch = [Environment]::GetEnvironmentVariable('TARGET_ARCH')
$savedNodeOptions = [Environment]::GetEnvironmentVariable('NODE_OPTIONS')
$savedNodePath = [Environment]::GetEnvironmentVariable('NODE_PATH')
try {
[Environment]::SetEnvironmentVariable('TARGET_ARCH', 'x64')
[Environment]::SetEnvironmentVariable('NODE_OPTIONS', $null)
[Environment]::SetEnvironmentVariable('NODE_PATH', $null)
Push-Location $repoRoot
try {
& $node $checkerPath --expected-source-revision $revision
if ($LASTEXITCODE -ne 0) {
throw "Sidecar resource checker failed with exit code $LASTEXITCODE."
}
} finally {
Pop-Location
}
} finally {
[Environment]::SetEnvironmentVariable('TARGET_ARCH', $savedTargetArch)
[Environment]::SetEnvironmentVariable('NODE_OPTIONS', $savedNodeOptions)
[Environment]::SetEnvironmentVariable('NODE_PATH', $savedNodePath)
}
if ((Get-HandoffFileSha256 $checkerPath) -cne $checkerSha256) {
throw 'Sidecar resource checker changed while it was executing.'
}
$runnerTemp = Get-HandoffTrustedPath `
([Environment]::GetEnvironmentVariable('RUNNER_TEMP')) 'RUNNER_TEMP' 'Container'
if ([string]::IsNullOrWhiteSpace($DestinationRoot) -or
-not [IO.Path]::IsPathRooted($DestinationRoot) -or
$DestinationRoot -match '[\x00-\x1F\x7F]' -or
$DestinationRoot -match '(^|[\\/])\.\.?(?:[\\/]|$)') {
throw 'DestinationRoot must be a safe, fully qualified local Windows path.'
}
$destination = [IO.Path]::GetFullPath($DestinationRoot).TrimEnd('\')
if (-not [string]::Equals(
(Split-Path $destination -Parent),
$runnerTemp.TrimEnd('\'),
[StringComparison]::OrdinalIgnoreCase
) -or (Split-Path $destination -Leaf) -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or
(Test-Path -LiteralPath $destination)) {
throw 'DestinationRoot must be an absent direct child of RUNNER_TEMP.'
}
if ($targetBytes -gt [long]::MaxValue - $nsisBytes -or
$targetBytes + $nsisBytes -gt [long]::MaxValue - 1GB) {
throw 'Handoff disk-capacity calculation overflowed.'
}
$requiredFreeBytes = $targetBytes + $nsisBytes + 1GB
$availableFreeBytes = [IO.DriveInfo]::new(
[IO.Path]::GetPathRoot($destination)
).AvailableFreeSpace
if ($availableFreeBytes -lt $requiredFreeBytes) {
throw "RUNNER_TEMP has insufficient free space for the bounded handoff: requires $requiredFreeBytes bytes."
}
$finalSourceAssertion = {
$lockedHead = [string](& $git -C $repoRoot rev-parse --verify HEAD)
$lockedStatus = @(& $git -C $repoRoot status --porcelain=v1 --untracked-files=all)
if ($LASTEXITCODE -ne 0 -or $lockedHead -cne $revision -or $lockedStatus.Count -ne 0 -or
(Get-HandoffFileSha256 $checkerPath) -cne $checkerSha256) {
throw 'Repository or checker changed while the handoff was being assembled.'
}
Assert-HandoffDefaultIndexFlags $git $repoRoot
}.GetNewClosure()
$handoff = Invoke-HandoffDestinationTransaction `
-DestinationRoot $destination -DestinationParentRoot $runnerTemp `
-TargetMappings $targetMappings -ResourceMappings $resourceMappings `
-NsisMappings $nsisMappings `
-ExpectedTargetInventory $expectedTargetInventory `
-ExpectedResourcesInventory $expectedResourcesInventory `
-ExpectedNsisInventory $expectedNsisInventory `
-SourceRevision $revision -CheckerSha256 $checkerSha256 `
-FinalSourceAssertion $finalSourceAssertion
Write-HandoffOutput 'artifact_root' $handoff.ArtifactRoot
Write-HandoffOutput 'prebuilt_root' $handoff.PrebuiltRoot
Write-HandoffOutput 'nsis_toolchain_root' $handoff.NsisToolchainRoot
Write-HandoffOutput 'receipt_path' $handoff.ReceiptPath
Write-HandoffOutput 'receipt_sha256' $handoff.ReceiptSha256
Write-HandoffOutput 'source_revision' $revision
} finally {
foreach ($toolLock in $toolLocks) { $toolLock.Dispose() }
}
}
if ($MyInvocation.InvocationName -ne '.') {
Invoke-WindowsSigningHandoff
}