2773 lines
122 KiB
PowerShell
2773 lines
122 KiB
PowerShell
#Requires -Version 7.0
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$wrapperPath = Join-Path $PSScriptRoot 'sign-windows-artifact.ps1'
|
|
. $wrapperPath -ArtifactPath 'C:\unused.exe'
|
|
|
|
$passed = 0
|
|
|
|
function Assert-Equal {
|
|
param($Actual, $Expected, [string]$Label)
|
|
if ($Actual -cne $Expected) { throw "$Label expected '$Expected', got '$Actual'." }
|
|
$script:passed++
|
|
}
|
|
|
|
function Assert-Throws {
|
|
param([scriptblock]$Action, [string]$Pattern, [string]$Label)
|
|
try {
|
|
& $Action
|
|
} catch {
|
|
if ($_.Exception.Message -notmatch $Pattern) {
|
|
throw "$Label threw the wrong error: $($_.Exception.Message)"
|
|
}
|
|
$script:passed++
|
|
return
|
|
}
|
|
throw "$Label did not throw."
|
|
}
|
|
|
|
function Write-JsonNoBom {
|
|
param([string]$Path, [object]$Value)
|
|
[IO.File]::WriteAllText(
|
|
$Path,
|
|
($Value | ConvertTo-Json -Depth 32 -Compress),
|
|
[Text.UTF8Encoding]::new($false)
|
|
)
|
|
}
|
|
|
|
function New-SyntheticPe {
|
|
param([string]$Path, [byte]$Marker = 0)
|
|
[IO.Directory]::CreateDirectory((Split-Path $Path -Parent)) | Out-Null
|
|
$bytes = [byte[]]::new(128)
|
|
$bytes[0] = 0x4D
|
|
$bytes[1] = 0x5A
|
|
[BitConverter]::GetBytes([uint32]0x40).CopyTo($bytes, 0x3C)
|
|
$bytes[0x40] = 0x50
|
|
$bytes[0x41] = 0x45
|
|
$bytes[0x42] = 0
|
|
$bytes[0x43] = 0
|
|
$bytes[127] = $Marker
|
|
[IO.File]::WriteAllBytes($Path, $bytes)
|
|
}
|
|
|
|
function New-SyntheticPatchablePe {
|
|
param([string]$Path)
|
|
[IO.Directory]::CreateDirectory((Split-Path $Path -Parent)) | Out-Null
|
|
$bytes = [byte[]]::new(256)
|
|
$bytes[0] = 0x4D
|
|
$bytes[1] = 0x5A
|
|
[BitConverter]::GetBytes([uint32]0x40).CopyTo($bytes, 0x3C)
|
|
$bytes[0x40] = 0x50
|
|
$bytes[0x41] = 0x45
|
|
$token = [Text.Encoding]::ASCII.GetBytes('__TAURI_BUNDLE_TYPE_VAR_UNK')
|
|
[Array]::Copy($token, 0, $bytes, 160, $token.Length)
|
|
[IO.File]::WriteAllBytes($Path, $bytes)
|
|
}
|
|
|
|
function Get-Sha256 {
|
|
param([string]$Path)
|
|
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
|
|
}
|
|
|
|
function Get-RealSha256 {
|
|
param([string]$Path)
|
|
$stream = [IO.File]::Open(
|
|
$Path,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::Read
|
|
)
|
|
$algorithm = [Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
return ([BitConverter]::ToString($algorithm.ComputeHash($stream)) -replace '-', '')
|
|
} finally {
|
|
$algorithm.Dispose()
|
|
$stream.Dispose()
|
|
}
|
|
}
|
|
|
|
function New-PendingLedger {
|
|
param([string]$SessionId, [string]$ManifestSha256, [object[]]$Slots)
|
|
return [pscustomobject][ordered]@{
|
|
schemaVersion = 3
|
|
sessionId = $SessionId
|
|
manifestSha256 = $ManifestSha256
|
|
state = 'open'
|
|
terminalAtUtc = $null
|
|
terminalReceiptSha256 = $null
|
|
entries = @($Slots | ForEach-Object {
|
|
[pscustomobject][ordered]@{
|
|
slotId = [string]$_.id
|
|
order = [int]$_.order
|
|
kind = [string]$_.kind
|
|
status = 'pending'
|
|
attempts = 0
|
|
reservationId = $null
|
|
path = $null
|
|
preSignSha256 = $null
|
|
reservedAtUtc = $null
|
|
completedAtUtc = $null
|
|
postSignSha256 = $null
|
|
signerSubject = $null
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
function Close-PrebuiltTreeLease {
|
|
param([AllowNull()] [object]$Lease)
|
|
if ($null -eq $Lease) { return }
|
|
$locksProperty = $Lease.PSObject.Properties['Locks']
|
|
$locks = if ($null -eq $locksProperty) { @($Lease) } else { @($locksProperty.Value) }
|
|
foreach ($lock in $locks) {
|
|
if ($null -ne $lock) { $lock.Dispose() }
|
|
}
|
|
}
|
|
|
|
function Assert-FreshProcessModuleIsolation {
|
|
param(
|
|
[Parameter(Mandatory = $true)] [string]$HostPath,
|
|
[Parameter(Mandatory = $true)] [string]$Label,
|
|
[Parameter(Mandatory = $true)] [string]$Root
|
|
)
|
|
|
|
$hostRoot = Join-Path $Root ([IO.Path]::GetFileNameWithoutExtension($HostPath))
|
|
$hostileModuleRoot = Join-Path $hostRoot 'hostile-modules'
|
|
$markerPath = Join-Path $hostRoot 'hostile-module-loaded.txt'
|
|
$childPath = Join-Path $hostRoot 'module-isolation-child.ps1'
|
|
[IO.Directory]::CreateDirectory($hostileModuleRoot) | Out-Null
|
|
foreach ($moduleName in @(
|
|
'Microsoft.PowerShell.Security',
|
|
'Microsoft.PowerShell.Management',
|
|
'Microsoft.PowerShell.Utility'
|
|
)) {
|
|
$moduleRoot = Join-Path $hostileModuleRoot $moduleName
|
|
[IO.Directory]::CreateDirectory($moduleRoot) | Out-Null
|
|
$modulePath = Join-Path $moduleRoot "$moduleName.psm1"
|
|
$manifestPath = Join-Path $moduleRoot "$moduleName.psd1"
|
|
[IO.File]::WriteAllText(
|
|
$modulePath,
|
|
"[IO.File]::AppendAllText('$($markerPath.Replace("'", "''"))', '$moduleName')`n" +
|
|
"function Get-AuthenticodeSignature { throw 'hostile command' }`n" +
|
|
"function Get-Acl { throw 'hostile command' }`n" +
|
|
"function Set-Acl { throw 'hostile command' }`n" +
|
|
"function Get-FileHash { throw 'hostile command' }`n" +
|
|
'Export-ModuleMember -Function *',
|
|
[Text.UTF8Encoding]::new($false)
|
|
)
|
|
New-ModuleManifest -Path $manifestPath -RootModule "$moduleName.psm1" `
|
|
-ModuleVersion '99.0.0' -FunctionsToExport '*' | Out-Null
|
|
}
|
|
$childSource = @'
|
|
param([string]$WrapperPath, [string]$HostileModuleRoot, [string]$MarkerPath)
|
|
$ErrorActionPreference = 'Stop'
|
|
[Environment]::SetEnvironmentVariable('PSModulePath', $HostileModuleRoot, 'Process')
|
|
$global:PSModuleAutoLoadingPreference = 'All'
|
|
. $WrapperPath -ArtifactPath 'C:\unused.exe'
|
|
$trustedRoot = [IO.Path]::GetFullPath([IO.Path]::Combine($PSHOME, 'Modules'))
|
|
$expected = @{
|
|
'Get-AuthenticodeSignature' = 'Microsoft.PowerShell.Security'
|
|
'Get-Acl' = 'Microsoft.PowerShell.Security'
|
|
'Set-Acl' = 'Microsoft.PowerShell.Security'
|
|
}
|
|
if ($PSVersionTable.PSEdition -ceq 'Core') {
|
|
$expected['Get-FileHash'] = 'Microsoft.PowerShell.Utility'
|
|
}
|
|
foreach ($entry in $expected.GetEnumerator()) {
|
|
$command = Get-Command $entry.Key -CommandType Cmdlet -ErrorAction Stop
|
|
$expectedPath = [IO.Path]::Combine($trustedRoot, $entry.Value, "$($entry.Value).psd1")
|
|
if (-not [string]::Equals(
|
|
[IO.Path]::GetFullPath([string]$command.Module.Path),
|
|
[IO.Path]::GetFullPath($expectedPath),
|
|
[StringComparison]::OrdinalIgnoreCase
|
|
)) {
|
|
throw "Command '$($entry.Key)' resolved outside the trusted module root."
|
|
}
|
|
}
|
|
if ([IO.File]::Exists($MarkerPath)) { throw 'A hostile PowerShell module was loaded.' }
|
|
'module-isolation-ok'
|
|
'@
|
|
[IO.File]::WriteAllText($childPath, $childSource, [Text.UTF8Encoding]::new($false))
|
|
$output = & $HostPath -NoLogo -NoProfile -NonInteractive `
|
|
-ExecutionPolicy Bypass -File $childPath `
|
|
-WrapperPath $wrapperPath -HostileModuleRoot $hostileModuleRoot `
|
|
-MarkerPath $markerPath 2>&1
|
|
if ($LASTEXITCODE -ne 0 -or @($output | Where-Object { $_ -ceq 'module-isolation-ok' }).Count -ne 1) {
|
|
throw "$Label hostile PSModulePath isolation failed: $($output -join ' | ')"
|
|
}
|
|
if (Test-Path -LiteralPath $markerPath) {
|
|
throw "$Label imported a hostile PowerShell module."
|
|
}
|
|
$script:passed++
|
|
}
|
|
|
|
function Start-SynchronizedChild {
|
|
param(
|
|
[Parameter(Mandatory = $true)] [string]$PowerShellPath,
|
|
[Parameter(Mandatory = $true)] [string]$ScriptPath,
|
|
[Parameter(Mandatory = $true)] [hashtable]$Arguments
|
|
)
|
|
$argumentList = [Collections.Generic.List[string]]::new()
|
|
foreach ($argument in @(
|
|
'-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
'-File', ('"' + $ScriptPath + '"')
|
|
)) {
|
|
$argumentList.Add($argument)
|
|
}
|
|
foreach ($entry in $Arguments.GetEnumerator()) {
|
|
$argumentList.Add("-$($entry.Key)")
|
|
$argumentList.Add('"' + ([string]$entry.Value).Replace('"', '\"') + '"')
|
|
}
|
|
return Start-Process -FilePath $PowerShellPath -ArgumentList @($argumentList) `
|
|
-WindowStyle Hidden -PassThru
|
|
}
|
|
|
|
function Wait-ForChildBarriers {
|
|
param([string[]]$Paths, [string]$Label)
|
|
$deadline = [DateTimeOffset]::UtcNow.AddSeconds(30)
|
|
while (@($Paths | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) }).Count -ne 0) {
|
|
if ([DateTimeOffset]::UtcNow -gt $deadline) {
|
|
throw "Timed out waiting for $Label child barriers."
|
|
}
|
|
Start-Sleep -Milliseconds 20
|
|
}
|
|
}
|
|
|
|
$releaseRoot = 'D:\repo\app\src-tauri\target\x86_64-pc-windows-msvc\release'
|
|
$resourcesRoot = 'D:\repo\app\src-tauri\resources'
|
|
$tempRoot = 'D:\repo\app\src-tauri\target\.signing-temp\run-123456'
|
|
$version = '0.2.0'
|
|
|
|
$installedTauriRoot = Join-Path `
|
|
(Split-Path $PSScriptRoot -Parent) 'node_modules\@tauri-apps\cli'
|
|
$installedNativeRoot = Join-Path `
|
|
(Split-Path $PSScriptRoot -Parent) 'node_modules\@tauri-apps\cli-win32-x64-msvc'
|
|
foreach ($binding in @(
|
|
@((Join-Path $installedTauriRoot 'tauri.js'), $TauriCliSha256, 'installed Tauri entry'),
|
|
@((Join-Path $installedTauriRoot 'main.js'), $TauriCliMainSha256, 'installed Tauri main'),
|
|
@((Join-Path $installedTauriRoot 'index.js'), $TauriCliIndexSha256, 'installed Tauri loader'),
|
|
@((Join-Path $installedNativeRoot 'package.json'), $TauriNativePackageSha256, 'installed Tauri native package'),
|
|
@((Join-Path $installedNativeRoot 'cli.win32-x64-msvc.node'), $TauriNativeBinarySha256, 'installed Tauri native binary')
|
|
)) {
|
|
Assert-Equal (Get-Sha256 $binding[0]) $binding[1] $binding[2]
|
|
}
|
|
$wrapperSource = Get-Content -Raw -LiteralPath $wrapperPath
|
|
if ($wrapperSource -match "ValidateSet\('Callback',\s*'Build'\)" -or
|
|
$wrapperSource -match 'function\s+Invoke-WaggleSigningBuild' -or
|
|
$wrapperSource -match 'function\s+Assert-WaggleSigningBuildComplete' -or
|
|
$wrapperSource -match "Mode\s+-ceq\s+'Build'") {
|
|
throw 'Production Artifact Signing still exposes the rejected local Build issuer.'
|
|
}
|
|
if ($wrapperSource -notmatch "ValidateSet\('Callback',\s*'Package'\)" -or
|
|
$wrapperSource -notmatch 'function\s+Invoke-WaggleSigningPackage' -or
|
|
$wrapperSource -notmatch 'function\s+Assert-WaggleSigningPackageComplete') {
|
|
throw 'Production Artifact Signing lacks the hosted-only Package issuer.'
|
|
}
|
|
foreach ($portableParameter in @(
|
|
'PortableToolchainRoot', 'PortableNodePath',
|
|
'PortableGitPath', 'PortableSevenZipPath'
|
|
)) {
|
|
if ($wrapperSource -notmatch "\[string\]\`$$portableParameter") {
|
|
throw "Hosted Package mode lacks the explicit $portableParameter input."
|
|
}
|
|
$passed++
|
|
}
|
|
$portableEnvironmentNames = @(
|
|
'WAGGLE_SIGNING_PORTABLE_TOOLCHAIN_ROOT',
|
|
'WAGGLE_SIGNING_PORTABLE_NODE_PATH',
|
|
'WAGGLE_SIGNING_PORTABLE_GIT_PATH',
|
|
'WAGGLE_SIGNING_PORTABLE_SEVEN_ZIP_PATH'
|
|
)
|
|
foreach ($portableEnvironmentName in $portableEnvironmentNames) {
|
|
if ($wrapperSource -notmatch [Regex]::Escape($portableEnvironmentName)) {
|
|
throw "Signing callbacks do not receive $portableEnvironmentName."
|
|
}
|
|
$passed++
|
|
}
|
|
if ($wrapperSource -notmatch 'function\s+Get-WagglePortableToolchain' -or
|
|
$wrapperSource -notmatch 'portableToolchainRoot' -or
|
|
$wrapperSource -notmatch 'sevenZipDllPath' -or
|
|
$wrapperSource -notmatch 'sevenZipDllSha256') {
|
|
throw 'Portable signing tools are not path-, manifest-, and closure-bound.'
|
|
}
|
|
$passed++
|
|
if ($wrapperSource -notmatch [Regex]::Escape(
|
|
'CN=.NET, O=Microsoft Corporation, L=Redmond, S=Washington, C=US'
|
|
) -or
|
|
$wrapperSource -notmatch
|
|
'Assert-MicrosoftAuthenticodeFile\s+\$dotnet\s+''\.NET host''\s+\$null\s+\$DotNetPublisher') {
|
|
throw '.NET 8 validation is not bound to the exact repository-pinned .NET signer.'
|
|
}
|
|
$passed++
|
|
if ($wrapperSource -notmatch 'Restore-WaggleReplacedArtifact\s+`?\s*-ArtifactPath') {
|
|
throw 'Production signing callback does not use the verified rollback path.'
|
|
}
|
|
$passed++
|
|
foreach ($hostBoundaryName in @(
|
|
'GITHUB_ACTIONS', 'GITHUB_EVENT_NAME', 'RUNNER_ENVIRONMENT',
|
|
'GITHUB_REPOSITORY', 'GITHUB_SHA', 'GITHUB_REF',
|
|
'GITHUB_REF_TYPE', 'GITHUB_REF_NAME', 'GITHUB_WORKFLOW_REF',
|
|
'GITHUB_WORKFLOW_SHA'
|
|
)) {
|
|
if ($wrapperSource -notmatch [Regex]::Escape($hostBoundaryName)) {
|
|
throw "Hosted Package mode does not bind $hostBoundaryName."
|
|
}
|
|
$passed++
|
|
}
|
|
foreach ($hostBoundaryPattern in @(
|
|
"GITHUB_EVENT_NAME\s*=\s*'push'",
|
|
'GITHUB_REF\s*=\s*\$expectedRef',
|
|
'GITHUB_WORKFLOW_REF\s*=\s*\$expectedWorkflowRef',
|
|
'GITHUB_WORKFLOW_SHA\s*=\s*\$ExpectedRevision'
|
|
)) {
|
|
if ($wrapperSource -notmatch $hostBoundaryPattern) {
|
|
throw "Hosted Package mode lacks exact boundary binding: $hostBoundaryPattern"
|
|
}
|
|
$passed++
|
|
}
|
|
$validBoundaryRevision = '0123456789abcdef0123456789abcdef01234567'
|
|
$validBoundaryVersion = '0.2.0'
|
|
$validHostedBoundary = [ordered]@{
|
|
GITHUB_ACTIONS = 'true'
|
|
GITHUB_EVENT_NAME = 'push'
|
|
RUNNER_ENVIRONMENT = 'github-hosted'
|
|
GITHUB_REPOSITORY = 'marolinik/waggle-os'
|
|
GITHUB_SHA = $validBoundaryRevision
|
|
GITHUB_REF = 'refs/tags/v0.2.0'
|
|
GITHUB_REF_TYPE = 'tag'
|
|
GITHUB_REF_NAME = 'v0.2.0'
|
|
GITHUB_WORKFLOW_REF = 'marolinik/waggle-os/.github/workflows/release.yml@refs/tags/v0.2.0'
|
|
GITHUB_WORKFLOW_SHA = $validBoundaryRevision
|
|
}
|
|
$savedHostedBoundary = @{}
|
|
foreach ($name in $validHostedBoundary.Keys) {
|
|
$savedHostedBoundary[$name] = [Environment]::GetEnvironmentVariable($name)
|
|
}
|
|
try {
|
|
foreach ($name in $validHostedBoundary.Keys) {
|
|
[Environment]::SetEnvironmentVariable($name, $validHostedBoundary[$name])
|
|
}
|
|
Assert-WaggleHostedSigningBoundary `
|
|
-ExpectedRevision $validBoundaryRevision `
|
|
-ExpectedVersion $validBoundaryVersion
|
|
$passed++
|
|
foreach ($name in $validHostedBoundary.Keys) {
|
|
[Environment]::SetEnvironmentVariable($name, "invalid-$name")
|
|
Assert-Throws {
|
|
Assert-WaggleHostedSigningBoundary `
|
|
-ExpectedRevision $validBoundaryRevision `
|
|
-ExpectedVersion $validBoundaryVersion
|
|
} "exact $([Regex]::Escape($name)) boundary evidence" `
|
|
"hosted signing rejects mutated $name"
|
|
[Environment]::SetEnvironmentVariable($name, $validHostedBoundary[$name])
|
|
}
|
|
} finally {
|
|
foreach ($name in $validHostedBoundary.Keys) {
|
|
[Environment]::SetEnvironmentVariable($name, $savedHostedBoundary[$name])
|
|
}
|
|
}
|
|
$wrapperTokens = $null
|
|
$wrapperParseErrors = $null
|
|
$wrapperAst = [Management.Automation.Language.Parser]::ParseFile(
|
|
$wrapperPath, [ref]$wrapperTokens, [ref]$wrapperParseErrors
|
|
)
|
|
if (@($wrapperParseErrors).Count -ne 0) {
|
|
throw "Signing wrapper does not parse: $($wrapperParseErrors[0].Message)"
|
|
}
|
|
$packageFunctions = @($wrapperAst.FindAll({
|
|
param($node)
|
|
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
|
$node.Name -ceq 'Invoke-WaggleSigningPackage'
|
|
}, $true))
|
|
if ($packageFunctions.Count -ne 1 -or
|
|
$packageFunctions[0].Extent.Text -match
|
|
'BuildScriptPaths|ViteCliPath|bundleNode|buildSidecar|bundleNativeDeps|stageSidecarDeps|tauri:build') {
|
|
throw 'Hosted Package mode still contains the rejected local source-build closure.'
|
|
}
|
|
$passed++
|
|
$packageSource = $packageFunctions[0].Extent.Text
|
|
if ([regex]::Matches($packageSource, 'New-WagglePrebuiltWorkCopy').Count -ne 2 -or
|
|
[regex]::Matches($packageSource, 'Clear-WaggleRegeneratedRoots').Count -ne 2 -or
|
|
$packageSource -notmatch "SetEnvironmentVariable\('CARGO_TARGET_DIR',\s*\`$unsignedWorkRoot\)" -or
|
|
$packageSource -notmatch "SetEnvironmentVariable\('CARGO_TARGET_DIR',\s*\`$signingWorkRoot\)" -or
|
|
$packageSource -match "SetEnvironmentVariable\('CARGO_TARGET_DIR',\s*\`$(?:unsigned|signing)SourceRoot\)" -or
|
|
$packageSource -notmatch '(?s)New-WaggleSigningManifest.*?\$preflightEvidenceRoot.*?\$receipt' -or
|
|
$packageSource -notmatch '(?s)Unsigned hosted prebuilt input.*?Remove-Item\s+-LiteralPath\s+\$unsignedWorkRoot.*?Signing hosted prebuilt input' -or
|
|
$packageSource -notmatch '(?s)Unsigned private work tree.*?Clear-WaggleRegeneratedRoots.*?Unsigned immutable NSIS preflight package' -or
|
|
$packageSource -notmatch '(?s)Signing private work tree.*?Clear-WaggleRegeneratedRoots.*?Signed NSIS bundle' -or
|
|
[regex]::Matches($packageSource, 'Assert-WaggleHostedDiskCapacity').Count -ne 2) {
|
|
throw 'Hosted Package mode does not isolate immutable receipt inputs in private work copies.'
|
|
}
|
|
$passed++
|
|
if ($packageSource -notmatch '(?s)Open-ReadLock\s+\$overridePath.*?Unsigned immutable NSIS preflight package' -or
|
|
$wrapperSource -notmatch '(?s)function\s+Open-WagglePackageToolchainLocks.*?app/src-tauri/icons/icon\.ico.*?status\s+--porcelain=v1.*?Assert-WaggleHostedSigningBoundary') {
|
|
throw 'Hosted Package mode does not lock exact-HEAD packaging inputs and its unsigned override.'
|
|
}
|
|
$passed++
|
|
if ($wrapperSource -notmatch "(?s)if\s*\(\`$replaced\)\s*\{.*?Rollback backup is missing") {
|
|
throw 'Signing callback does not report a missing required rollback backup as a rollback failure.'
|
|
}
|
|
$passed++
|
|
if ($wrapperSource -notmatch "PSModuleAutoLoadingPreference\s*=\s*'None'" -or
|
|
$wrapperSource -notmatch "SetEnvironmentVariable\(\s*'PSModulePath'") {
|
|
throw 'Production Artifact Signing does not harden PowerShell module resolution before trust checks.'
|
|
}
|
|
$passed++
|
|
if ($wrapperSource -match 'NpmCliPath\s+run|npm\s+run\s+tauri:build') {
|
|
throw 'Production signing build still invokes the PATH-dependent npm build script.'
|
|
}
|
|
$passed++
|
|
foreach ($environmentName in @(
|
|
'NODE_OPTIONS', 'NODE_PATH', 'NAPI_RS_NATIVE_LIBRARY_PATH',
|
|
'NAPI_RS_FORCE_WASI', 'npm_config_node_options'
|
|
)) {
|
|
if ($wrapperSource -notmatch "SetEnvironmentVariable\('$environmentName', \`$null\)") {
|
|
throw "Production signing build does not sanitize $environmentName."
|
|
}
|
|
$passed++
|
|
}
|
|
if ($packageSource -notmatch '(?s)foreach\s*\(\$name\s+in\s+\$environmentNames\).*?\$savedEnvironment\[\$name\].*?finally\s*\{.*?SetEnvironmentVariable\(\$name,\s*\$savedEnvironment\[\$name\]\)' -or
|
|
$wrapperSource -notmatch "@\('-PortableToolchainRoot',\s*\`$ToolchainRoot\)" -or
|
|
$wrapperSource -notmatch "@\('-PortableNodePath',\s*\`$Node\)" -or
|
|
$wrapperSource -notmatch "@\('-PortableGitPath',\s*\`$Git\)" -or
|
|
$wrapperSource -notmatch "@\('-PortableSevenZipPath',\s*\`$SevenZip\)") {
|
|
throw 'Portable toolchain inputs are not forwarded and transactionally restored.'
|
|
}
|
|
$passed++
|
|
|
|
& {
|
|
$handoffPath = Join-Path $PSScriptRoot 'new-windows-signing-handoff.ps1'
|
|
. $handoffPath -SourceTargetRoot 'C:\unused' -DestinationRoot 'C:\unused'
|
|
|
|
Assert-Equal $MaxTargetFileCount 18000 'handoff target file-count production bound'
|
|
Assert-Equal $MaxTargetBytes ([long]768MB) 'handoff target byte production bound'
|
|
Assert-Equal $MaxResourceFileCount 17500 'handoff resource file-count production bound'
|
|
Assert-Equal $MaxResourceBytes ([long]600MB) 'handoff resource byte production bound'
|
|
|
|
$handoffFixtureRoot = Join-Path `
|
|
([IO.Path]::GetTempPath()) `
|
|
"waggle-handoff-test-$([Guid]::NewGuid().ToString('N'))"
|
|
[IO.Directory]::CreateDirectory($handoffFixtureRoot) | Out-Null
|
|
$handoffReparsePath = $null
|
|
try {
|
|
$sourceRoot = Join-Path $handoffFixtureRoot 'source'
|
|
$sourceDependency = Join-Path $sourceRoot 'release\deps\waggle.exe'
|
|
$sourceMain = Join-Path $sourceRoot 'release\waggle.exe'
|
|
$sourceResource = Join-Path $sourceRoot 'resources\service.js'
|
|
$sourceResourceTwo = Join-Path $sourceRoot 'resources\model.json'
|
|
$sourceNsis = Join-Path $sourceRoot 'nsis\makensis.exe'
|
|
$sourceNsisDll = Join-Path $sourceRoot 'nsis\plugin.dll'
|
|
[IO.Directory]::CreateDirectory((Split-Path $sourceDependency -Parent)) | Out-Null
|
|
[IO.Directory]::CreateDirectory((Split-Path $sourceResource -Parent)) | Out-Null
|
|
[IO.Directory]::CreateDirectory((Split-Path $sourceNsis -Parent)) | Out-Null
|
|
New-SyntheticPe $sourceDependency 211
|
|
New-Item -ItemType HardLink -Path $sourceMain -Target $sourceDependency | Out-Null
|
|
[IO.File]::WriteAllText($sourceResource, 'receipt-bound-service')
|
|
[IO.File]::WriteAllText($sourceResourceTwo, '{"model":"fixture"}')
|
|
[IO.File]::WriteAllText($sourceNsis, 'fixture-makensis')
|
|
[IO.File]::WriteAllText($sourceNsisDll, 'fixture-plugin')
|
|
|
|
$targetMappings = @(
|
|
[pscustomobject]@{
|
|
Source = $sourceMain
|
|
Path = "$TargetTriple\release\waggle.exe"
|
|
},
|
|
[pscustomobject]@{
|
|
Source = $sourceDependency
|
|
Path = "$TargetTriple\release\deps\waggle.exe"
|
|
},
|
|
[pscustomobject]@{ Source = $sourceResource; Path = 'resources\service.js' },
|
|
[pscustomobject]@{ Source = $sourceResourceTwo; Path = 'resources\model.json' }
|
|
)
|
|
$resourceMappings = @(
|
|
[pscustomobject]@{ Source = $sourceResource; Path = 'service.js' },
|
|
[pscustomobject]@{ Source = $sourceResourceTwo; Path = 'model.json' }
|
|
)
|
|
$nsisMappings = @(
|
|
[pscustomobject]@{ Source = $sourceNsis; Path = 'makensis.exe' },
|
|
[pscustomobject]@{ Source = $sourceNsisDll; Path = 'plugins\plugin.dll' }
|
|
)
|
|
$expectedTarget = New-HandoffInventory $targetMappings 'Fixture target'
|
|
$expectedResources = New-HandoffInventory $resourceMappings 'Fixture resources'
|
|
$expectedNsis = New-HandoffInventory $nsisMappings 'Fixture NSIS'
|
|
$sourceRevision = 'a' * 40
|
|
$checkerSha256 = 'B' * 64
|
|
$transactionArguments = @{
|
|
DestinationParentRoot = $handoffFixtureRoot
|
|
TargetMappings = $targetMappings
|
|
ResourceMappings = $resourceMappings
|
|
NsisMappings = $nsisMappings
|
|
ExpectedTargetInventory = $expectedTarget
|
|
ExpectedResourcesInventory = $expectedResources
|
|
ExpectedNsisInventory = $expectedNsis
|
|
SourceRevision = $sourceRevision
|
|
CheckerSha256 = $checkerSha256
|
|
TargetFileCountLimit = 8
|
|
TargetByteLimit = 1MB
|
|
ResourceFileCountLimit = 4
|
|
ResourceByteLimit = 1MB
|
|
NsisFileCountLimit = 4
|
|
NsisByteLimit = 1MB
|
|
}
|
|
|
|
$acceptedDestination = Join-Path $handoffFixtureRoot 'accepted'
|
|
$accepted = Invoke-HandoffDestinationTransaction `
|
|
-DestinationRoot $acceptedDestination @transactionArguments
|
|
Assert-Equal `
|
|
(Test-Path -LiteralPath $accepted.ReceiptPath -PathType Leaf) $true `
|
|
'handoff transaction publishes a receipt'
|
|
Assert-Equal `
|
|
(Get-HandoffFileSha256 $accepted.ReceiptPath) $accepted.ReceiptSha256 `
|
|
'handoff receipt digest binds exact bytes'
|
|
$acceptedReceipt = Get-Content -Raw -LiteralPath $accepted.ReceiptPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
Assert-Equal $acceptedReceipt.schemaVersion 1 'handoff receipt schema'
|
|
Assert-Equal $acceptedReceipt.repository $ApprovedRepository 'handoff receipt repository'
|
|
Assert-Equal $acceptedReceipt.sourceRevision $sourceRevision `
|
|
'handoff receipt source revision'
|
|
Assert-Equal $acceptedReceipt.targetInventory.sha256 $expectedTarget.sha256 `
|
|
'handoff receipt target inventory'
|
|
Assert-Equal $acceptedReceipt.resourcesInventory.sha256 $expectedResources.sha256 `
|
|
'handoff receipt resource inventory'
|
|
Assert-Equal $acceptedReceipt.nsisInventory.sha256 $expectedNsis.sha256 `
|
|
'handoff receipt NSIS inventory'
|
|
$acceptedRelease = Join-Path `
|
|
$accepted.PrebuiltRoot "$TargetTriple\release"
|
|
Assert-HandoffCargoPair $acceptedRelease 'Accepted staged Cargo output'
|
|
$passed++
|
|
|
|
foreach ($boundProbe in @(
|
|
[pscustomobject]@{ Name = 'target-count'; Overrides = @{ TargetFileCountLimit = 3 } },
|
|
[pscustomobject]@{
|
|
Name = 'target-bytes'
|
|
Overrides = @{
|
|
TargetByteLimit = ([long](Assert-HandoffMappingBounds `
|
|
$targetMappings 8 1MB 'Fixture target bytes')) - 1
|
|
}
|
|
},
|
|
[pscustomobject]@{ Name = 'resource-count'; Overrides = @{ ResourceFileCountLimit = 1 } },
|
|
[pscustomobject]@{ Name = 'nsis-count'; Overrides = @{ NsisFileCountLimit = 1 } }
|
|
)) {
|
|
$probeDestination = Join-Path $handoffFixtureRoot ([string]$boundProbe.Name)
|
|
$probeArguments = @{} + $transactionArguments
|
|
foreach ($override in $boundProbe.Overrides.GetEnumerator()) {
|
|
$probeArguments[$override.Key] = $override.Value
|
|
}
|
|
Assert-Throws {
|
|
Invoke-HandoffDestinationTransaction `
|
|
-DestinationRoot $probeDestination @probeArguments | Out-Null
|
|
} 'deterministic file-count or byte bound' `
|
|
"handoff $($boundProbe.Name) rejection"
|
|
Assert-Equal (Test-Path -LiteralPath $probeDestination) $false `
|
|
"handoff $($boundProbe.Name) leaves no destination"
|
|
}
|
|
|
|
$tamperedDestination = Join-Path $handoffFixtureRoot 'tampered-stage'
|
|
Assert-Throws {
|
|
Invoke-HandoffDestinationTransaction `
|
|
-DestinationRoot $tamperedDestination @transactionArguments `
|
|
-FinalSourceAssertion {
|
|
param([string]$StagedRoot)
|
|
[IO.File]::WriteAllText(
|
|
(Join-Path $StagedRoot 'resources\service.js'),
|
|
'tampered-after-copy'
|
|
)
|
|
} | Out-Null
|
|
} 'inventory changed during handoff staging' `
|
|
'handoff tampered staged inventory rejection'
|
|
Assert-Equal (Test-Path -LiteralPath $tamperedDestination) $false `
|
|
'handoff tamper rollback removes the entire destination'
|
|
|
|
$collisionMappings = @($resourceMappings) + @(
|
|
[pscustomobject]@{ Source = $sourceResource; Path = 'SERVICE.JS' }
|
|
)
|
|
Assert-Throws {
|
|
New-HandoffInventory $collisionMappings 'Fixture collision' | Out-Null
|
|
} 'duplicate path or case-insensitive collision' `
|
|
'handoff case-insensitive collision rejection'
|
|
Assert-Throws {
|
|
Invoke-HandoffDestinationTransaction `
|
|
-DestinationRoot (Join-Path $handoffFixtureRoot '..\escape') `
|
|
@transactionArguments | Out-Null
|
|
} 'safe, fully qualified local Windows path|absent direct child' `
|
|
'handoff unsafe destination rejection'
|
|
|
|
$adsRoot = Join-Path $handoffFixtureRoot 'ads-tree'
|
|
[IO.Directory]::CreateDirectory($adsRoot) | Out-Null
|
|
$adsFile = Join-Path $adsRoot 'payload.bin'
|
|
[IO.File]::WriteAllText($adsFile, 'visible')
|
|
[IO.File]::WriteAllText("${adsFile}:hidden", 'hidden')
|
|
Assert-Throws {
|
|
Get-HandoffTreeFiles $adsRoot 'Fixture ADS tree' | Out-Null
|
|
} 'alternate data stream' 'handoff ADS rejection'
|
|
|
|
$reparseTarget = Join-Path $handoffFixtureRoot 'reparse-target'
|
|
$reparseRoot = Join-Path $handoffFixtureRoot 'reparse-tree'
|
|
[IO.Directory]::CreateDirectory($reparseTarget) | Out-Null
|
|
[IO.Directory]::CreateDirectory($reparseRoot) | Out-Null
|
|
[IO.File]::WriteAllText((Join-Path $reparseTarget 'payload.bin'), 'outside')
|
|
$handoffReparsePath = Join-Path $reparseRoot 'linked'
|
|
New-Item -ItemType Junction -Path $handoffReparsePath `
|
|
-Target $reparseTarget | Out-Null
|
|
Assert-Throws {
|
|
Get-HandoffTreeFiles $reparseRoot 'Fixture reparse tree' | Out-Null
|
|
} 'reparse point|linked directory' 'handoff reparse rejection'
|
|
|
|
$git = [string](
|
|
Get-Command git.exe -CommandType Application -ErrorAction Stop |
|
|
Select-Object -First 1 -ExpandProperty Source
|
|
)
|
|
$repoFixture = Join-Path $handoffFixtureRoot 'repo'
|
|
[IO.Directory]::CreateDirectory($repoFixture) | Out-Null
|
|
& $git -C $repoFixture init --quiet
|
|
& $git -C $repoFixture config user.email 'handoff-test@invalid.example'
|
|
& $git -C $repoFixture config user.name 'Waggle Handoff Test'
|
|
for ($index = 0; $index -lt 101; $index++) {
|
|
[IO.File]::WriteAllText(
|
|
(Join-Path $repoFixture ("input-{0:D3}.txt" -f $index)),
|
|
"canonical-$index",
|
|
[Text.UTF8Encoding]::new($false)
|
|
)
|
|
}
|
|
& $git -C $repoFixture add -- .
|
|
& $git -C $repoFixture commit --quiet -m 'fixture'
|
|
& $git -C $repoFixture tag v1.0.0
|
|
$fixtureRevision = [string](& $git -C $repoFixture rev-parse HEAD)
|
|
$trackedFixture = @('input-000.txt')
|
|
Assert-HandoffRepositoryState `
|
|
$git $repoFixture $fixtureRevision '1.0.0' $trackedFixture
|
|
$passed++
|
|
[IO.File]::AppendAllText((Join-Path $repoFixture 'input-000.txt'), 'dirty')
|
|
Assert-Throws {
|
|
Assert-HandoffRepositoryState `
|
|
$git $repoFixture $fixtureRevision '1.0.0' $trackedFixture
|
|
} 'clean exact tagged' 'handoff dirty repository rejection'
|
|
& $git -C $repoFixture checkout --quiet -- input-000.txt
|
|
& $git -C $repoFixture tag -d v1.0.0 | Out-Null
|
|
Assert-Throws {
|
|
Assert-HandoffRepositoryState `
|
|
$git $repoFixture $fixtureRevision '1.0.0' $trackedFixture 2>$null
|
|
} 'clean exact tagged' 'handoff missing release tag rejection'
|
|
& $git -C $repoFixture tag v1.0.0
|
|
& $git -C $repoFixture update-index --skip-worktree input-000.txt
|
|
Assert-Throws {
|
|
Assert-HandoffRepositoryState `
|
|
$git $repoFixture $fixtureRevision '1.0.0' $trackedFixture
|
|
} 'non-default tracked state' 'handoff skip-worktree index rejection'
|
|
& $git -C $repoFixture update-index --no-skip-worktree input-000.txt
|
|
} finally {
|
|
if ($null -ne $handoffReparsePath -and
|
|
(Test-Path -LiteralPath $handoffReparsePath)) {
|
|
[IO.Directory]::Delete($handoffReparsePath)
|
|
}
|
|
if (Test-Path -LiteralPath $handoffFixtureRoot) {
|
|
foreach ($fixtureItem in @(Get-ChildItem `
|
|
-LiteralPath $handoffFixtureRoot -Force -Recurse)) {
|
|
if (($fixtureItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) {
|
|
$fixtureItem.Attributes = [IO.FileAttributes]::Normal
|
|
}
|
|
}
|
|
[IO.Directory]::Delete($handoffFixtureRoot, $true)
|
|
}
|
|
}
|
|
}
|
|
|
|
& {
|
|
$portableFixtureParent = Join-Path `
|
|
([IO.Path]::GetTempPath()) `
|
|
"waggle-portable-tools-$([Guid]::NewGuid().ToString('N'))"
|
|
[IO.Directory]::CreateDirectory($portableFixtureParent) | Out-Null
|
|
$portableRoot = New-PrivateDirectory (Join-Path $portableFixtureParent 'private')
|
|
$node = Join-Path $portableRoot 'node\node.exe'
|
|
$git = Join-Path $portableRoot 'git\cmd\git.exe'
|
|
$gitRuntime = Join-Path $portableRoot 'git\mingw64\bin\git.exe'
|
|
$gitDependency = Join-Path $portableRoot 'git\mingw64\bin\libcrypto-3-x64.dll'
|
|
$sevenZip = Join-Path $portableRoot 'sevenzip\7z.exe'
|
|
$sevenZipDll = Join-Path $portableRoot 'sevenzip\7z.dll'
|
|
$outsideNode = Join-Path $portableFixtureParent 'outside-node.exe'
|
|
foreach ($path in @(
|
|
$node, $git, $gitRuntime, $gitDependency,
|
|
$sevenZip, $sevenZipDll, $outsideNode
|
|
)) {
|
|
[IO.Directory]::CreateDirectory((Split-Path $path -Parent)) | Out-Null
|
|
[IO.File]::WriteAllText($path, "fixture:$([IO.Path]::GetFileName($path))")
|
|
}
|
|
|
|
function Get-FileHash {
|
|
param([string]$LiteralPath, [string]$Algorithm)
|
|
if ($null -ne (Get-Variable receiptPath -ErrorAction SilentlyContinue) -and
|
|
[string]::Equals(
|
|
[IO.Path]::GetFullPath($LiteralPath),
|
|
[IO.Path]::GetFullPath($receiptPath),
|
|
[StringComparison]::OrdinalIgnoreCase
|
|
)) {
|
|
return [pscustomobject]@{ Hash = Get-RealSha256 $LiteralPath }
|
|
}
|
|
$content = [IO.File]::ReadAllText($LiteralPath)
|
|
$hash = if ($content -match 'tampered') {
|
|
'0' * 64
|
|
} else {
|
|
switch ([IO.Path]::GetFileName($LiteralPath)) {
|
|
'node.exe' { $NodeSha256 }
|
|
'git.exe' {
|
|
if ($LiteralPath -match 'mingw64') { $GitRuntimeSha256 } else { $GitSha256 }
|
|
}
|
|
'7z.exe' { $SevenZipSha256 }
|
|
'7z.dll' { $SevenZipDllSha256 }
|
|
'node.zip' { $NodeArchiveSha256 }
|
|
'mingit.zip' { $GitArchiveSha256 }
|
|
'sevenzip.exe' { $SevenZipArchiveSha256 }
|
|
default { 'F' * 64 }
|
|
}
|
|
}
|
|
return [pscustomobject]@{ Hash = $hash }
|
|
}
|
|
|
|
$archiveRoot = Join-Path $portableFixtureParent 'downloads'
|
|
[IO.Directory]::CreateDirectory($archiveRoot) | Out-Null
|
|
$nodeArchive = Join-Path $archiveRoot 'node.zip'
|
|
$gitArchive = Join-Path $archiveRoot 'mingit.zip'
|
|
$sevenZipArchive = Join-Path $archiveRoot 'sevenzip.exe'
|
|
foreach ($archive in @($nodeArchive, $gitArchive, $sevenZipArchive)) {
|
|
[IO.File]::WriteAllText($archive, 'fixture:vendor-archive')
|
|
}
|
|
$portableInventory = New-WagglePrebuiltInventory -Root $portableRoot
|
|
$receiptPath = Join-Path $portableFixtureParent 'portable-receipt.json'
|
|
Write-WaggleJsonNoBom $receiptPath ([ordered]@{
|
|
schemaVersion = 1
|
|
portableToolchainRoot = $portableRoot
|
|
archives = [ordered]@{
|
|
node = [ordered]@{ path = $nodeArchive; sha256 = $NodeArchiveSha256 }
|
|
git = [ordered]@{ path = $gitArchive; sha256 = $GitArchiveSha256 }
|
|
sevenZip = [ordered]@{ path = $sevenZipArchive; sha256 = $SevenZipArchiveSha256 }
|
|
}
|
|
inventory = $portableInventory
|
|
})
|
|
$portableReceiptJson = [IO.File]::ReadAllText($receiptPath)
|
|
$receiptSha256 = Get-RealSha256 $receiptPath
|
|
$savedPortableFileCount = $PortableToolchainFileCount
|
|
$savedPortableInventorySha256 = $PortableToolchainInventorySha256
|
|
$PortableToolchainFileCount = @($portableInventory.entries).Count
|
|
$PortableToolchainInventorySha256 = $portableInventory.sha256
|
|
|
|
function Get-AuthenticodeSignature {
|
|
param([string]$LiteralPath)
|
|
$content = [IO.File]::ReadAllText($LiteralPath)
|
|
$subject = switch ([IO.Path]::GetFileName($LiteralPath)) {
|
|
'node.exe' { $NodePublisher }
|
|
'git.exe' {
|
|
if ($content -match 'wrong-publisher') { $MicrosoftPublisher } else { $GitPublisher }
|
|
}
|
|
'dotnet.exe' {
|
|
if ($content -match 'wrong-publisher') { $MicrosoftPublisher } else { $DotNetPublisher }
|
|
}
|
|
default { $MicrosoftPublisher }
|
|
}
|
|
$oids = [Security.Cryptography.OidCollection]::new()
|
|
[void]$oids.Add([Security.Cryptography.Oid]::new($CodeSigningOid))
|
|
$eku = [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new(
|
|
$oids,
|
|
$false
|
|
)
|
|
return [pscustomobject]@{
|
|
Status = [Management.Automation.SignatureStatus]::Valid
|
|
SignatureType = 'Authenticode'
|
|
SignerCertificate = [pscustomobject]@{
|
|
Subject = $subject
|
|
Extensions = @($eku)
|
|
}
|
|
}
|
|
}
|
|
|
|
$portableEnvironmentNames = @(
|
|
'WAGGLE_SIGNING_PORTABLE_TOOLCHAIN_ROOT',
|
|
'WAGGLE_SIGNING_PORTABLE_NODE_PATH',
|
|
'WAGGLE_SIGNING_PORTABLE_GIT_PATH',
|
|
'WAGGLE_SIGNING_PORTABLE_SEVEN_ZIP_PATH',
|
|
'WAGGLE_SIGNING_PORTABLE_RECEIPT_PATH',
|
|
'WAGGLE_SIGNING_PORTABLE_RECEIPT_SHA256',
|
|
'WAGGLE_SIGNING_MANIFEST_PATH',
|
|
'WAGGLE_SIGNING_MANIFEST_SHA256'
|
|
)
|
|
$savedPortableEnvironment = @{}
|
|
foreach ($name in $portableEnvironmentNames) {
|
|
$savedPortableEnvironment[$name] = [Environment]::GetEnvironmentVariable($name)
|
|
}
|
|
try {
|
|
$portable = Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath $receiptSha256
|
|
Assert-Equal $portable.NodePath $node 'portable Node path validation'
|
|
Assert-Equal $portable.GitPath $git 'portable Git path validation'
|
|
Assert-Equal $portable.GitRuntimePath $gitRuntime `
|
|
'portable Git runtime closure validation'
|
|
Assert-Equal $portable.SevenZipDllPath $sevenZipDll `
|
|
'portable 7-Zip closure validation'
|
|
foreach ($lock in $portable.Locks) { $lock.Dispose() }
|
|
|
|
[IO.File]::AppendAllText(
|
|
$receiptPath, ' ', [Text.UTF8Encoding]::new($false)
|
|
)
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath $receiptSha256
|
|
} 'does not match its handoff SHA-256' `
|
|
'portable raw receipt byte tamper with stale digest rejection'
|
|
[IO.File]::WriteAllText(
|
|
$receiptPath, $portableReceiptJson, [Text.UTF8Encoding]::new($false)
|
|
)
|
|
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath ('E' * 64)
|
|
} 'does not match its handoff SHA-256' 'portable receipt handoff digest rejection'
|
|
|
|
$mutatedReceipt = $portableReceiptJson | ConvertFrom-Json -Depth 32
|
|
$mutatedReceipt.schemaVersion = 2
|
|
Write-WaggleJsonNoBom $receiptPath $mutatedReceipt
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath `
|
|
(Get-RealSha256 $receiptPath)
|
|
} 'schemaVersion must be 1' 'portable receipt schema rejection'
|
|
[IO.File]::WriteAllText(
|
|
$receiptPath, $portableReceiptJson, [Text.UTF8Encoding]::new($false)
|
|
)
|
|
|
|
$mutatedReceipt = $portableReceiptJson | ConvertFrom-Json -Depth 32
|
|
$mutatedReceipt.portableToolchainRoot = $portableFixtureParent
|
|
Write-WaggleJsonNoBom $receiptPath $mutatedReceipt
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath `
|
|
(Get-RealSha256 $receiptPath)
|
|
} 'receipt root' 'portable receipt root substitution rejection'
|
|
[IO.File]::WriteAllText(
|
|
$receiptPath, $portableReceiptJson, [Text.UTF8Encoding]::new($false)
|
|
)
|
|
|
|
$mutatedReceipt = $portableReceiptJson | ConvertFrom-Json -Depth 32
|
|
$mutatedReceipt.archives.node.sha256 = '0' * 64
|
|
Write-WaggleJsonNoBom $receiptPath $mutatedReceipt
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath `
|
|
(Get-RealSha256 $receiptPath)
|
|
} 'exact distinct repository-pinned handoff' `
|
|
'portable receipt recomputed archive hash rejection'
|
|
[IO.File]::WriteAllText(
|
|
$receiptPath, $portableReceiptJson, [Text.UTF8Encoding]::new($false)
|
|
)
|
|
|
|
$mutatedReceipt = $portableReceiptJson | ConvertFrom-Json -Depth 32
|
|
$mutatedReceipt.archives.node.path = $gitArchive
|
|
Write-WaggleJsonNoBom $receiptPath $mutatedReceipt
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath `
|
|
(Get-RealSha256 $receiptPath)
|
|
} 'exact distinct repository-pinned handoff|does not match its pinned SHA-256' `
|
|
'portable receipt recomputed archive path rejection'
|
|
[IO.File]::WriteAllText(
|
|
$receiptPath, $portableReceiptJson, [Text.UTF8Encoding]::new($false)
|
|
)
|
|
|
|
$mutatedReceipt = $portableReceiptJson | ConvertFrom-Json -Depth 32
|
|
$mutatedReceipt.inventory.sha256 = '0' * 64
|
|
Write-WaggleJsonNoBom $receiptPath $mutatedReceipt
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath `
|
|
(Get-RealSha256 $receiptPath)
|
|
} 'repository-pinned full closure' `
|
|
'portable receipt recomputed inventory aggregate rejection'
|
|
[IO.File]::WriteAllText(
|
|
$receiptPath, $portableReceiptJson, [Text.UTF8Encoding]::new($false)
|
|
)
|
|
|
|
$mutatedReceipt = $portableReceiptJson | ConvertFrom-Json -Depth 32
|
|
$mutatedReceipt.inventory.entries[0].sha256 = '0' * 64
|
|
Write-WaggleJsonNoBom $receiptPath $mutatedReceipt
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath `
|
|
(Get-RealSha256 $receiptPath)
|
|
} 'inventory aggregate SHA-256 digest is invalid' `
|
|
'portable receipt recomputed inventory entry rejection'
|
|
[IO.File]::WriteAllText(
|
|
$receiptPath, $portableReceiptJson, [Text.UTF8Encoding]::new($false)
|
|
)
|
|
|
|
$mutatedReceipt = $portableReceiptJson | ConvertFrom-Json -Depth 32
|
|
$mutatedReceipt.inventory.entries = @($mutatedReceipt.inventory.entries)[1..(
|
|
@($mutatedReceipt.inventory.entries).Count - 1
|
|
)]
|
|
Write-WaggleJsonNoBom $receiptPath $mutatedReceipt
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath `
|
|
(Get-RealSha256 $receiptPath)
|
|
} 'repository-pinned full closure' `
|
|
'portable receipt recomputed inventory full-count rejection'
|
|
[IO.File]::WriteAllText(
|
|
$receiptPath, $portableReceiptJson, [Text.UTF8Encoding]::new($false)
|
|
)
|
|
|
|
$unexpectedDependency = Join-Path $portableRoot 'git\mingw64\bin\injected.dll'
|
|
[IO.File]::WriteAllText($unexpectedDependency, 'unexpected')
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath $receiptSha256
|
|
} 'missing, extra, or unexpected files' `
|
|
'portable unexpected adjacent dependency rejection'
|
|
[IO.File]::Delete($unexpectedDependency)
|
|
|
|
[IO.File]::WriteAllText($gitDependency, 'tampered adjacent dependency')
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath $receiptSha256
|
|
} 'inventory SHA-256 digest and size' `
|
|
'portable mutated adjacent dependency rejection'
|
|
[IO.File]::WriteAllText($gitDependency, 'fixture:libcrypto-3-x64.dll')
|
|
|
|
[IO.File]::Delete($gitDependency)
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath $receiptSha256
|
|
} 'missing, extra, or unexpected files' `
|
|
'portable missing adjacent dependency rejection'
|
|
[IO.File]::WriteAllText($gitDependency, 'fixture:libcrypto-3-x64.dll')
|
|
|
|
[IO.File]::WriteAllText($node, 'tampered')
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath $receiptSha256
|
|
} 'inventory SHA-256 digest and size' 'portable Node tamper rejection'
|
|
[IO.File]::WriteAllText($node, 'fixture:node.exe')
|
|
|
|
[IO.File]::WriteAllText($git, 'wrong-publisher')
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath $receiptSha256
|
|
} 'Git executable.*approved publisher' 'portable Git publisher rejection'
|
|
[IO.File]::WriteAllText($git, 'fixture:git.exe')
|
|
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $outsideNode $git $sevenZip $receiptPath $receiptSha256
|
|
} 'contained by the private portable toolchain root' `
|
|
'portable tool path containment rejection'
|
|
Assert-Throws {
|
|
Get-WagglePortableToolchain `
|
|
$portableRoot $node $git $sevenZip $receiptPath $receiptSha256 `
|
|
-DisallowedRoots @($portableFixtureParent)
|
|
} 'overlaps.*boundary' 'portable root overlap rejection'
|
|
|
|
[Environment]::SetEnvironmentVariable(
|
|
'WAGGLE_SIGNING_PORTABLE_TOOLCHAIN_ROOT', $portableRoot
|
|
)
|
|
[Environment]::SetEnvironmentVariable('WAGGLE_SIGNING_PORTABLE_NODE_PATH', $node)
|
|
[Environment]::SetEnvironmentVariable('WAGGLE_SIGNING_PORTABLE_GIT_PATH', $git)
|
|
[Environment]::SetEnvironmentVariable('WAGGLE_SIGNING_PORTABLE_SEVEN_ZIP_PATH', $sevenZip)
|
|
[Environment]::SetEnvironmentVariable('WAGGLE_SIGNING_PORTABLE_RECEIPT_PATH', $receiptPath)
|
|
[Environment]::SetEnvironmentVariable('WAGGLE_SIGNING_PORTABLE_RECEIPT_SHA256', $receiptSha256)
|
|
[Environment]::SetEnvironmentVariable('WAGGLE_SIGNING_MANIFEST_PATH', $null)
|
|
[Environment]::SetEnvironmentVariable('WAGGLE_SIGNING_MANIFEST_SHA256', $null)
|
|
Assert-Throws {
|
|
Get-WaggleSigningToolchain
|
|
} 'active receipt-bound signing session' `
|
|
'portable callback outside receipt-bound session rejection'
|
|
$resolvedPortable = Get-WaggleSigningToolchain -AllowPortableBeforeManifest
|
|
Assert-Equal $resolvedPortable.PortableToolchainRoot $portableRoot `
|
|
'Package setup resolves exact portable root'
|
|
foreach ($lock in $resolvedPortable.Locks) { $lock.Dispose() }
|
|
[Environment]::SetEnvironmentVariable('WAGGLE_SIGNING_PORTABLE_NODE_PATH', $null)
|
|
Assert-Throws {
|
|
Get-WaggleSigningToolchain -AllowPortableBeforeManifest
|
|
} 'must provide the exact root, Node, Git, 7-Zip, receipt path, and receipt SHA-256' `
|
|
'partial portable environment rejection'
|
|
|
|
$dotnetFixture = Join-Path $portableRoot 'dotnet.exe'
|
|
[IO.File]::WriteAllText($dotnetFixture, 'wrong-publisher')
|
|
Assert-Throws {
|
|
Assert-MicrosoftAuthenticodeFile `
|
|
$dotnetFixture '.NET host' $null $DotNetPublisher
|
|
} 'approved publisher' 'generic Microsoft signer rejected for .NET host'
|
|
[IO.File]::WriteAllText($dotnetFixture, 'exact-dotnet-publisher')
|
|
Assert-MicrosoftAuthenticodeFile `
|
|
$dotnetFixture '.NET host' $null $DotNetPublisher
|
|
$script:passed++
|
|
} finally {
|
|
$PortableToolchainFileCount = $savedPortableFileCount
|
|
$PortableToolchainInventorySha256 = $savedPortableInventorySha256
|
|
foreach ($name in $portableEnvironmentNames) {
|
|
[Environment]::SetEnvironmentVariable($name, $savedPortableEnvironment[$name])
|
|
}
|
|
[IO.Directory]::Delete($portableFixtureParent, $true)
|
|
}
|
|
}
|
|
|
|
Assert-Equal `
|
|
(Get-ArtifactPolicyKind 'D:\repo\app\src-tauri\target\x86_64-pc-windows-msvc\release\waggle.exe' $releaseRoot $resourcesRoot $tempRoot $version) `
|
|
'PE' 'main executable policy'
|
|
Assert-Equal `
|
|
(Get-ArtifactPolicyKind 'D:\repo\app\src-tauri\target\x86_64-pc-windows-msvc\release\bundle\nsis\Waggle_0.2.0_x64-setup.exe' $releaseRoot $resourcesRoot $tempRoot $version) `
|
|
'PE' 'NSIS installer policy'
|
|
Assert-Throws {
|
|
Get-ArtifactPolicyKind 'D:\repo\app\src-tauri\target\x86_64-pc-windows-msvc\release\bundle\msi\Waggle_0.2.0_x64_en-US.msi' $releaseRoot $resourcesRoot $tempRoot $version
|
|
} 'approved Tauri release manifest' 'MSI rejection for NSIS-only production signing'
|
|
Assert-Throws {
|
|
Get-ArtifactPolicyKind 'D:\repo\app\src-tauri\target\x86_64-pc-windows-msvc\release\wix\x64\wix\WixUIExtension.dll' $releaseRoot $resourcesRoot $tempRoot $version
|
|
} 'approved Tauri release manifest' 'WiX rejection for NSIS-only production signing'
|
|
Assert-Equal `
|
|
(Get-ArtifactPolicyKind 'D:\repo\app\src-tauri\resources\native\vec0.dll' $releaseRoot $resourcesRoot $tempRoot $version) `
|
|
'PE' 'resource policy'
|
|
Assert-Equal `
|
|
(Get-ArtifactPolicyKind 'D:\repo\app\src-tauri\target\.signing-temp\run-123456\nst9DA7.tmp' $releaseRoot $resourcesRoot $tempRoot $version) `
|
|
'PE' 'NSIS uninstaller policy'
|
|
|
|
Assert-Throws {
|
|
Get-ArtifactPolicyKind 'D:\outside\malware.exe' $releaseRoot $resourcesRoot $tempRoot $version
|
|
} 'outside every approved' 'outside-root rejection'
|
|
Assert-Throws {
|
|
Get-ArtifactPolicyKind 'D:\repo\app\src-tauri\target\x86_64-pc-windows-msvc\release-evil\waggle.exe' $releaseRoot $resourcesRoot $tempRoot $version
|
|
} 'outside every approved' 'prefix-collision rejection'
|
|
Assert-Throws {
|
|
Get-ArtifactPolicyKind 'D:\repo\app\src-tauri\target\.signing-temp\run-123456\evil.tmp' $releaseRoot $resourcesRoot $tempRoot $version
|
|
} 'outside every approved' 'wrong temp name rejection'
|
|
Assert-Throws {
|
|
Get-ArtifactPolicyKind 'D:\repo\app\src-tauri\resources\node.exe' $releaseRoot $resourcesRoot $tempRoot $version
|
|
} 'resource manifest' 'vendor binary rejection'
|
|
Assert-Throws {
|
|
Get-TrustedPath 'D:\safe\..\outside.exe' 'Traversal probe'
|
|
} 'safe, fully qualified' 'traversal rejection'
|
|
Assert-Throws {
|
|
Get-TrustedPath '\\server\share\outside.exe' 'UNC probe'
|
|
} 'safe, fully qualified' 'UNC rejection'
|
|
Assert-Throws {
|
|
Get-TrustedPath '\\?\C:\outside.exe' 'Device probe'
|
|
} 'safe, fully qualified' 'device path rejection'
|
|
Assert-Throws {
|
|
Get-TrustedPath 'D:\outside.exe:stream' 'ADS probe'
|
|
} 'safe, fully qualified' 'ADS rejection'
|
|
|
|
$probeRoot = Join-Path ([IO.Path]::GetTempPath()) "waggle-signing-policy-$([Guid]::NewGuid().ToString('N'))"
|
|
$targetDirectory = Join-Path $probeRoot 'target'
|
|
$junctionPath = Join-Path $probeRoot 'junction'
|
|
$fakeToolDirectory = Join-Path $probeRoot 'x64'
|
|
$hardLinkReleaseRoot = Join-Path $probeRoot 'release'
|
|
try {
|
|
[IO.Directory]::CreateDirectory($targetDirectory) | Out-Null
|
|
[IO.File]::WriteAllText((Join-Path $targetDirectory 'payload.exe'), 'not a PE')
|
|
New-Item -ItemType Junction -Path $junctionPath -Target $targetDirectory | Out-Null
|
|
Assert-Throws {
|
|
Get-TrustedPath (Join-Path $junctionPath 'payload.exe') 'Junction probe'
|
|
} 'reparse point|linked filesystem' 'junction rejection'
|
|
|
|
[IO.Directory]::CreateDirectory($fakeToolDirectory) | Out-Null
|
|
$patchableMain = Join-Path $probeRoot 'patchable-waggle.exe'
|
|
New-SyntheticPatchablePe $patchableMain
|
|
$expectedPatchedBytes = [IO.File]::ReadAllBytes($patchableMain)
|
|
$expectedToken = [Text.Encoding]::ASCII.GetBytes('__TAURI_BUNDLE_TYPE_VAR_NSS')
|
|
[Array]::Copy($expectedToken, 0, $expectedPatchedBytes, 160, $expectedToken.Length)
|
|
$expectedPatchedHash = [Convert]::ToHexString(
|
|
[Security.Cryptography.SHA256]::HashData($expectedPatchedBytes)
|
|
)
|
|
Assert-Equal `
|
|
(Get-NsisPatchedMainSha256 $patchableMain) `
|
|
$expectedPatchedHash `
|
|
'Tauri NSIS main patch pre-authorization hash'
|
|
$missingPatchToken = Join-Path $probeRoot 'missing-patch-token.exe'
|
|
New-SyntheticPe $missingPatchToken
|
|
Assert-Throws {
|
|
Get-NsisPatchedMainSha256 $missingPatchToken
|
|
} 'exactly one unpatched' 'missing Tauri bundle token rejection'
|
|
$duplicatePatchToken = Join-Path $probeRoot 'duplicate-patch-token.exe'
|
|
$duplicateBytes = [byte[]]::new(320)
|
|
$sourceBytes = [IO.File]::ReadAllBytes($patchableMain)
|
|
[Array]::Copy($sourceBytes, $duplicateBytes, $sourceBytes.Length)
|
|
$unknownToken = [Text.Encoding]::ASCII.GetBytes('__TAURI_BUNDLE_TYPE_VAR_UNK')
|
|
[Array]::Copy($unknownToken, 0, $duplicateBytes, 270, $unknownToken.Length)
|
|
[IO.File]::WriteAllBytes($duplicatePatchToken, $duplicateBytes)
|
|
Assert-Throws {
|
|
Get-NsisPatchedMainSha256 $duplicatePatchToken
|
|
} 'exactly one unpatched' 'duplicate Tauri bundle token rejection'
|
|
$fakeSignTool = Join-Path $fakeToolDirectory 'signtool.exe'
|
|
$fakeDlib = Join-Path $fakeToolDirectory 'Azure.CodeSigning.Dlib.dll'
|
|
[IO.File]::WriteAllText($fakeSignTool, 'impostor')
|
|
[IO.File]::WriteAllText($fakeDlib, 'impostor')
|
|
Assert-Throws {
|
|
Assert-MicrosoftAuthenticodeFile `
|
|
(Get-TrustedPath $fakeSignTool 'Fake SignTool') 'Fake SignTool' $null
|
|
} 'not validly Authenticode-signed by the approved publisher' 'fake SignTool rejection'
|
|
Assert-Throws {
|
|
Assert-MicrosoftAuthenticodeFile `
|
|
(Get-TrustedPath $fakeDlib 'Fake dlib') 'Fake dlib' $ArtifactSigningDlibSha256
|
|
} 'not validly Authenticode-signed by the approved publisher' 'fake dlib rejection'
|
|
|
|
$currentPowerShell = Get-TrustedPath (Get-Process -Id $PID).Path 'Current PowerShell'
|
|
Assert-MicrosoftAuthenticodeFile $currentPowerShell 'Current PowerShell' $null
|
|
$passed++
|
|
$approvedPowerShell = Get-ApprovedPowerShell7Path
|
|
Assert-ApprovedPowerShell7Path $approvedPowerShell
|
|
$passed++
|
|
Assert-FreshProcessModuleIsolation `
|
|
$approvedPowerShell 'PowerShell 7' $probeRoot
|
|
if (Test-Path -LiteralPath $SystemPowerShellPath -PathType Leaf) {
|
|
Assert-FreshProcessModuleIsolation `
|
|
$SystemPowerShellPath 'Windows PowerShell' $probeRoot
|
|
}
|
|
|
|
$privateDirectory = New-PrivateDirectory (Join-Path $probeRoot 'private')
|
|
if (-not (Get-Acl -LiteralPath $privateDirectory).AreAccessRulesProtected) {
|
|
throw 'Private signing directory test still inherits permissions.'
|
|
}
|
|
$passed++
|
|
if ((New-PrivateDirectory $privateDirectory) -cne $privateDirectory) {
|
|
throw 'Private signing directory hardening is not idempotent.'
|
|
}
|
|
$passed++
|
|
|
|
$readLock = Open-ReadLock $fakeSignTool
|
|
try {
|
|
Assert-Throws {
|
|
$writeProbe = [IO.File]::Open(
|
|
$fakeSignTool,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None
|
|
)
|
|
$writeProbe.Dispose()
|
|
} 'used by another process|cannot access' 'read-lock write rejection'
|
|
} finally {
|
|
$readLock.Dispose()
|
|
}
|
|
|
|
$hardLinkDeps = Join-Path $hardLinkReleaseRoot 'deps'
|
|
[IO.Directory]::CreateDirectory($hardLinkDeps) | Out-Null
|
|
$hardLinkDependency = Join-Path $hardLinkDeps 'waggle.exe'
|
|
$hardLinkMain = Join-Path $hardLinkReleaseRoot 'waggle.exe'
|
|
[IO.File]::WriteAllText($hardLinkDependency, 'cargo executable probe')
|
|
New-Item -ItemType HardLink -Path $hardLinkMain -Target $hardLinkDependency | Out-Null
|
|
$originalSystemRoot = $env:SystemRoot
|
|
try {
|
|
$env:SystemRoot = $probeRoot
|
|
Assert-Equal `
|
|
(Get-SystemFsutilPath) `
|
|
'C:\Windows\System32\fsutil.exe' `
|
|
'fixed fsutil path ignores environment'
|
|
} finally {
|
|
$env:SystemRoot = $originalSystemRoot
|
|
}
|
|
Assert-ApprovedHardLinkTopology $hardLinkMain $hardLinkReleaseRoot
|
|
$passed++
|
|
$detachedReleaseRoot = Join-Path $probeRoot 'detached-release'
|
|
[IO.Directory]::CreateDirectory($detachedReleaseRoot) | Out-Null
|
|
$detachedMain = Join-Path $detachedReleaseRoot 'waggle.exe'
|
|
[IO.File]::WriteAllText($detachedMain, 'detached executable probe')
|
|
Assert-Throws {
|
|
Assert-ApprovedHardLinkTopology $detachedMain $detachedReleaseRoot
|
|
} 'must have.*hard-link topology' 'detached Cargo main rejection'
|
|
Assert-ApprovedHardLinkTopology $detachedMain $detachedReleaseRoot -AllowDetachedMain
|
|
$passed++
|
|
$rogueHardLink = Join-Path $probeRoot 'rogue.exe'
|
|
New-Item -ItemType HardLink -Path $rogueHardLink -Target $hardLinkDependency | Out-Null
|
|
Assert-Throws {
|
|
Assert-ApprovedHardLinkTopology $hardLinkMain $hardLinkReleaseRoot
|
|
} 'unexpected hard-link sibling' 'rogue hard-link rejection'
|
|
} finally {
|
|
if (Test-Path -LiteralPath $junctionPath) { [IO.Directory]::Delete($junctionPath) }
|
|
if (Test-Path -LiteralPath $probeRoot) { [IO.Directory]::Delete($probeRoot, $true) }
|
|
}
|
|
|
|
$prebuiltFixtureRoot = Join-Path `
|
|
([IO.Path]::GetTempPath()) `
|
|
"waggle-prebuilt-tree-$([Guid]::NewGuid().ToString('N'))"
|
|
$reparseDirectory = $null
|
|
try {
|
|
$seedRoot = Join-Path $prebuiltFixtureRoot 'seed'
|
|
[IO.Directory]::CreateDirectory((Join-Path $seedRoot 'Bin')) | Out-Null
|
|
[IO.Directory]::CreateDirectory((Join-Path $seedRoot 'resources')) | Out-Null
|
|
[IO.File]::WriteAllBytes(
|
|
(Join-Path $seedRoot 'Bin\Payload.bin'),
|
|
[Text.Encoding]::UTF8.GetBytes('immutable-payload-one')
|
|
)
|
|
[IO.File]::WriteAllBytes(
|
|
(Join-Path $seedRoot 'resources\data.bin'),
|
|
[Text.Encoding]::UTF8.GetBytes('immutable-payload-two')
|
|
)
|
|
$expectedInventory = New-WagglePrebuiltInventory -Root $seedRoot
|
|
if (@($expectedInventory.entries).Count -ne 2 -or
|
|
[string]$expectedInventory.sha256 -notmatch '^[0-9A-Fa-f]{64}$') {
|
|
throw 'Prebuilt inventory lacks its exact canonical entries and aggregate digest.'
|
|
}
|
|
$passed++
|
|
|
|
$treeLease = Open-WaggleValidatedPrebuiltTree `
|
|
-Root $seedRoot -ExpectedInventory $expectedInventory -Label 'Pristine prebuilt tree'
|
|
try {
|
|
foreach ($entry in @($expectedInventory.entries)) {
|
|
$lockedPath = Join-Path $seedRoot ([string]$entry.path)
|
|
Assert-Throws {
|
|
$writeProbe = [IO.File]::Open(
|
|
$lockedPath,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None
|
|
)
|
|
$writeProbe.Dispose()
|
|
} 'used by another process|cannot access' 'validated prebuilt tree holds every file read-locked'
|
|
}
|
|
} finally {
|
|
Close-PrebuiltTreeLease $treeLease
|
|
}
|
|
|
|
$mutablePath = Join-Path $seedRoot 'Bin\Payload.bin'
|
|
$immutablePath = Join-Path $seedRoot 'resources\data.bin'
|
|
$mutableLease = Open-WaggleValidatedPrebuiltTree `
|
|
-Root $seedRoot -ExpectedInventory $expectedInventory `
|
|
-Label 'Signing-mutable prebuilt tree' -MutablePaths @($mutablePath)
|
|
try {
|
|
$mutableWriteProbe = [IO.File]::Open(
|
|
$mutablePath,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None
|
|
)
|
|
$mutableWriteProbe.Dispose()
|
|
$passed++
|
|
Assert-Throws {
|
|
$immutableWriteProbe = [IO.File]::Open(
|
|
$immutablePath,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None
|
|
)
|
|
$immutableWriteProbe.Dispose()
|
|
} 'used by another process|cannot access' 'mutable tree keeps immutable files read-locked'
|
|
} finally {
|
|
Close-PrebuiltTreeLease $mutableLease
|
|
}
|
|
|
|
foreach ($passName in @('unsigned-generated-roots', 'signing-generated-roots')) {
|
|
$generatedTreeRoot = Join-Path $prebuiltFixtureRoot $passName
|
|
$generatedReleaseRoot = Join-Path `
|
|
$generatedTreeRoot 'x86_64-pc-windows-msvc\release'
|
|
$generatedNsisRoot = Join-Path $generatedReleaseRoot 'nsis'
|
|
$generatedBundleNsisRoot = Join-Path $generatedReleaseRoot 'bundle\nsis'
|
|
$generatedNsisSiblingRoot = Join-Path $generatedReleaseRoot 'nsis-evil'
|
|
$generatedBundleSiblingRoot = Join-Path $generatedReleaseRoot 'bundle\nsis-old'
|
|
$generatedResourceRoot = Join-Path $generatedTreeRoot 'resources'
|
|
foreach ($directory in @(
|
|
$generatedNsisRoot, $generatedBundleNsisRoot,
|
|
$generatedNsisSiblingRoot, $generatedBundleSiblingRoot,
|
|
$generatedResourceRoot
|
|
)) {
|
|
[IO.Directory]::CreateDirectory($directory) | Out-Null
|
|
}
|
|
$generatedFiles = @(
|
|
[pscustomobject]@{
|
|
Path = Join-Path $generatedNsisRoot 'x64\Plugins\fixture.dll'
|
|
Content = 'tauri-nsis-output'
|
|
}
|
|
[pscustomobject]@{
|
|
Path = Join-Path $generatedBundleNsisRoot 'Waggle_fixture_x64-setup.exe'
|
|
Content = 'tauri-installer-output'
|
|
}
|
|
[pscustomobject]@{
|
|
Path = Join-Path $generatedNsisSiblingRoot 'sibling.dll'
|
|
Content = 'locked-nsis-sibling'
|
|
}
|
|
[pscustomobject]@{
|
|
Path = Join-Path $generatedBundleSiblingRoot 'sibling.exe'
|
|
Content = 'locked-bundle-sibling'
|
|
}
|
|
[pscustomobject]@{
|
|
Path = Join-Path $generatedResourceRoot 'data.bin'
|
|
Content = 'locked-resource'
|
|
}
|
|
)
|
|
foreach ($file in $generatedFiles) {
|
|
[IO.Directory]::CreateDirectory((Split-Path ([string]$file.Path) -Parent)) | Out-Null
|
|
[IO.File]::WriteAllBytes(
|
|
[string]$file.Path, [Text.Encoding]::UTF8.GetBytes([string]$file.Content)
|
|
)
|
|
}
|
|
$generatedInventory = New-WagglePrebuiltInventory -Root $generatedTreeRoot
|
|
$generatedLease = Open-WaggleValidatedPrebuiltTree `
|
|
-Root $generatedTreeRoot -ExpectedInventory $generatedInventory `
|
|
-Label "$passName private work tree" `
|
|
-RegeneratedRoots @($generatedNsisRoot, $generatedBundleNsisRoot) `
|
|
-CargoReleaseRelativePath 'x86_64-pc-windows-msvc\release'
|
|
try {
|
|
Clear-WaggleRegeneratedRoots $generatedLease
|
|
Assert-Equal `
|
|
(Test-Path -LiteralPath $generatedNsisRoot) $false `
|
|
"$passName clears the exact Tauri NSIS work root"
|
|
Assert-Equal `
|
|
(Test-Path -LiteralPath $generatedBundleNsisRoot) $false `
|
|
"$passName clears the exact Tauri installer output root"
|
|
Assert-Equal `
|
|
(Test-Path -LiteralPath $generatedNsisSiblingRoot -PathType Container) $true `
|
|
"$passName preserves the nsis-evil sibling root"
|
|
Assert-Equal `
|
|
(Test-Path -LiteralPath $generatedBundleSiblingRoot -PathType Container) $true `
|
|
"$passName preserves the bundle nsis-old sibling root"
|
|
foreach ($lockedFile in @(
|
|
(Join-Path $generatedNsisSiblingRoot 'sibling.dll'),
|
|
(Join-Path $generatedBundleSiblingRoot 'sibling.exe'),
|
|
(Join-Path $generatedResourceRoot 'data.bin')
|
|
)) {
|
|
Assert-Throws {
|
|
$writeProbe = [IO.File]::Open(
|
|
$lockedFile,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None
|
|
)
|
|
$writeProbe.Dispose()
|
|
} 'used by another process|cannot access' `
|
|
"$passName retains the non-generated work-tree lock for $lockedFile"
|
|
}
|
|
} finally {
|
|
Close-PrebuiltTreeLease $generatedLease
|
|
}
|
|
}
|
|
|
|
$copySeed = {
|
|
param([string]$Name)
|
|
$destination = Join-Path $prebuiltFixtureRoot $Name
|
|
[IO.Directory]::CreateDirectory($destination) | Out-Null
|
|
Copy-Item -Path (Join-Path $seedRoot '*') -Destination $destination -Recurse
|
|
return $destination
|
|
}
|
|
|
|
$extraRoot = & $copySeed 'extra'
|
|
[IO.File]::WriteAllText((Join-Path $extraRoot 'unexpected.bin'), 'unexpected')
|
|
Assert-Throws {
|
|
Open-WaggleValidatedPrebuiltTree `
|
|
-Root $extraRoot -ExpectedInventory $expectedInventory -Label 'Extra-file tree'
|
|
} 'extra|unexpected|inventory' 'prebuilt tree extra-file rejection'
|
|
|
|
$missingRoot = & $copySeed 'missing'
|
|
[IO.File]::Delete((Join-Path $missingRoot 'resources\data.bin'))
|
|
Assert-Throws {
|
|
Open-WaggleValidatedPrebuiltTree `
|
|
-Root $missingRoot -ExpectedInventory $expectedInventory -Label 'Missing-file tree'
|
|
} 'missing|inventory' 'prebuilt tree missing-file rejection'
|
|
|
|
$mutatedRoot = & $copySeed 'same-size-mutation'
|
|
$mutatedPath = Join-Path $mutatedRoot 'Bin\Payload.bin'
|
|
$originalLength = (Get-Item -LiteralPath $mutatedPath).Length
|
|
[IO.File]::WriteAllBytes(
|
|
$mutatedPath,
|
|
[Text.Encoding]::UTF8.GetBytes('tampered-payload-one!')
|
|
)
|
|
Assert-Equal `
|
|
(Get-Item -LiteralPath $mutatedPath).Length `
|
|
$originalLength `
|
|
'same-size prebuilt mutation fixture'
|
|
Assert-Throws {
|
|
Open-WaggleValidatedPrebuiltTree `
|
|
-Root $mutatedRoot -ExpectedInventory $expectedInventory -Label 'Mutated tree'
|
|
} 'SHA-256|digest|inventory' 'prebuilt tree same-size mutation rejection'
|
|
|
|
$collisionInventory = $expectedInventory | ConvertTo-Json -Depth 32 |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$collisionEntry = $collisionInventory.entries[0] | ConvertTo-Json -Depth 8 |
|
|
ConvertFrom-Json -Depth 8 -DateKind String
|
|
$collisionEntry.path = ([string]$collisionEntry.path).ToUpperInvariant()
|
|
$collisionInventory.entries = @($collisionInventory.entries) + @($collisionEntry)
|
|
Assert-Throws {
|
|
Open-WaggleValidatedPrebuiltTree `
|
|
-Root $seedRoot -ExpectedInventory $collisionInventory -Label 'Case-collision inventory'
|
|
} 'case-insensitive|case collision|duplicate canonical' 'prebuilt inventory case-collision rejection'
|
|
|
|
$traversalInventory = $expectedInventory | ConvertTo-Json -Depth 32 |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$traversalInventory.entries[0].path = '..\outside.bin'
|
|
Assert-Throws {
|
|
Open-WaggleValidatedPrebuiltTree `
|
|
-Root $seedRoot -ExpectedInventory $traversalInventory -Label 'Traversal inventory'
|
|
} 'relative|traversal|canonical|unsafe' 'prebuilt inventory traversal rejection'
|
|
|
|
$adsRoot = & $copySeed 'alternate-data-stream'
|
|
$adsCarrier = Join-Path $adsRoot 'Bin\Payload.bin'
|
|
[IO.File]::WriteAllText("${adsCarrier}:waggle-probe", 'hidden-content')
|
|
Assert-Throws {
|
|
Open-WaggleValidatedPrebuiltTree `
|
|
-Root $adsRoot -ExpectedInventory $expectedInventory -Label 'ADS tree'
|
|
} 'alternate data stream|ADS|named stream' 'prebuilt tree ADS rejection'
|
|
|
|
$reparseRoot = & $copySeed 'reparse'
|
|
$reparseDirectory = Join-Path $reparseRoot 'resources'
|
|
$reparseTarget = Join-Path $prebuiltFixtureRoot 'reparse-target'
|
|
Move-Item -LiteralPath $reparseDirectory -Destination $reparseTarget
|
|
New-Item -ItemType Junction -Path $reparseDirectory -Target $reparseTarget | Out-Null
|
|
Assert-Throws {
|
|
Open-WaggleValidatedPrebuiltTree `
|
|
-Root $reparseRoot -ExpectedInventory $expectedInventory -Label 'Reparse tree'
|
|
} 'reparse point|linked filesystem|junction' 'prebuilt tree reparse rejection'
|
|
|
|
$unapprovedLinkRoot = & $copySeed 'unapproved-hardlink'
|
|
$unapprovedLink = Join-Path $unapprovedLinkRoot 'rogue-link.bin'
|
|
Copy-Item -LiteralPath (Join-Path $unapprovedLinkRoot 'resources\data.bin') `
|
|
-Destination $unapprovedLink
|
|
$unapprovedLinkInventory = New-WagglePrebuiltInventory -Root $unapprovedLinkRoot
|
|
[IO.File]::Delete($unapprovedLink)
|
|
New-Item -ItemType HardLink -Path $unapprovedLink `
|
|
-Target (Join-Path $unapprovedLinkRoot 'resources\data.bin') | Out-Null
|
|
Assert-Throws {
|
|
Open-WaggleValidatedPrebuiltTree `
|
|
-Root $unapprovedLinkRoot -ExpectedInventory $unapprovedLinkInventory `
|
|
-Label 'Unapproved hard-link tree'
|
|
} 'hard-link|hardlink|link topology' 'prebuilt tree unapproved hard-link rejection'
|
|
|
|
$approvedLinkRoot = Join-Path $prebuiltFixtureRoot 'approved-hardlink'
|
|
$approvedDependency = Join-Path $approvedLinkRoot 'release\deps\waggle.exe'
|
|
$approvedMain = Join-Path $approvedLinkRoot 'release\waggle.exe'
|
|
[IO.Directory]::CreateDirectory((Split-Path $approvedDependency -Parent)) | Out-Null
|
|
New-SyntheticPe $approvedDependency 91
|
|
New-Item -ItemType HardLink -Path $approvedMain -Target $approvedDependency | Out-Null
|
|
$approvedLinkInventory = New-WagglePrebuiltInventory -Root $approvedLinkRoot
|
|
$approvedLinkLease = Open-WaggleValidatedPrebuiltTree `
|
|
-Root $approvedLinkRoot -ExpectedInventory $approvedLinkInventory `
|
|
-Label 'Approved Cargo hard-link tree'
|
|
try {
|
|
Assert-ApprovedHardLinkTopology `
|
|
$approvedMain (Join-Path $approvedLinkRoot 'release')
|
|
$passed++
|
|
} finally {
|
|
Close-PrebuiltTreeLease $approvedLinkLease
|
|
}
|
|
|
|
$combinedSourceRoot = Join-Path $prebuiltFixtureRoot 'combined-source'
|
|
$combinedReleaseRelative = 'x86_64-pc-windows-msvc\release'
|
|
$combinedDependency = Join-Path `
|
|
$combinedSourceRoot "$combinedReleaseRelative\deps\waggle.exe"
|
|
$combinedMain = Join-Path $combinedSourceRoot "$combinedReleaseRelative\waggle.exe"
|
|
$combinedResource = Join-Path $combinedSourceRoot 'resources\service.js'
|
|
[IO.Directory]::CreateDirectory((Split-Path $combinedDependency -Parent)) | Out-Null
|
|
[IO.Directory]::CreateDirectory((Split-Path $combinedResource -Parent)) | Out-Null
|
|
New-SyntheticPe $combinedDependency 92
|
|
New-Item -ItemType HardLink -Path $combinedMain -Target $combinedDependency | Out-Null
|
|
[IO.File]::WriteAllText($combinedResource, 'receipt-bound-sidecar')
|
|
$combinedInventory = New-WagglePrebuiltInventory `
|
|
-Root $combinedSourceRoot -CargoReleaseRelativePath $combinedReleaseRelative
|
|
$requiredDiskBytes = Assert-WaggleHostedDiskCapacity `
|
|
-Path $combinedSourceRoot -Inventory $combinedInventory -AvailableBytes ([long]::MaxValue)
|
|
Assert-Throws {
|
|
Assert-WaggleHostedDiskCapacity `
|
|
-Path $combinedSourceRoot -Inventory $combinedInventory `
|
|
-AvailableBytes ($requiredDiskBytes - 1)
|
|
} 'insufficient free disk space' 'hosted signing disk-capacity fail-fast'
|
|
$passed++
|
|
$combinedSourceLease = Open-WaggleValidatedPrebuiltTree `
|
|
-Root $combinedSourceRoot -ExpectedInventory $combinedInventory `
|
|
-Label 'Combined hosted input tree' `
|
|
-CargoReleaseRelativePath $combinedReleaseRelative
|
|
$combinedWorkRoot = Join-Path $prebuiltFixtureRoot 'combined-work'
|
|
try {
|
|
$sourceHashBeforeCopy = Get-Sha256 $combinedMain
|
|
New-WagglePrebuiltWorkCopy `
|
|
-SourceRoot $combinedSourceRoot -DestinationRoot $combinedWorkRoot `
|
|
-Inventory $combinedInventory -Label 'Combined hosted input tree' `
|
|
-CargoReleaseRelativePath $combinedReleaseRelative | Out-Null
|
|
Assert-Equal (Get-Sha256 $combinedMain) $sourceHashBeforeCopy `
|
|
'work-copy creation leaves receipt source unchanged'
|
|
$workMain = Join-Path $combinedWorkRoot "$combinedReleaseRelative\waggle.exe"
|
|
$workDependency = Join-Path `
|
|
$combinedWorkRoot "$combinedReleaseRelative\deps\waggle.exe"
|
|
Assert-ApprovedHardLinkTopology `
|
|
$workMain (Join-Path $combinedWorkRoot $combinedReleaseRelative)
|
|
$workLinks = @(Get-HardLinkPaths $workMain)
|
|
if ($workLinks.Count -ne 2 -or $workLinks -notcontains $workMain -or
|
|
$workLinks -notcontains $workDependency -or
|
|
$workLinks -contains $combinedMain -or $workLinks -contains $combinedDependency) {
|
|
throw 'Work copy did not create a destination-local Cargo hard-link pair.'
|
|
}
|
|
$passed++
|
|
$combinedWorkLease = Open-WaggleValidatedPrebuiltTree `
|
|
-Root $combinedWorkRoot -ExpectedInventory $combinedInventory `
|
|
-Label 'Combined mutable private work tree' `
|
|
-MutablePaths @($workMain, $workDependency) `
|
|
-CargoReleaseRelativePath $combinedReleaseRelative
|
|
try {
|
|
$writeProbe = [IO.File]::Open(
|
|
$workMain,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None
|
|
)
|
|
$writeProbe.Dispose()
|
|
Assert-Throws {
|
|
$writeProbe = [IO.File]::Open(
|
|
(Join-Path $combinedWorkRoot 'resources\service.js'),
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None
|
|
)
|
|
$writeProbe.Dispose()
|
|
} 'used by another process|cannot access|being used|denied' `
|
|
'private work tree locks immutable resources while Cargo pair stays mutable'
|
|
} finally {
|
|
Close-PrebuiltTreeLease $combinedWorkLease
|
|
}
|
|
} finally {
|
|
Close-PrebuiltTreeLease $combinedSourceLease
|
|
if (Test-Path -LiteralPath $combinedWorkRoot) {
|
|
[IO.Directory]::Delete($combinedWorkRoot, $true)
|
|
}
|
|
}
|
|
|
|
$resourcesInventory = New-WagglePrebuiltInventory `
|
|
-Root (Join-Path $seedRoot 'resources')
|
|
$hostedReceiptPath = Join-Path $prebuiltFixtureRoot 'build-receipt.json'
|
|
$hostedReceipt = [pscustomobject][ordered]@{
|
|
schemaVersion = 1
|
|
repository = 'marolinik/waggle-os'
|
|
sourceRevision = 'a' * 40
|
|
targetTriple = 'x86_64-pc-windows-msvc'
|
|
targetInventory = $expectedInventory
|
|
resourcesInventory = $resourcesInventory
|
|
checker = [pscustomobject]@{ exitCode = 0; sha256 = 'A' * 64 }
|
|
}
|
|
Write-JsonNoBom $hostedReceiptPath $hostedReceipt
|
|
$hostedReceiptLease = Get-WaggleHostedBuildReceipt `
|
|
$hostedReceiptPath (Get-Sha256 $hostedReceiptPath) ('a' * 40)
|
|
$hostedReceiptLease.Lock.Dispose()
|
|
$passed++
|
|
$mismatchedReceipt = $hostedReceipt | ConvertTo-Json -Depth 32 |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$mismatchedReceipt.resourcesInventory.entries[0].sha256 = 'B' * 64
|
|
$mismatchedReceipt.resourcesInventory.sha256 = Get-WaggleInventorySha256 `
|
|
@($mismatchedReceipt.resourcesInventory.entries)
|
|
Write-JsonNoBom $hostedReceiptPath $mismatchedReceipt
|
|
Assert-Throws {
|
|
Get-WaggleHostedBuildReceipt `
|
|
$hostedReceiptPath (Get-Sha256 $hostedReceiptPath) ('a' * 40)
|
|
} 'resource projection.*does not match' `
|
|
'hosted receipt rejects independently valid mismatched resource inventory'
|
|
|
|
$payloadProjection = @(
|
|
[pscustomobject]@{ path = 'waggle.exe'; size = 128; sha256 = 'C' * 64 },
|
|
[pscustomobject]@{
|
|
path = 'resources\data.bin'
|
|
size = [long]$resourcesInventory.entries[0].size
|
|
sha256 = [string]$resourcesInventory.entries[0].sha256
|
|
}
|
|
)
|
|
Assert-WaggleUnsignedPayloadResourceProjection `
|
|
$payloadProjection $resourcesInventory
|
|
$passed++
|
|
$tamperedPayloadProjection = $payloadProjection | ConvertTo-Json -Depth 8 |
|
|
ConvertFrom-Json -Depth 8 -DateKind String
|
|
$tamperedPayloadProjection[1].sha256 = 'D' * 64
|
|
Assert-Throws {
|
|
Assert-WaggleUnsignedPayloadResourceProjection `
|
|
$tamperedPayloadProjection $resourcesInventory
|
|
} 'resource projection.*does not match' `
|
|
'unsigned payload rejects resource digest mismatch against build receipt'
|
|
|
|
$preflightSigningRoot = Join-Path $prebuiltFixtureRoot 'preflight-signing'
|
|
$preflightUnsignedRoot = Join-Path $prebuiltFixtureRoot 'preflight-unsigned'
|
|
$preflightRelative = 'resources\service.js'
|
|
$preflightSigningPath = Join-Path $preflightSigningRoot $preflightRelative
|
|
$preflightUnsignedPath = Join-Path $preflightUnsignedRoot $preflightRelative
|
|
[IO.Directory]::CreateDirectory((Split-Path $preflightSigningPath -Parent)) | Out-Null
|
|
[IO.Directory]::CreateDirectory((Split-Path $preflightUnsignedPath -Parent)) | Out-Null
|
|
[IO.File]::WriteAllText($preflightSigningPath, 'signing-copy')
|
|
[IO.File]::WriteAllText($preflightUnsignedPath, 'unsigned-copy')
|
|
Assert-Equal `
|
|
(Resolve-WagglePreflightFixedPath `
|
|
$preflightSigningPath $preflightSigningRoot $preflightUnsignedRoot) `
|
|
([IO.Path]::GetFullPath($preflightUnsignedPath)) `
|
|
'manifest preflight resolves same relative path from unsigned work tree'
|
|
Assert-Throws {
|
|
Resolve-WagglePreflightFixedPath `
|
|
(Join-Path $prebuiltFixtureRoot 'outside.bin') `
|
|
$preflightSigningRoot $preflightUnsignedRoot
|
|
} 'escaped.*signing target root' 'manifest preflight rejects signing-root escape'
|
|
|
|
$rollbackRoot = Join-Path $prebuiltFixtureRoot 'rollback'
|
|
$rollbackDependency = Join-Path $rollbackRoot 'release\deps\waggle.exe'
|
|
$rollbackMain = Join-Path $rollbackRoot 'release\waggle.exe'
|
|
[IO.Directory]::CreateDirectory((Split-Path $rollbackDependency -Parent)) | Out-Null
|
|
New-SyntheticPe $rollbackDependency 101
|
|
New-Item -ItemType HardLink -Path $rollbackMain -Target $rollbackDependency | Out-Null
|
|
$expectedOriginalSha256 = Get-Sha256 $rollbackMain
|
|
$expectedHardLinkPaths = @(Get-HardLinkPaths $rollbackMain)
|
|
$replacement = Join-Path $rollbackRoot 'signed-replacement.exe'
|
|
$backup = Join-Path $rollbackRoot 'original.backup'
|
|
New-SyntheticPe $replacement 102
|
|
[IO.File]::Replace($replacement, $rollbackMain, $backup, $true)
|
|
if ((Get-Sha256 $rollbackMain) -ceq $expectedOriginalSha256) {
|
|
throw 'Rollback test fixture did not replace the original artifact.'
|
|
}
|
|
$passed++
|
|
Restore-WaggleReplacedArtifact `
|
|
-ArtifactPath $rollbackMain -BackupPath $backup `
|
|
-ExpectedOriginalSha256 $expectedOriginalSha256 `
|
|
-ExpectedHardLinkPaths $expectedHardLinkPaths
|
|
Assert-Equal (Get-Sha256 $rollbackMain) $expectedOriginalSha256 'rollback restores exact main bytes'
|
|
Assert-Equal (Get-Sha256 $rollbackDependency) $expectedOriginalSha256 'rollback preserves exact dependency bytes'
|
|
Assert-ApprovedHardLinkTopology `
|
|
$rollbackMain (Join-Path $rollbackRoot 'release')
|
|
$passed++
|
|
|
|
$regularRollbackArtifact = Join-Path $rollbackRoot 'regular-artifact.dll'
|
|
$regularRollbackReplacement = Join-Path $rollbackRoot 'regular-replacement.dll'
|
|
$regularRollbackBackup = Join-Path $rollbackRoot 'regular.backup'
|
|
New-SyntheticPe $regularRollbackArtifact 106
|
|
New-SyntheticPe $regularRollbackReplacement 107
|
|
$regularOriginalHash = Get-Sha256 $regularRollbackArtifact
|
|
[IO.File]::Replace(
|
|
$regularRollbackReplacement,
|
|
$regularRollbackArtifact,
|
|
$regularRollbackBackup,
|
|
$true
|
|
)
|
|
Restore-WaggleReplacedArtifact `
|
|
-ArtifactPath $regularRollbackArtifact -BackupPath $regularRollbackBackup `
|
|
-ExpectedOriginalSha256 $regularOriginalHash `
|
|
-ExpectedHardLinkPaths @($regularRollbackArtifact)
|
|
Assert-Equal `
|
|
(Get-Sha256 $regularRollbackArtifact) $regularOriginalHash `
|
|
'rollback restores exact regular-artifact bytes'
|
|
|
|
$missingBackupArtifact = Join-Path $rollbackRoot 'missing-backup-artifact.exe'
|
|
$missingBackup = Join-Path $rollbackRoot 'missing.backup'
|
|
New-SyntheticPe $missingBackupArtifact 103
|
|
$missingArtifactHash = Get-Sha256 $missingBackupArtifact
|
|
Assert-Throws {
|
|
Restore-WaggleReplacedArtifact `
|
|
-ArtifactPath $missingBackupArtifact -BackupPath $missingBackup `
|
|
-ExpectedOriginalSha256 ('0' * 64) `
|
|
-ExpectedHardLinkPaths @($missingBackupArtifact)
|
|
} 'backup.*missing|does not exist' 'rollback missing-backup rejection'
|
|
Assert-Equal `
|
|
(Get-Sha256 $missingBackupArtifact) $missingArtifactHash `
|
|
'missing rollback backup leaves replacement unchanged'
|
|
|
|
$corruptBackupArtifact = Join-Path $rollbackRoot 'corrupt-backup-artifact.exe'
|
|
$corruptBackup = Join-Path $rollbackRoot 'corrupt.backup'
|
|
New-SyntheticPe $corruptBackupArtifact 104
|
|
New-SyntheticPe $corruptBackup 105
|
|
$corruptArtifactHash = Get-Sha256 $corruptBackupArtifact
|
|
Assert-Throws {
|
|
Restore-WaggleReplacedArtifact `
|
|
-ArtifactPath $corruptBackupArtifact -BackupPath $corruptBackup `
|
|
-ExpectedOriginalSha256 ('0' * 64) `
|
|
-ExpectedHardLinkPaths @($corruptBackupArtifact)
|
|
} 'backup.*SHA-256|backup.*digest|original.*digest' 'rollback corrupt-backup rejection'
|
|
Assert-Equal `
|
|
(Get-Sha256 $corruptBackupArtifact) $corruptArtifactHash `
|
|
'corrupt rollback backup leaves replacement unchanged'
|
|
|
|
Assert-Throws {
|
|
Open-WaggleValidatedPrebuiltTree `
|
|
-Root $seedRoot -ExpectedInventory $expectedInventory `
|
|
-Label 'Overlapping prebuilt tree' -DisallowedRoots @($prebuiltFixtureRoot)
|
|
} 'overlap|disallowed root|must be distinct' 'prebuilt tree root-overlap rejection'
|
|
} finally {
|
|
if ($null -ne $reparseDirectory -and (Test-Path -LiteralPath $reparseDirectory)) {
|
|
[IO.Directory]::Delete($reparseDirectory)
|
|
}
|
|
if (Test-Path -LiteralPath $prebuiltFixtureRoot) {
|
|
[IO.Directory]::Delete($prebuiltFixtureRoot, $true)
|
|
}
|
|
}
|
|
|
|
$sessionFixtureRoot = Join-Path `
|
|
([IO.Path]::GetTempPath()) `
|
|
"waggle-signing-session-$([Guid]::NewGuid().ToString('N'))"
|
|
$environmentNames = @(
|
|
'WAGGLE_SIGNING_MANIFEST_PATH', 'WAGGLE_SIGNING_MANIFEST_SHA256',
|
|
'WAGGLE_SIGNING_SESSION_ID', 'CARGO_TARGET_DIR', 'TEMP', 'TMP',
|
|
'WAGGLE_NSIS_SIGNING_TEMP_ROOT', 'NODE_OPTIONS', 'NODE_PATH',
|
|
'WAGGLE_SIGNING_PORTABLE_TOOLCHAIN_ROOT',
|
|
'WAGGLE_SIGNING_PORTABLE_NODE_PATH',
|
|
'WAGGLE_SIGNING_PORTABLE_GIT_PATH',
|
|
'WAGGLE_SIGNING_PORTABLE_SEVEN_ZIP_PATH',
|
|
'NAPI_RS_NATIVE_LIBRARY_PATH', 'NAPI_RS_FORCE_WASI',
|
|
'npm_config_node_options', 'TARGET_ARCH'
|
|
)
|
|
$savedEnvironment = @{}
|
|
foreach ($name in $environmentNames) {
|
|
$savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name)
|
|
}
|
|
try {
|
|
$sessionId = '0123456789abcdef0123456789abcdef'
|
|
$tauriRoot = Join-Path $sessionFixtureRoot 'app\src-tauri'
|
|
$resourcesRoot = Join-Path $tauriRoot 'resources'
|
|
$targetRoot = New-PrivateDirectory `
|
|
(Join-Path $tauriRoot "target\.signing-builds\run-$sessionId")
|
|
$releaseRoot = Join-Path $targetRoot 'x86_64-pc-windows-msvc\release'
|
|
[IO.Directory]::CreateDirectory($resourcesRoot) | Out-Null
|
|
[IO.File]::WriteAllText((Join-Path $resourcesRoot 'service.js'), 'sidecar')
|
|
[IO.Directory]::CreateDirectory($releaseRoot) | Out-Null
|
|
$sessionDirectory = New-PrivateDirectory `
|
|
(Join-Path $tauriRoot "target\.signing-sessions\run-$sessionId")
|
|
$tempRoot = New-PrivateDirectory `
|
|
(Join-Path $tauriRoot "target\.signing-temp\run-$sessionId")
|
|
$configPath = Join-Path $tauriRoot 'tauri.conf.json'
|
|
$overridePath = Join-Path $sessionDirectory 'tauri.signing-override.json'
|
|
Write-JsonNoBom $configPath ([ordered]@{ version = '0.2.0' })
|
|
Write-WaggleSigningOverride `
|
|
$overridePath $wrapperPath $resourcesRoot | Out-Null
|
|
$unsignedOverride = Get-Content -Raw $overridePath | ConvertFrom-Json -Depth 16
|
|
Assert-Equal $unsignedOverride.bundle.targets[0] 'nsis' 'unsigned preflight targets NSIS only'
|
|
Assert-Equal $unsignedOverride.build.beforeBuildCommand '' 'unsigned preflight disables nested build'
|
|
Assert-Equal $unsignedOverride.build.beforeBundleCommand '' 'unsigned preflight disables nested bundle hook'
|
|
$unsignedResourceMap = @($unsignedOverride.bundle.resources.PSObject.Properties)
|
|
Assert-Equal $unsignedResourceMap.Count 1 'unsigned override maps one complete resource tree'
|
|
Assert-Equal `
|
|
([IO.Path]::GetFullPath([string]$unsignedResourceMap[0].Name)) `
|
|
([IO.Path]::GetFullPath($resourcesRoot)) `
|
|
'unsigned override resource source'
|
|
Assert-Equal ([string]$unsignedResourceMap[0].Value) 'resources' `
|
|
'unsigned override resource destination'
|
|
if ($null -ne $unsignedOverride.bundle.windows.PSObject.Properties['signCommand']) {
|
|
throw 'Unsigned preflight override unexpectedly contains a signing command.'
|
|
}
|
|
$passed++
|
|
Write-WaggleSigningOverride `
|
|
$overridePath $wrapperPath $resourcesRoot -EnableSigning | Out-Null
|
|
$signedOverride = Get-Content -Raw $overridePath | ConvertFrom-Json -Depth 16
|
|
Assert-Equal $signedOverride.bundle.windows.signCommand.cmd `
|
|
$SystemPowerShellPath 'signed override uses canonical bootstrap host'
|
|
Assert-Equal `
|
|
(@($signedOverride.bundle.windows.signCommand.args | Where-Object { $_ -ceq '%1' }).Count) `
|
|
1 'signed override contains one artifact placeholder'
|
|
Assert-Equal $signedOverride.build.beforeBundleCommand '' 'signed override disables nested bundle hook'
|
|
$invalidOverride = $signedOverride | ConvertTo-Json -Depth 16 |
|
|
ConvertFrom-Json -Depth 16
|
|
$invalidOverride.bundle.resources = [ordered]@{
|
|
(Join-Path $resourcesRoot 'native') = 'resources'
|
|
}
|
|
Write-JsonNoBom $overridePath $invalidOverride
|
|
Assert-Throws {
|
|
Assert-WaggleSigningOverrideContract `
|
|
$overridePath $wrapperPath $resourcesRoot
|
|
} 'exact receipt-bound resource tree' `
|
|
'signed override rejects receipt resource subroot substitution'
|
|
Write-WaggleSigningOverride `
|
|
$overridePath $wrapperPath $resourcesRoot -EnableSigning | Out-Null
|
|
|
|
$toolDirectory = Join-Path $sessionFixtureRoot 'tools'
|
|
[IO.Directory]::CreateDirectory($toolDirectory) | Out-Null
|
|
$toolNames = @(
|
|
'wrapper.ps1', 'tauri.js', 'tauri-main.js', 'tauri-index.js',
|
|
'tauri-package.json', 'tauri-native-package.json', 'tauri-native.node',
|
|
'vite.js', 'vite-package.json', 'bundle-node.mjs', 'build-sidecar.mjs',
|
|
'bundle-native-deps.mjs', 'stage-sidecar-deps.mjs', 'makensis.exe',
|
|
'git.exe', 'git-runtime.exe', 'node.exe', 'npm-cli.js', '7z.exe', 'signtool.exe',
|
|
'artifact-signing.nupkg'
|
|
)
|
|
$toolPaths = @{}
|
|
foreach ($toolName in $toolNames) {
|
|
$toolPath = Join-Path $toolDirectory $toolName
|
|
[IO.File]::WriteAllText($toolPath, "fixture:$toolName")
|
|
$toolPaths[$toolName] = $toolPath
|
|
}
|
|
|
|
$context = [pscustomobject]@{
|
|
RepoRoot = $sessionFixtureRoot
|
|
AppRoot = Join-Path $sessionFixtureRoot 'app'
|
|
TauriRoot = $tauriRoot
|
|
ResourcesRoot = $resourcesRoot
|
|
TargetRoot = $targetRoot
|
|
ReleaseRoot = $releaseRoot
|
|
ConfigPath = $configPath
|
|
OverrideConfigPath = $overridePath
|
|
WrapperPath = $wrapperPath
|
|
TauriCliPath = $toolPaths['tauri.js']
|
|
TauriCliPackagePath = $toolPaths['tauri-package.json']
|
|
TauriCliMainPath = $toolPaths['tauri-main.js']
|
|
TauriCliIndexPath = $toolPaths['tauri-index.js']
|
|
TauriNativePackagePath = $toolPaths['tauri-native-package.json']
|
|
TauriNativeBinaryPath = $toolPaths['tauri-native.node']
|
|
ViteCliPath = $toolPaths['vite.js']
|
|
VitePackagePath = $toolPaths['vite-package.json']
|
|
WebRoot = Join-Path $sessionFixtureRoot 'apps\web'
|
|
BuildScriptPaths = [ordered]@{
|
|
bundleNode = $toolPaths['bundle-node.mjs']
|
|
buildSidecar = $toolPaths['build-sidecar.mjs']
|
|
bundleNativeDeps = $toolPaths['bundle-native-deps.mjs']
|
|
stageSidecarDeps = $toolPaths['stage-sidecar-deps.mjs']
|
|
}
|
|
MakensisPath = $toolPaths['makensis.exe']
|
|
GitPath = $toolPaths['git.exe']
|
|
GitRuntimePath = $toolPaths['git-runtime.exe']
|
|
PortableToolchainRoot = $null
|
|
NodePath = $toolPaths['node.exe']
|
|
NpmCliPath = $toolPaths['npm-cli.js']
|
|
SevenZipPath = $toolPaths['7z.exe']
|
|
SevenZipDllPath = $toolPaths['7z.exe']
|
|
SignToolPath = $toolPaths['signtool.exe']
|
|
ArtifactSigningPackagePath = $toolPaths['artifact-signing.nupkg']
|
|
SourceRevision = 'a' * 40
|
|
TauriCliVersion = '2.10.1'
|
|
TauriCliSha256 = Get-Sha256 $toolPaths['tauri.js']
|
|
TauriCliPackageSha256 = Get-Sha256 $toolPaths['tauri-package.json']
|
|
TauriCliMainSha256 = Get-Sha256 $toolPaths['tauri-main.js']
|
|
TauriCliIndexSha256 = Get-Sha256 $toolPaths['tauri-index.js']
|
|
TauriNativePackageSha256 = Get-Sha256 $toolPaths['tauri-native-package.json']
|
|
TauriNativeBinarySha256 = Get-Sha256 $toolPaths['tauri-native.node']
|
|
ViteCliSha256 = Get-Sha256 $toolPaths['vite.js']
|
|
VitePackageSha256 = Get-Sha256 $toolPaths['vite-package.json']
|
|
ViteVersion = '6.4.3'
|
|
BuildScriptHashes = [ordered]@{
|
|
bundleNode = Get-Sha256 $toolPaths['bundle-node.mjs']
|
|
buildSidecar = Get-Sha256 $toolPaths['build-sidecar.mjs']
|
|
bundleNativeDeps = Get-Sha256 $toolPaths['bundle-native-deps.mjs']
|
|
stageSidecarDeps = Get-Sha256 $toolPaths['stage-sidecar-deps.mjs']
|
|
}
|
|
MakensisSha256 = Get-Sha256 $toolPaths['makensis.exe']
|
|
GitSha256 = Get-Sha256 $toolPaths['git.exe']
|
|
GitRuntimeSha256 = Get-Sha256 $toolPaths['git-runtime.exe']
|
|
NodeSha256 = Get-Sha256 $toolPaths['node.exe']
|
|
NpmCliSha256 = Get-Sha256 $toolPaths['npm-cli.js']
|
|
SevenZipSha256 = Get-Sha256 $toolPaths['7z.exe']
|
|
SevenZipDllSha256 = Get-Sha256 $toolPaths['7z.exe']
|
|
SignToolSha256 = Get-Sha256 $toolPaths['signtool.exe']
|
|
ArtifactSigningPackageSha256 = Get-Sha256 $toolPaths['artifact-signing.nupkg']
|
|
ArtifactSigningX64ManifestSha256 = 'B' * 64
|
|
}
|
|
$fixedPaths = @(Get-ExpectedNsisFixedPaths $context '0.2.0')
|
|
$packagedPaths = @(Get-ExpectedNsisPackagedPaths)
|
|
$slots = [Collections.Generic.List[object]]::new()
|
|
for ($index = 0; $index -lt $fixedPaths.Count; $index++) {
|
|
New-SyntheticPe $fixedPaths[$index] ([byte]($index + 1))
|
|
$slots.Add([pscustomobject][ordered]@{
|
|
id = 'fixed-{0:d2}' -f ($index + 1)
|
|
order = $index + 1
|
|
kind = 'fixed'
|
|
maxUses = 1
|
|
path = $fixedPaths[$index]
|
|
packagedPath = $packagedPaths[$index]
|
|
preSignSha256 = Get-Sha256 $fixedPaths[$index]
|
|
})
|
|
}
|
|
$slots.Add([pscustomobject][ordered]@{
|
|
id = 'nsis-uninstaller'
|
|
order = 13
|
|
kind = 'generated-nsis-uninstaller'
|
|
maxUses = 1
|
|
pathPattern = $NsisUninstallerPattern
|
|
evidencePath = Join-Path $sessionDirectory 'signed-evidence\13-nsis-uninstaller.exe'
|
|
})
|
|
$slots.Add([pscustomobject][ordered]@{
|
|
id = 'nsis-installer'
|
|
order = 14
|
|
kind = 'generated-nsis-installer'
|
|
maxUses = 1
|
|
path = Join-Path $releaseRoot 'bundle\nsis\Waggle_0.2.0_x64-setup.exe'
|
|
})
|
|
|
|
$manifestPath = Join-Path $sessionDirectory 'manifest.json'
|
|
$ledgerPath = Join-Path $sessionDirectory 'callback-ledger.json'
|
|
$baseManifest = [pscustomobject][ordered]@{
|
|
schemaVersion = 1
|
|
mode = 'nsis'
|
|
sessionId = $sessionId
|
|
sourceRevision = $context.SourceRevision
|
|
createdAtUtc = [DateTimeOffset]::UtcNow.AddMinutes(-1).ToString('O')
|
|
expiresAtUtc = [DateTimeOffset]::UtcNow.AddHours(1).ToString('O')
|
|
repoRoot = $context.RepoRoot
|
|
tauriRoot = $tauriRoot
|
|
targetRoot = $targetRoot
|
|
releaseRoot = $releaseRoot
|
|
resourcesRoot = $resourcesRoot
|
|
tempRoot = $tempRoot
|
|
ledgerPath = $ledgerPath
|
|
appVersion = '0.2.0'
|
|
payloads = @(
|
|
[pscustomobject]@{ path = 'waggle.exe'; sha256 = '1' * 64; size = 128 }
|
|
) + @(1..9 | ForEach-Object {
|
|
[pscustomobject]@{ path = "resources\fixture-$_.bin"; sha256 = '2' * 64; size = $_ }
|
|
})
|
|
toolchain = [pscustomobject][ordered]@{
|
|
wrapperPath = $context.WrapperPath
|
|
wrapperSha256 = Get-Sha256 $context.WrapperPath
|
|
tauriConfigPath = $configPath
|
|
tauriConfigSha256 = Get-Sha256 $configPath
|
|
tauriOverrideConfigPath = $overridePath
|
|
tauriOverrideConfigSha256 = Get-Sha256 $overridePath
|
|
tauriCliPath = $context.TauriCliPath
|
|
tauriCliSha256 = $context.TauriCliSha256
|
|
tauriCliPackagePath = $context.TauriCliPackagePath
|
|
tauriCliPackageSha256 = $context.TauriCliPackageSha256
|
|
tauriCliMainPath = $context.TauriCliMainPath
|
|
tauriCliMainSha256 = $context.TauriCliMainSha256
|
|
tauriCliIndexPath = $context.TauriCliIndexPath
|
|
tauriCliIndexSha256 = $context.TauriCliIndexSha256
|
|
tauriNativePackagePath = $context.TauriNativePackagePath
|
|
tauriNativePackageSha256 = $context.TauriNativePackageSha256
|
|
tauriNativeBinaryPath = $context.TauriNativeBinaryPath
|
|
tauriNativeBinarySha256 = $context.TauriNativeBinarySha256
|
|
tauriCliVersion = $context.TauriCliVersion
|
|
viteCliPath = $context.ViteCliPath
|
|
viteCliSha256 = $context.ViteCliSha256
|
|
vitePackagePath = $context.VitePackagePath
|
|
vitePackageSha256 = $context.VitePackageSha256
|
|
viteVersion = $context.ViteVersion
|
|
buildScripts = [pscustomobject][ordered]@{
|
|
bundleNode = [pscustomobject]@{ path = $context.BuildScriptPaths.bundleNode; sha256 = $context.BuildScriptHashes.bundleNode }
|
|
buildSidecar = [pscustomobject]@{ path = $context.BuildScriptPaths.buildSidecar; sha256 = $context.BuildScriptHashes.buildSidecar }
|
|
bundleNativeDeps = [pscustomobject]@{ path = $context.BuildScriptPaths.bundleNativeDeps; sha256 = $context.BuildScriptHashes.bundleNativeDeps }
|
|
stageSidecarDeps = [pscustomobject]@{ path = $context.BuildScriptPaths.stageSidecarDeps; sha256 = $context.BuildScriptHashes.stageSidecarDeps }
|
|
}
|
|
makensisPath = $context.MakensisPath
|
|
makensisSha256 = $context.MakensisSha256
|
|
gitPath = $context.GitPath
|
|
gitSha256 = $context.GitSha256
|
|
gitRuntimePath = $context.GitRuntimePath
|
|
gitRuntimeSha256 = $context.GitRuntimeSha256
|
|
nodePath = $context.NodePath
|
|
nodeSha256 = $context.NodeSha256
|
|
npmCliPath = $context.NpmCliPath
|
|
npmCliSha256 = $context.NpmCliSha256
|
|
sevenZipPath = $context.SevenZipPath
|
|
sevenZipSha256 = $context.SevenZipSha256
|
|
signToolPath = $context.SignToolPath
|
|
signToolSha256 = $context.SignToolSha256
|
|
artifactSigningPackagePath = $context.ArtifactSigningPackagePath
|
|
artifactSigningPackageSha256 = $context.ArtifactSigningPackageSha256
|
|
artifactSigningX64ManifestSha256 = $context.ArtifactSigningX64ManifestSha256
|
|
}
|
|
slots = @($slots)
|
|
}
|
|
$writeSessionFixture = {
|
|
param([object]$Manifest)
|
|
Write-JsonNoBom $manifestPath $Manifest
|
|
$manifestHash = Get-Sha256 $manifestPath
|
|
Write-JsonNoBom $ledgerPath (New-PendingLedger $sessionId $manifestHash $Manifest.slots)
|
|
$env:WAGGLE_SIGNING_MANIFEST_SHA256 = $manifestHash
|
|
return $manifestHash
|
|
}
|
|
|
|
$env:WAGGLE_SIGNING_MANIFEST_PATH = $manifestPath
|
|
$env:WAGGLE_SIGNING_SESSION_ID = $sessionId
|
|
$env:CARGO_TARGET_DIR = $targetRoot
|
|
$env:TEMP = $tempRoot
|
|
$env:TMP = $tempRoot
|
|
$env:WAGGLE_NSIS_SIGNING_TEMP_ROOT = $tempRoot
|
|
[void](& $writeSessionFixture $baseManifest)
|
|
|
|
$loadedSession = Get-WaggleSigningSession $context
|
|
try {
|
|
Assert-Equal $loadedSession.Id $sessionId 'valid manifest session load'
|
|
foreach ($lockedPath in @(
|
|
$context.TauriCliMainPath,
|
|
$context.TauriCliIndexPath,
|
|
$context.TauriNativeBinaryPath
|
|
)) {
|
|
Assert-Throws {
|
|
$writeProbe = [IO.File]::Open(
|
|
$lockedPath,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Write,
|
|
[IO.FileShare]::None
|
|
)
|
|
$writeProbe.Dispose()
|
|
} 'used by another process|cannot access' 'loaded session locks complete Tauri runtime closure'
|
|
}
|
|
} finally {
|
|
foreach ($lock in $loadedSession.Locks) { $lock.Dispose() }
|
|
}
|
|
|
|
$portableContext = $context | Select-Object *
|
|
$portableContext.PortableToolchainRoot = $toolDirectory
|
|
$portableContext | Add-Member `
|
|
-NotePropertyName PortableToolchainReceiptPath `
|
|
-NotePropertyValue $toolPaths['artifact-signing.nupkg']
|
|
$portableContext | Add-Member `
|
|
-NotePropertyName PortableToolchainReceiptSha256 `
|
|
-NotePropertyValue (Get-Sha256 $toolPaths['artifact-signing.nupkg'])
|
|
$portableContext | Add-Member `
|
|
-NotePropertyName PortableToolchainInventorySha256 `
|
|
-NotePropertyValue $PortableToolchainInventorySha256
|
|
$portableContext | Add-Member `
|
|
-NotePropertyName PortableToolchainLocks `
|
|
-NotePropertyValue ([Collections.Generic.List[IDisposable]]::new())
|
|
$portableContext.SevenZipDllPath = $toolPaths['7z.exe']
|
|
$portableContext.SevenZipDllSha256 = Get-Sha256 $toolPaths['7z.exe']
|
|
$portableManifest = $baseManifest | ConvertTo-Json -Depth 32 |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$portableManifest | Add-Member -NotePropertyName buildReceipt -NotePropertyValue (
|
|
[pscustomobject]@{
|
|
schemaVersion = 1
|
|
repository = 'marolinik/waggle-os'
|
|
sourceRevision = $context.SourceRevision
|
|
targetTriple = 'x86_64-pc-windows-msvc'
|
|
}
|
|
)
|
|
$portableManifest.toolchain | Add-Member `
|
|
-NotePropertyName portableToolchainRoot -NotePropertyValue $toolDirectory
|
|
$portableManifest.toolchain | Add-Member `
|
|
-NotePropertyName sevenZipDllPath -NotePropertyValue $portableContext.SevenZipDllPath
|
|
$portableManifest.toolchain | Add-Member `
|
|
-NotePropertyName sevenZipDllSha256 -NotePropertyValue $portableContext.SevenZipDllSha256
|
|
$portableManifest.toolchain | Add-Member `
|
|
-NotePropertyName portableToolchainReceiptPath `
|
|
-NotePropertyValue $portableContext.PortableToolchainReceiptPath
|
|
$portableManifest.toolchain | Add-Member `
|
|
-NotePropertyName portableToolchainReceiptSha256 `
|
|
-NotePropertyValue $portableContext.PortableToolchainReceiptSha256
|
|
$portableManifest.toolchain | Add-Member `
|
|
-NotePropertyName portableToolchainInventorySha256 `
|
|
-NotePropertyValue $PortableToolchainInventorySha256
|
|
$portableManifest.toolchain | Add-Member `
|
|
-NotePropertyName portableToolchainFileCount `
|
|
-NotePropertyValue $PortableToolchainFileCount
|
|
$env:WAGGLE_SIGNING_PORTABLE_TOOLCHAIN_ROOT = $toolDirectory
|
|
$env:WAGGLE_SIGNING_PORTABLE_NODE_PATH = $context.NodePath
|
|
$env:WAGGLE_SIGNING_PORTABLE_GIT_PATH = $context.GitPath
|
|
$env:WAGGLE_SIGNING_PORTABLE_SEVEN_ZIP_PATH = $context.SevenZipPath
|
|
$env:WAGGLE_SIGNING_PORTABLE_RECEIPT_PATH = `
|
|
$portableContext.PortableToolchainReceiptPath
|
|
$env:WAGGLE_SIGNING_PORTABLE_RECEIPT_SHA256 = `
|
|
$portableContext.PortableToolchainReceiptSha256
|
|
[void](& $writeSessionFixture $portableManifest)
|
|
$portableSession = Get-WaggleSigningSession $portableContext
|
|
try {
|
|
Assert-Equal $portableSession.Id $sessionId `
|
|
'receipt-bound portable manifest session load'
|
|
} finally {
|
|
foreach ($lock in $portableSession.Locks) { $lock.Dispose() }
|
|
}
|
|
|
|
$wrongPortableRootManifest = $portableManifest | ConvertTo-Json -Depth 32 |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$wrongPortableRootManifest.toolchain.portableToolchainRoot = `
|
|
Join-Path $sessionFixtureRoot 'substituted-tools'
|
|
[void](& $writeSessionFixture $wrongPortableRootManifest)
|
|
Assert-Throws {
|
|
Get-WaggleSigningSession $portableContext
|
|
} 'Portable signing toolchain root does not match' `
|
|
'portable manifest root substitution rejection'
|
|
|
|
$wrongPortableHashManifest = $portableManifest | ConvertTo-Json -Depth 32 |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$wrongPortableHashManifest.toolchain.sevenZipDllSha256 = '0' * 64
|
|
[void](& $writeSessionFixture $wrongPortableHashManifest)
|
|
Assert-Throws {
|
|
Get-WaggleSigningSession $portableContext
|
|
} '7-Zip runtime library manifest SHA-256' `
|
|
'portable manifest closure digest substitution rejection'
|
|
|
|
[void](& $writeSessionFixture $portableManifest)
|
|
$env:WAGGLE_SIGNING_PORTABLE_NODE_PATH = $context.GitPath
|
|
Assert-Throws {
|
|
Get-WaggleSigningSession $portableContext
|
|
} 'WAGGLE_SIGNING_PORTABLE_NODE_PATH does not match' `
|
|
'portable callback environment substitution rejection'
|
|
$env:WAGGLE_SIGNING_PORTABLE_NODE_PATH = $context.NodePath
|
|
|
|
$unboundPortableManifest = $portableManifest | ConvertTo-Json -Depth 32 |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$unboundPortableManifest.PSObject.Properties.Remove('buildReceipt')
|
|
[void](& $writeSessionFixture $unboundPortableManifest)
|
|
Assert-Throws {
|
|
Get-WaggleSigningSession $portableContext
|
|
} 'missing required property.*buildReceipt' `
|
|
'portable callback without hosted receipt rejection'
|
|
|
|
foreach ($portableEnvironmentName in @(
|
|
'WAGGLE_SIGNING_PORTABLE_TOOLCHAIN_ROOT',
|
|
'WAGGLE_SIGNING_PORTABLE_NODE_PATH',
|
|
'WAGGLE_SIGNING_PORTABLE_GIT_PATH',
|
|
'WAGGLE_SIGNING_PORTABLE_SEVEN_ZIP_PATH',
|
|
'WAGGLE_SIGNING_PORTABLE_RECEIPT_PATH',
|
|
'WAGGLE_SIGNING_PORTABLE_RECEIPT_SHA256'
|
|
)) {
|
|
[Environment]::SetEnvironmentVariable($portableEnvironmentName, $null)
|
|
}
|
|
[void](& $writeSessionFixture $baseManifest)
|
|
|
|
$validManifestHash = $env:WAGGLE_SIGNING_MANIFEST_SHA256
|
|
$env:WAGGLE_SIGNING_MANIFEST_SHA256 = '0' * 64
|
|
Assert-Throws {
|
|
Get-WaggleSigningSession $context
|
|
} 'manifest does not match.*SHA256|manifest.*WAGGLE_SIGNING_MANIFEST_SHA256' 'wrong manifest digest rejection'
|
|
$env:WAGGLE_SIGNING_MANIFEST_SHA256 = $validManifestHash
|
|
|
|
$unsupportedManifest = $baseManifest | ConvertTo-Json -Depth 32 |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$unsupportedManifest.mode = 'msi'
|
|
[void](& $writeSessionFixture $unsupportedManifest)
|
|
Assert-Throws {
|
|
Get-WaggleSigningSession $context
|
|
} 'NSIS-only' 'unsupported manifest mode rejection'
|
|
|
|
$expiredManifest = $baseManifest | ConvertTo-Json -Depth 32 |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$expiredManifest.createdAtUtc = [DateTimeOffset]::UtcNow.AddHours(-2).ToString('O')
|
|
$expiredManifest.expiresAtUtc = [DateTimeOffset]::UtcNow.AddHours(-1).ToString('O')
|
|
[void](& $writeSessionFixture $expiredManifest)
|
|
Assert-Throws {
|
|
Get-WaggleSigningSession $context
|
|
} 'expired|four-hour' 'expired manifest rejection'
|
|
|
|
$wrongToolManifest = $baseManifest | ConvertTo-Json -Depth 32 |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$wrongToolManifest.toolchain.wrapperSha256 = '0' * 64
|
|
[void](& $writeSessionFixture $wrongToolManifest)
|
|
Assert-Throws {
|
|
Get-WaggleSigningSession $context
|
|
} 'wrapper.*SHA-256' 'wrong wrapper digest rejection'
|
|
|
|
$wrongNativeManifest = $baseManifest | ConvertTo-Json -Depth 32 |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$wrongNativeManifest.toolchain.tauriNativeBinarySha256 = '0' * 64
|
|
[void](& $writeSessionFixture $wrongNativeManifest)
|
|
Assert-Throws {
|
|
Get-WaggleSigningSession $context
|
|
} 'native CLI binary.*SHA-256|native CLI binary.*digest' 'wrong Tauri native digest rejection'
|
|
|
|
Write-JsonNoBom $overridePath ([ordered]@{
|
|
build = @{ beforeBuildCommand = '' }
|
|
bundle = @{ active = $true; targets = @('msi'); windows = @{
|
|
signCommand = @{ cmd = $SystemPowerShellPath; args = @(
|
|
'-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
'-File', $wrapperPath, '-ArtifactPath', '%1'
|
|
) }
|
|
} }
|
|
})
|
|
$wrongOverrideManifest = $baseManifest | ConvertTo-Json -Depth 32 |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$wrongOverrideManifest.toolchain.tauriOverrideConfigSha256 = Get-Sha256 $overridePath
|
|
[void](& $writeSessionFixture $wrongOverrideManifest)
|
|
Assert-Throws {
|
|
Get-WaggleSigningSession $context
|
|
} 'NSIS-only callback contract' 'MSI signing override rejection'
|
|
Write-WaggleSigningOverride $overridePath $wrapperPath -EnableSigning | Out-Null
|
|
|
|
[void](& $writeSessionFixture $baseManifest)
|
|
$env:TEMP = Join-Path $sessionFixtureRoot 'wrong-temp'
|
|
Assert-Throws {
|
|
Get-WaggleSigningSession $context
|
|
} 'TEMP path does not match' 'cross-session TEMP rejection'
|
|
$env:TEMP = $tempRoot
|
|
|
|
[void](& $writeSessionFixture $baseManifest)
|
|
$legacyLedger = [pscustomobject]@{
|
|
schemaVersion = 1
|
|
sessionId = $sessionId
|
|
manifestSha256 = $env:WAGGLE_SIGNING_MANIFEST_SHA256
|
|
nextOrder = 1
|
|
entries = @()
|
|
}
|
|
Assert-Throws {
|
|
Assert-WaggleSigningLedgerState `
|
|
([pscustomobject]@{
|
|
Id = $sessionId
|
|
ManifestSha256 = $env:WAGGLE_SIGNING_MANIFEST_SHA256
|
|
Manifest = $baseManifest
|
|
TempRoot = $tempRoot
|
|
}) `
|
|
$legacyLedger
|
|
} 'active manifest session|schemaVersion' 'legacy empty ledger rejection'
|
|
|
|
$lifecycleDirectory = New-PrivateDirectory (Join-Path $sessionFixtureRoot 'lifecycle')
|
|
$lifecycleTemp = New-PrivateDirectory (Join-Path $lifecycleDirectory 'temp')
|
|
$lifecycleSlots = [Collections.Generic.List[object]]::new()
|
|
$signedHost = Get-TrustedPath (Get-Process -Id $PID).Path 'Signed lifecycle host' -AllowHardLink
|
|
for ($index = 0; $index -lt 14; $index++) {
|
|
$path = if ($index -eq 0) {
|
|
$signedHost
|
|
} else {
|
|
$candidate = Join-Path $lifecycleDirectory "slot-$($index + 1).exe"
|
|
New-SyntheticPe $candidate ([byte]($index + 20))
|
|
$candidate
|
|
}
|
|
$lifecycleSlots.Add([pscustomobject][ordered]@{
|
|
id = 'lifecycle-{0:d2}' -f ($index + 1)
|
|
order = $index + 1
|
|
kind = 'fixed'
|
|
maxUses = 1
|
|
path = $path
|
|
preSignSha256 = Get-Sha256 $path
|
|
})
|
|
}
|
|
$lifecycleLedgerPath = Join-Path $lifecycleDirectory 'callback-ledger.json'
|
|
$lifecycleSession = [pscustomobject]@{
|
|
Id = 'abcdef0123456789abcdef0123456789'
|
|
ManifestSha256 = 'C' * 64
|
|
Manifest = [pscustomobject]@{ slots = @($lifecycleSlots) }
|
|
TempRoot = $lifecycleTemp
|
|
SessionDirectory = $lifecycleDirectory
|
|
LedgerPath = $lifecycleLedgerPath
|
|
ManifestPath = Join-Path $lifecycleDirectory 'manifest.json'
|
|
Context = [pscustomobject]@{ SourceRevision = 'a' * 40 }
|
|
}
|
|
Write-JsonNoBom $lifecycleLedgerPath `
|
|
(New-PendingLedger $lifecycleSession.Id $lifecycleSession.ManifestSha256 $lifecycleSlots)
|
|
$lifecycleLedger = Get-Content -Raw $lifecycleLedgerPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
Assert-WaggleSigningLedgerState $lifecycleSession $lifecycleLedger
|
|
$passed++
|
|
|
|
Assert-Throws {
|
|
Reserve-WaggleSigningCallback $lifecycleSession $lifecycleSlots[1].path
|
|
} 'callback phase' 'cross-phase callback rejection'
|
|
|
|
$ledgerBeforeInvalidMutation = Get-Content -Raw -LiteralPath $lifecycleLedgerPath
|
|
Assert-Throws {
|
|
Invoke-WaggleLedgerMutation $lifecycleSession {
|
|
param($ledger)
|
|
$ledger.entries[0].attempts = 99
|
|
}
|
|
} 'pristine|reservation metadata' 'invalid ledger mutation rejection'
|
|
Assert-Equal `
|
|
(Get-Content -Raw -LiteralPath $lifecycleLedgerPath) `
|
|
$ledgerBeforeInvalidMutation `
|
|
'invalid mutation leaves ledger unchanged'
|
|
|
|
$reservation = Reserve-WaggleSigningCallback $lifecycleSession $lifecycleSlots[0].path
|
|
Assert-Throws {
|
|
Reserve-WaggleSigningCallback $lifecycleSession $lifecycleSlots[0].path
|
|
} 'already consumed|state must be' 'duplicate callback rejection'
|
|
Assert-Throws {
|
|
Reserve-WaggleSigningCallback $lifecycleSession $lifecycleSlots[1].path
|
|
} 'callback phase|state must be' 'reserved callback blocks later slot'
|
|
Complete-WaggleSigningCallback $lifecycleSession $reservation $lifecycleSlots[0].path | Out-Null
|
|
$completedLedger = Get-Content -Raw -LiteralPath $lifecycleLedgerPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
Assert-Equal $completedLedger.entries[0].status 'completed' 'callback completion state'
|
|
[IO.File]::Copy($signedHost, $lifecycleSlots[2].path, $true)
|
|
$lifecycleSlots[2].preSignSha256 = Get-Sha256 $lifecycleSlots[2].path
|
|
$phaseReservation = Reserve-WaggleSigningCallback `
|
|
$lifecycleSession $lifecycleSlots[2].path
|
|
Assert-Equal $phaseReservation.order 3 'resource callback may arrive out of manifest order'
|
|
Complete-WaggleSigningCallback `
|
|
$lifecycleSession $phaseReservation $lifecycleSlots[2].path | Out-Null
|
|
$secondReservation = Reserve-WaggleSigningCallback $lifecycleSession $lifecycleSlots[1].path
|
|
Assert-Equal $secondReservation.order 2 'next ordered callback reservation'
|
|
$failureReceiptPath = Fail-WaggleSigningSession `
|
|
$lifecycleSession $secondReservation 'fixture_failure' 'fixture error' 'restored-original'
|
|
Assert-Equal `
|
|
(Test-Path -LiteralPath $failureReceiptPath -PathType Leaf) `
|
|
$true `
|
|
'failed callback publishes durable failure receipt'
|
|
$failedLedger = Get-Content -Raw -LiteralPath $lifecycleLedgerPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
Assert-Equal $failedLedger.state 'failed' 'callback failure terminal ledger state'
|
|
Assert-Equal $failedLedger.entries[1].status 'failed' 'reserved callback becomes failed'
|
|
$failedLedgerBeforeRetry = Get-Content -Raw -LiteralPath $lifecycleLedgerPath
|
|
Assert-Throws {
|
|
Reserve-WaggleSigningCallback $lifecycleSession $lifecycleSlots[2].path
|
|
} 'receipt already exists|terminal' 'failed session callback retry rejection'
|
|
Assert-Equal `
|
|
(Get-Content -Raw -LiteralPath $lifecycleLedgerPath) `
|
|
$failedLedgerBeforeRetry `
|
|
'failed session retry leaves ledger unchanged'
|
|
|
|
$completeDirectory = New-PrivateDirectory (Join-Path $sessionFixtureRoot 'complete-ledger')
|
|
$completeTemp = New-PrivateDirectory (Join-Path $completeDirectory 'temp')
|
|
$completeSlots = [Collections.Generic.List[object]]::new()
|
|
$completePayloads = [Collections.Generic.List[object]]::new()
|
|
for ($index = 0; $index -lt 12; $index++) {
|
|
$completePath = Join-Path $completeDirectory "complete-$($index + 1).exe"
|
|
New-SyntheticPe $completePath ([byte]($index + 40))
|
|
$completeSlots.Add([pscustomobject][ordered]@{
|
|
id = 'complete-{0:d2}' -f ($index + 1)
|
|
order = $index + 1
|
|
kind = 'fixed'
|
|
maxUses = 1
|
|
path = $completePath
|
|
packagedPath = $packagedPaths[$index]
|
|
preSignSha256 = Get-Sha256 $completePath
|
|
})
|
|
$completePayloads.Add([pscustomobject][ordered]@{
|
|
path = $packagedPaths[$index]
|
|
sha256 = Get-Sha256 $completePath
|
|
size = [long](Get-Item -LiteralPath $completePath).Length
|
|
extractedPath = $completePath
|
|
})
|
|
}
|
|
$completeUninstallerPath = Join-Path $completeTemp 'nstABCD.tmp'
|
|
New-SyntheticPe $completeUninstallerPath 80
|
|
$completeEvidencePath = Join-Path $completeDirectory 'signed-evidence\13-nsis-uninstaller.exe'
|
|
New-SyntheticPe $completeEvidencePath 81
|
|
$completeSlots.Add([pscustomobject][ordered]@{
|
|
id = 'complete-13'; order = 13; kind = 'generated-nsis-uninstaller'; maxUses = 1
|
|
pathPattern = $NsisUninstallerPattern; evidencePath = $completeEvidencePath
|
|
})
|
|
$completeInstallerPath = Join-Path $completeDirectory 'Waggle_0.2.0_x64-setup.exe'
|
|
New-SyntheticPe $completeInstallerPath 82
|
|
$completeSlots.Add([pscustomobject][ordered]@{
|
|
id = 'complete-14'; order = 14; kind = 'generated-nsis-installer'; maxUses = 1
|
|
path = $completeInstallerPath
|
|
})
|
|
$completeLedgerPath = Join-Path $completeDirectory 'callback-ledger.json'
|
|
$completeSession = [pscustomobject]@{
|
|
Id = 'fedcba9876543210fedcba9876543210'
|
|
ManifestSha256 = 'D' * 64
|
|
Manifest = [pscustomobject]@{
|
|
slots = @($completeSlots)
|
|
payloads = @($completePayloads | ForEach-Object {
|
|
[pscustomobject]@{ path = $_.path; sha256 = $_.sha256; size = $_.size }
|
|
})
|
|
}
|
|
TempRoot = $completeTemp
|
|
SessionDirectory = $completeDirectory
|
|
LedgerPath = $completeLedgerPath
|
|
ManifestPath = Join-Path $completeDirectory 'manifest.json'
|
|
Context = [pscustomobject]@{
|
|
SourceRevision = 'b' * 40
|
|
SignToolPath = $toolPaths['signtool.exe']
|
|
SevenZipPath = $toolPaths['7z.exe']
|
|
}
|
|
}
|
|
$completeLedger = New-PendingLedger `
|
|
$completeSession.Id $completeSession.ManifestSha256 @($completeSlots)
|
|
$reservedAt = [DateTimeOffset]::UtcNow.AddMinutes(-2).ToString('O')
|
|
$completedAt = [DateTimeOffset]::UtcNow.AddMinutes(-1).ToString('O')
|
|
foreach ($entry in $completeLedger.entries) {
|
|
$slot = $completeSlots[[int]$entry.order - 1]
|
|
$entryPath = if ([int]$entry.order -eq 13) {
|
|
$completeUninstallerPath
|
|
} else {
|
|
[string]$slot.path
|
|
}
|
|
$entry.status = 'completed'
|
|
$entry.attempts = 1
|
|
$entry.reservationId = [Guid]::NewGuid().ToString('N')
|
|
$entry.path = $entryPath
|
|
$entry.preSignSha256 = Get-Sha256 $entryPath
|
|
$entry.reservedAtUtc = $reservedAt
|
|
$entry.completedAtUtc = $completedAt
|
|
$entry.postSignSha256 = if ([int]$entry.order -eq 13) {
|
|
Get-Sha256 $completeEvidencePath
|
|
} else {
|
|
Get-Sha256 $entryPath
|
|
}
|
|
$entry.signerSubject = 'CN=Fixture signer'
|
|
}
|
|
Write-JsonNoBom $completeLedgerPath $completeLedger
|
|
$loadedCompleteLedger = Get-CompletedWaggleSigningLedger $completeSession
|
|
Assert-Equal $loadedCompleteLedger.entries.Count 14 'complete finalizer accepts exact 14-slot ledger'
|
|
$changedPayloads = @($completePayloads | ForEach-Object {
|
|
[pscustomobject]@{
|
|
path = $_.path; sha256 = $_.sha256; size = $_.size; extractedPath = $_.extractedPath
|
|
}
|
|
})
|
|
$changedPayloads[0].sha256 = '0' * 64
|
|
Assert-Throws {
|
|
Assert-WaggleFinalPayloadBindings `
|
|
$completeSession $loadedCompleteLedger $changedPayloads { param($Path, $SignTool) }
|
|
} 'payload bytes differ' 'same-name changed packaged payload rejection'
|
|
$completeLedger.entries[13].status = 'pending'
|
|
$completeLedger.entries[13].attempts = 0
|
|
foreach ($field in @(
|
|
'reservationId', 'path', 'preSignSha256', 'reservedAtUtc',
|
|
'completedAtUtc', 'postSignSha256', 'signerSubject'
|
|
)) {
|
|
$completeLedger.entries[13].$field = $null
|
|
}
|
|
Write-JsonNoBom $completeLedgerPath $completeLedger
|
|
Assert-Throws {
|
|
Get-CompletedWaggleSigningLedger $completeSession
|
|
} 'incomplete' 'finalizer rejects incomplete callback ledger'
|
|
Write-JsonNoBom $completeLedgerPath $loadedCompleteLedger
|
|
$finalResult = Assert-WaggleSigningPackageComplete `
|
|
$completeSession @($completePayloads) { param($Path, $SignTool) }
|
|
Assert-Equal `
|
|
(Test-Path -LiteralPath $finalResult.ReceiptPath -PathType Leaf) `
|
|
$true `
|
|
'finalizer publishes regular provenance receipt'
|
|
$sealedLedger = Get-Content -Raw -LiteralPath $completeLedgerPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
Assert-Equal $sealedLedger.state 'sealed' 'finalizer seals only after receipt publication'
|
|
Assert-Equal `
|
|
$sealedLedger.terminalReceiptSha256 `
|
|
(Get-Sha256 $finalResult.ReceiptPath) `
|
|
'sealed ledger binds exact provenance receipt digest'
|
|
|
|
$receiptFailureDirectory = New-PrivateDirectory `
|
|
(Join-Path $sessionFixtureRoot 'receipt-ledger-recovery')
|
|
$receiptFailureLedgerPath = Join-Path $receiptFailureDirectory 'callback-ledger.json'
|
|
$receiptFailureSession = [pscustomobject]@{
|
|
Id = '11111111111111111111111111111111'
|
|
ManifestSha256 = 'F' * 64
|
|
Manifest = [pscustomobject]@{ slots = @($completeSlots) }
|
|
TempRoot = $completeTemp
|
|
SessionDirectory = $receiptFailureDirectory
|
|
LedgerPath = $receiptFailureLedgerPath
|
|
ManifestPath = Join-Path $receiptFailureDirectory 'manifest.json'
|
|
Context = [pscustomobject]@{ SourceRevision = 'b' * 40 }
|
|
}
|
|
Write-JsonNoBom $receiptFailureLedgerPath `
|
|
(New-PendingLedger $receiptFailureSession.Id $receiptFailureSession.ManifestSha256 @($completeSlots))
|
|
$ledgerBlocker = [IO.FileStream]::new(
|
|
$receiptFailureLedgerPath,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::Read
|
|
)
|
|
try {
|
|
Assert-Throws {
|
|
Fail-WaggleSigningSession `
|
|
$receiptFailureSession $null 'forced-ledger-failure' `
|
|
'forced receipt-before-ledger recovery fixture'
|
|
} 'replace|access|being used|used by another process' `
|
|
'ledger replace failure occurs after durable receipt publication'
|
|
} finally {
|
|
$ledgerBlocker.Dispose()
|
|
}
|
|
$pendingReceiptPath = Join-Path $receiptFailureDirectory 'failure-receipt.json'
|
|
$terminalIntentPath = Join-Path $receiptFailureDirectory 'terminal-intent.json'
|
|
Assert-Equal `
|
|
(Test-Path -LiteralPath $pendingReceiptPath -PathType Leaf) $true `
|
|
'ledger failure leaves a durable pending terminal receipt'
|
|
Assert-Equal `
|
|
(Test-Path -LiteralPath $terminalIntentPath -PathType Leaf) $true `
|
|
'ledger failure leaves a durable terminal intent'
|
|
$receiptFailureLedger = Get-Content -Raw -LiteralPath $receiptFailureLedgerPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
Assert-Equal $receiptFailureLedger.state 'open' 'receipt failure leaves ledger open'
|
|
Assert-Throws {
|
|
Invoke-WaggleLedgerMutation $receiptFailureSession { param($ledger) }
|
|
} 'pending terminal receipt' 'pending terminal intent blocks ordinary ledger mutation'
|
|
$pendingReceiptBytes = [IO.File]::ReadAllBytes($pendingReceiptPath)
|
|
$pendingReceipt = Get-Content -Raw -LiteralPath $pendingReceiptPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$pendingReceiptSha256 = Get-Sha256 $pendingReceiptPath
|
|
[IO.File]::WriteAllText(
|
|
$pendingReceiptPath,
|
|
'{"schemaVersion":1,"status":"failed","tampered":true}',
|
|
[Text.UTF8Encoding]::new($false)
|
|
)
|
|
Assert-Throws {
|
|
Fail-WaggleSigningSession `
|
|
$receiptFailureSession $null 'forced-ledger-failure' `
|
|
'forced receipt-before-ledger recovery fixture'
|
|
} 'intent|digest|receipt' 'tampered pending terminal receipt is rejected'
|
|
[IO.File]::WriteAllBytes($pendingReceiptPath, $pendingReceiptBytes)
|
|
$recoveredReceiptPath = Fail-WaggleSigningSession `
|
|
$receiptFailureSession $null 'forced-ledger-failure' `
|
|
'forced receipt-before-ledger recovery fixture'
|
|
$recoveredLedger = Get-Content -Raw -LiteralPath $receiptFailureLedgerPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
Assert-Equal $recoveredLedger.state 'failed' 'pending terminal receipt recovers ledger state'
|
|
Assert-Equal `
|
|
$recoveredLedger.terminalAtUtc $pendingReceipt.terminalAtUtc `
|
|
'recovery reuses the original durable terminal timestamp'
|
|
Assert-Equal `
|
|
$recoveredLedger.terminalReceiptSha256 $pendingReceiptSha256 `
|
|
'recovery reuses the original durable terminal receipt digest'
|
|
$recoveredBytes = [IO.File]::ReadAllBytes($recoveredReceiptPath)
|
|
[IO.File]::WriteAllText($recoveredReceiptPath, '{}', [Text.UTF8Encoding]::new($false))
|
|
Assert-Throws {
|
|
Fail-WaggleSigningSession `
|
|
$receiptFailureSession $null 'forced-ledger-failure' `
|
|
'forced receipt-before-ledger recovery fixture'
|
|
} 'intent|digest|receipt' 'post-commit terminal receipt tampering is rejected'
|
|
[IO.File]::WriteAllBytes($recoveredReceiptPath, $recoveredBytes)
|
|
|
|
$intentOnlyDirectory = New-PrivateDirectory `
|
|
(Join-Path $sessionFixtureRoot 'intent-only-recovery')
|
|
$intentOnlyLedgerPath = Join-Path $intentOnlyDirectory 'callback-ledger.json'
|
|
$intentOnlySession = [pscustomobject]@{
|
|
Id = '12121212121212121212121212121212'
|
|
ManifestSha256 = '1' * 64
|
|
Manifest = [pscustomobject]@{ slots = @($completeSlots) }
|
|
TempRoot = $completeTemp
|
|
SessionDirectory = $intentOnlyDirectory
|
|
LedgerPath = $intentOnlyLedgerPath
|
|
ManifestPath = Join-Path $intentOnlyDirectory 'manifest.json'
|
|
Context = [pscustomobject]@{ SourceRevision = 'b' * 40 }
|
|
}
|
|
Write-JsonNoBom $intentOnlyLedgerPath `
|
|
(New-PendingLedger $intentOnlySession.Id $intentOnlySession.ManifestSha256 @($completeSlots))
|
|
$intentOnlyTerminalAt = [DateTimeOffset]::UtcNow.AddMinutes(-3).ToString('O')
|
|
$intentOnlyReceipt = [pscustomobject][ordered]@{
|
|
schemaVersion = 1
|
|
status = 'failed'
|
|
sessionId = $intentOnlySession.Id
|
|
sourceRevision = $intentOnlySession.Context.SourceRevision
|
|
manifestPath = $intentOnlySession.ManifestPath
|
|
manifestSha256 = $intentOnlySession.ManifestSha256
|
|
failureCode = 'intent-only-failure'
|
|
failureMessage = 'recover the receipt from the durable terminal intent'
|
|
failedSlotId = $null
|
|
rollbackOutcome = 'not-required'
|
|
terminalAtUtc = $intentOnlyTerminalAt
|
|
}
|
|
$intentOnlyStagedReceiptPath = Join-Path $intentOnlyDirectory 'staged-receipt.tmp'
|
|
Write-WaggleDurableJsonNew $intentOnlyStagedReceiptPath $intentOnlyReceipt
|
|
$intentOnlyReceiptSha256 = Get-Sha256 $intentOnlyStagedReceiptPath
|
|
[IO.File]::Delete($intentOnlyStagedReceiptPath)
|
|
Write-WaggleDurableJsonNew `
|
|
(Join-Path $intentOnlyDirectory 'terminal-intent.json') `
|
|
([pscustomobject][ordered]@{
|
|
schemaVersion = 1
|
|
state = 'failed'
|
|
sessionId = $intentOnlySession.Id
|
|
manifestSha256 = $intentOnlySession.ManifestSha256
|
|
terminalAtUtc = $intentOnlyTerminalAt
|
|
receiptSha256 = $intentOnlyReceiptSha256
|
|
})
|
|
Assert-Throws {
|
|
Fail-WaggleSigningSession `
|
|
$intentOnlySession $null 'intent-only-failure' 'tampered recovery candidate'
|
|
} 'intent|candidate' 'intent-only recovery rejects a changed terminal candidate'
|
|
Assert-Equal `
|
|
(Test-Path -LiteralPath (Join-Path $intentOnlyDirectory 'failure-receipt.json')) `
|
|
$false `
|
|
'intent-only candidate mismatch does not publish a receipt'
|
|
$intentOnlyOpenLedger = Get-Content -Raw -LiteralPath $intentOnlyLedgerPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
Assert-Equal $intentOnlyOpenLedger.state 'open' 'intent-only candidate mismatch leaves ledger open'
|
|
$intentOnlyRecoveredReceiptPath = Fail-WaggleSigningSession `
|
|
$intentOnlySession $null 'intent-only-failure' `
|
|
'recover the receipt from the durable terminal intent'
|
|
$intentOnlyRecoveredLedger = Get-Content -Raw -LiteralPath $intentOnlyLedgerPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
Assert-Equal $intentOnlyRecoveredLedger.state 'failed' 'intent-only retry terminalizes the ledger'
|
|
Assert-Equal `
|
|
$intentOnlyRecoveredLedger.terminalAtUtc $intentOnlyTerminalAt `
|
|
'intent-only retry reuses the durable terminal timestamp'
|
|
Assert-Equal `
|
|
(Get-Sha256 $intentOnlyRecoveredReceiptPath) $intentOnlyReceiptSha256 `
|
|
'intent-only retry publishes the exact intent-bound receipt bytes'
|
|
|
|
$concurrencyRoot = New-PrivateDirectory `
|
|
(Join-Path $sessionFixtureRoot 'concurrent-reservation')
|
|
$concurrencyLedgerPath = Join-Path $concurrencyRoot 'callback-ledger.json'
|
|
$concurrencySessionPath = Join-Path $concurrencyRoot 'session.json'
|
|
$concurrencySession = [pscustomobject]@{
|
|
Id = '22222222222222222222222222222222'
|
|
ManifestSha256 = '2' * 64
|
|
Manifest = [pscustomobject]@{ slots = @($lifecycleSlots) }
|
|
TempRoot = $lifecycleTemp
|
|
SessionDirectory = $concurrencyRoot
|
|
LedgerPath = $concurrencyLedgerPath
|
|
ManifestPath = Join-Path $concurrencyRoot 'manifest.json'
|
|
Context = [pscustomobject]@{ SourceRevision = 'a' * 40 }
|
|
}
|
|
Write-JsonNoBom $concurrencyLedgerPath `
|
|
(New-PendingLedger `
|
|
$concurrencySession.Id $concurrencySession.ManifestSha256 @($lifecycleSlots))
|
|
Write-JsonNoBom $concurrencySessionPath $concurrencySession
|
|
$reservationChildPath = Join-Path $concurrencyRoot 'reserve-child.ps1'
|
|
$reservationChildSource = @'
|
|
param(
|
|
[string]$WrapperPath, [string]$SessionPath, [string]$CallbackArtifactPath,
|
|
[string]$ReadyPath, [string]$GoPath, [string]$ResultPath
|
|
)
|
|
$ErrorActionPreference = 'Stop'
|
|
. $WrapperPath -ArtifactPath 'C:\unused.exe'
|
|
$session = Get-Content -Raw -LiteralPath $SessionPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
[IO.File]::WriteAllText($ReadyPath, 'ready')
|
|
while (-not [IO.File]::Exists($GoPath)) { Start-Sleep -Milliseconds 10 }
|
|
try {
|
|
$reservation = Reserve-WaggleSigningCallback $session $CallbackArtifactPath
|
|
[IO.File]::WriteAllText($ResultPath, "reserved:$($reservation.reservationId)")
|
|
} catch {
|
|
[IO.File]::WriteAllText($ResultPath, "error:$($_.Exception.Message)")
|
|
}
|
|
'@
|
|
[IO.File]::WriteAllText(
|
|
$reservationChildPath, $reservationChildSource, [Text.UTF8Encoding]::new($false)
|
|
)
|
|
$reservationGoPath = Join-Path $concurrencyRoot 'go'
|
|
$reservationReadyPaths = @(
|
|
(Join-Path $concurrencyRoot 'ready-1'),
|
|
(Join-Path $concurrencyRoot 'ready-2')
|
|
)
|
|
$reservationResultPaths = @(
|
|
(Join-Path $concurrencyRoot 'result-1'),
|
|
(Join-Path $concurrencyRoot 'result-2')
|
|
)
|
|
$reservationProcesses = @()
|
|
for ($index = 0; $index -lt 2; $index++) {
|
|
$reservationProcesses += Start-SynchronizedChild `
|
|
-PowerShellPath $approvedPowerShell -ScriptPath $reservationChildPath `
|
|
-Arguments @{
|
|
WrapperPath = $wrapperPath
|
|
SessionPath = $concurrencySessionPath
|
|
CallbackArtifactPath = [string]$lifecycleSlots[0].path
|
|
ReadyPath = $reservationReadyPaths[$index]
|
|
GoPath = $reservationGoPath
|
|
ResultPath = $reservationResultPaths[$index]
|
|
}
|
|
}
|
|
Wait-ForChildBarriers $reservationReadyPaths 'reservation race'
|
|
[IO.File]::WriteAllText($reservationGoPath, 'go')
|
|
foreach ($process in $reservationProcesses) {
|
|
if (-not $process.WaitForExit(30000)) {
|
|
$process.Kill()
|
|
throw 'Timed out waiting for the duplicate reservation race.'
|
|
}
|
|
Assert-Equal $process.ExitCode 0 'reservation race child exit code'
|
|
}
|
|
$reservationResults = @($reservationResultPaths | ForEach-Object {
|
|
[IO.File]::ReadAllText($_)
|
|
})
|
|
Assert-Equal `
|
|
@($reservationResults | Where-Object { $_ -cmatch '^reserved:[0-9a-f]{32}$' }).Count `
|
|
1 `
|
|
"concurrent duplicate reservation has exactly one winner [$($reservationResults -join ' | ')]"
|
|
Assert-Equal `
|
|
@($reservationResults | Where-Object {
|
|
$_ -cmatch '^error:.*(already consumed|state must be|reservation)'
|
|
}).Count `
|
|
1 `
|
|
'concurrent duplicate reservation has exactly one rejected loser'
|
|
$concurrencyLedger = Get-Content -Raw -LiteralPath $concurrencyLedgerPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
Assert-WaggleSigningLedgerState $concurrencySession $concurrencyLedger
|
|
Assert-Equal $concurrencyLedger.entries[0].status 'reserved' 'reservation race persists one reservation'
|
|
Assert-Equal $concurrencyLedger.entries[0].attempts 1 'reservation race consumes one attempt'
|
|
|
|
$terminalRoot = New-PrivateDirectory `
|
|
(Join-Path $sessionFixtureRoot 'concurrent-terminal')
|
|
$terminalLedgerPath = Join-Path $terminalRoot 'callback-ledger.json'
|
|
$terminalSessionPath = Join-Path $terminalRoot 'session.json'
|
|
$terminalSession = [pscustomobject]@{
|
|
Id = '33333333333333333333333333333333'
|
|
ManifestSha256 = '3' * 64
|
|
Manifest = [pscustomobject]@{ slots = @($completeSlots) }
|
|
TempRoot = $completeTemp
|
|
SessionDirectory = $terminalRoot
|
|
LedgerPath = $terminalLedgerPath
|
|
ManifestPath = Join-Path $terminalRoot 'manifest.json'
|
|
Context = [pscustomobject]@{ SourceRevision = 'b' * 40 }
|
|
}
|
|
$terminalLedger = $loadedCompleteLedger | ConvertTo-Json -Depth 32 |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
$terminalLedger.sessionId = $terminalSession.Id
|
|
$terminalLedger.manifestSha256 = $terminalSession.ManifestSha256
|
|
$terminalLedger.state = 'open'
|
|
$terminalLedger.terminalAtUtc = $null
|
|
$terminalLedger.terminalReceiptSha256 = $null
|
|
Write-JsonNoBom $terminalLedgerPath $terminalLedger
|
|
Write-JsonNoBom $terminalSessionPath $terminalSession
|
|
$terminalChildPath = Join-Path $terminalRoot 'terminal-child.ps1'
|
|
$terminalChildSource = @'
|
|
param(
|
|
[string]$WrapperPath, [string]$SessionPath, [string]$State,
|
|
[string]$ReadyPath, [string]$GoPath, [string]$ResultPath
|
|
)
|
|
$ErrorActionPreference = 'Stop'
|
|
. $WrapperPath -ArtifactPath 'C:\unused.exe'
|
|
$session = Get-Content -Raw -LiteralPath $SessionPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
[IO.File]::WriteAllText($ReadyPath, 'ready')
|
|
while (-not [IO.File]::Exists($GoPath)) { Start-Sleep -Milliseconds 10 }
|
|
$receipt = [pscustomobject][ordered]@{
|
|
schemaVersion = 1
|
|
status = $State
|
|
sessionId = $session.Id
|
|
sourceRevision = $session.Context.SourceRevision
|
|
manifestSha256 = $session.ManifestSha256
|
|
terminalAtUtc = [DateTimeOffset]::UtcNow.ToString('O')
|
|
failureCode = if ($State -ceq 'failed') { 'race-fixture' } else { $null }
|
|
failureMessage = if ($State -ceq 'failed') { 'race fixture failure' } else { $null }
|
|
failedSlotId = $null
|
|
rollbackOutcome = if ($State -ceq 'failed') { 'not-required' } else { $null }
|
|
manifestPath = if ($State -ceq 'sealed') { $session.ManifestPath } else { $null }
|
|
callbackLedgerPath = if ($State -ceq 'sealed') { $session.LedgerPath } else { $null }
|
|
installerPath = if ($State -ceq 'sealed') { [string]$session.Manifest.slots[13].path } else { $null }
|
|
installerSha256 = if ($State -ceq 'sealed') { 'A' * 64 } else { $null }
|
|
signerSubject = if ($State -ceq 'sealed') { 'CN=Race Fixture' } else { $null }
|
|
payloadManifestSha256 = if ($State -ceq 'sealed') { 'B' * 64 } else { $null }
|
|
artifactBindings = if ($State -ceq 'sealed') { @(
|
|
[pscustomobject]@{ kind = 'fixture'; sha256 = 'C' * 64 },
|
|
[pscustomobject]@{ kind = 'fixture'; sha256 = 'D' * 64 }
|
|
) } else { $null }
|
|
}
|
|
try {
|
|
$path = Set-WaggleSigningTerminalState `
|
|
-Session $session -State $State -Receipt $receipt `
|
|
-LedgerMutation { param($ledger) }
|
|
[IO.File]::WriteAllText($ResultPath, "terminal:${State}:$path")
|
|
} catch {
|
|
[IO.File]::WriteAllText($ResultPath, "error:$($_.Exception.Message)")
|
|
}
|
|
'@
|
|
[IO.File]::WriteAllText(
|
|
$terminalChildPath, $terminalChildSource, [Text.UTF8Encoding]::new($false)
|
|
)
|
|
$terminalGoPath = Join-Path $terminalRoot 'go'
|
|
$terminalReadyPaths = @(
|
|
(Join-Path $terminalRoot 'ready-failed'),
|
|
(Join-Path $terminalRoot 'ready-sealed')
|
|
)
|
|
$terminalResultPaths = @(
|
|
(Join-Path $terminalRoot 'result-failed'),
|
|
(Join-Path $terminalRoot 'result-sealed')
|
|
)
|
|
$terminalStates = @('failed', 'sealed')
|
|
$terminalProcesses = @()
|
|
for ($index = 0; $index -lt 2; $index++) {
|
|
$terminalProcesses += Start-SynchronizedChild `
|
|
-PowerShellPath $approvedPowerShell -ScriptPath $terminalChildPath `
|
|
-Arguments @{
|
|
WrapperPath = $wrapperPath
|
|
SessionPath = $terminalSessionPath
|
|
State = $terminalStates[$index]
|
|
ReadyPath = $terminalReadyPaths[$index]
|
|
GoPath = $terminalGoPath
|
|
ResultPath = $terminalResultPaths[$index]
|
|
}
|
|
}
|
|
Wait-ForChildBarriers $terminalReadyPaths 'terminal fail-vs-seal race'
|
|
[IO.File]::WriteAllText($terminalGoPath, 'go')
|
|
foreach ($process in $terminalProcesses) {
|
|
if (-not $process.WaitForExit(30000)) {
|
|
$process.Kill()
|
|
throw 'Timed out waiting for the terminal fail-vs-seal race.'
|
|
}
|
|
Assert-Equal $process.ExitCode 0 'terminal race child exit code'
|
|
}
|
|
$terminalResults = @($terminalResultPaths | ForEach-Object {
|
|
[IO.File]::ReadAllText($_)
|
|
})
|
|
Assert-Equal `
|
|
@($terminalResults | Where-Object { $_ -cmatch '^terminal:(failed|sealed):' }).Count `
|
|
1 `
|
|
'terminal fail-vs-seal race has exactly one winner'
|
|
Assert-Equal `
|
|
@($terminalResults | Where-Object {
|
|
$_ -cmatch '^error:.*(terminal|opposite|different)'
|
|
}).Count `
|
|
1 `
|
|
'terminal fail-vs-seal race has exactly one rejected loser'
|
|
$terminalReceiptPaths = @(
|
|
(Join-Path $terminalRoot 'failure-receipt.json'),
|
|
(Join-Path $terminalRoot 'provenance-receipt.json')
|
|
)
|
|
$publishedTerminalReceipts = @($terminalReceiptPaths | Where-Object {
|
|
Test-Path -LiteralPath $_ -PathType Leaf
|
|
})
|
|
Assert-Equal $publishedTerminalReceipts.Count 1 'terminal race publishes one receipt'
|
|
$terminalLedgerAfterRace = Get-Content -Raw -LiteralPath $terminalLedgerPath |
|
|
ConvertFrom-Json -Depth 32 -DateKind String
|
|
Assert-WaggleSigningLedgerState $terminalSession $terminalLedgerAfterRace
|
|
$expectedTerminalState = if (
|
|
[IO.Path]::GetFileName($publishedTerminalReceipts[0]) -ceq 'failure-receipt.json'
|
|
) { 'failed' } else { 'sealed' }
|
|
Assert-Equal `
|
|
$terminalLedgerAfterRace.state $expectedTerminalState `
|
|
'terminal race ledger matches its sole receipt'
|
|
Assert-Equal `
|
|
$terminalLedgerAfterRace.terminalReceiptSha256 `
|
|
(Get-Sha256 $publishedTerminalReceipts[0]) `
|
|
'terminal race ledger binds its sole receipt digest'
|
|
} finally {
|
|
foreach ($name in $environmentNames) {
|
|
[Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name])
|
|
}
|
|
if (Test-Path -LiteralPath $sessionFixtureRoot) {
|
|
[IO.Directory]::Delete($sessionFixtureRoot, $true)
|
|
}
|
|
}
|
|
|
|
Write-Host "Artifact Signing policy tests passed: $passed"
|