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