diff --git a/Tools/windows/PACKAGING.md b/Tools/windows/PACKAGING.md index 87a261c9..dd85b4e4 100644 --- a/Tools/windows/PACKAGING.md +++ b/Tools/windows/PACKAGING.md @@ -39,6 +39,45 @@ No supported publication route accepts this metadata. `release.ps1` requires `release-candidate` / `release-tag` provenance and refuses a local package before any GitHub upload. +## Candidate source custody + +A Git bundle contains Git objects but not Git LFS media. Do not use a bare +`GraphCode-source.bundle` as an offline handoff: cloning it can make Git LFS +treat the bundle file as a standalone-file remote, and disabling smudging does +not produce the required materialized clean checkout. + +Create and verify the source custody ZIP separately from the product package: + +```powershell +pwsh -NoProfile -File Tools\windows\source-custody.ps1 -Command Create ` + -Repository . -Candidate -Tag ` + -Artifact .\GraphCode-source-custody.zip +pwsh -NoProfile -File Tools\windows\source-custody.ps1 -Command Verify ` + -Artifact .\GraphCode-source-custody.zip +``` + +The deterministic ZIP contains the Git bundle, the exact LFS objects referenced +by the candidate tree, an integrity manifest, and +`Restore-GraphCodeSource.ps1`. Restore first checks out LFS pointers with +smudging disabled, copies the verified media into local LFS storage, and runs +`git lfs checkout`, which does not download. It then requires the detached +`HEAD`, peeled annotated tag, materialized LFS set, and empty +`git status --short` to match the manifest. + +After the handoff hash is verified, restore without network access: + +```powershell +Expand-Archive -LiteralPath .\GraphCode-source-custody.zip ` + -DestinationPath .\GraphCode-source-custody +powershell.exe -NoProfile ` + -File .\GraphCode-source-custody\Restore-GraphCodeSource.ps1 ` + -Command Restore -ArtifactRoot .\GraphCode-source-custody ` + -Destination C:\GraphCode-Evidence\source +``` + +This artifact is source/evidence custody only. Regenerating it does not rebuild +or alter the candidate product ZIP, commit, or tag. + The ZIP contains one top-level `GraphCode` directory. Installation verifies the complete manifest and provider provenance before copying anything, then stages and swaps atomically. The scheduled task is created and run; the exact installed diff --git a/Tools/windows/Tests/SourceCustody.Tests.ps1 b/Tools/windows/Tests/SourceCustody.Tests.ps1 new file mode 100644 index 00000000..5166ac7b --- /dev/null +++ b/Tools/windows/Tests/SourceCustody.Tests.ps1 @@ -0,0 +1,150 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$script = Join-Path $repoRoot "Tools\windows\source-custody.ps1" +if (-not (Test-Path -LiteralPath $script -PathType Leaf)) { + throw "RED: source custody tool is missing: $script" +} + +$tokens = $null +$errors = $null +[void] [Management.Automation.Language.Parser]::ParseFile($script, [ref] $tokens, [ref] $errors) +if ($errors.Count) { throw "source custody tool has parse errors: $errors" } + +$fixture = Join-Path ([IO.Path]::GetTempPath()) "graphcode-source-custody-$([guid]::NewGuid())" +$source = Join-Path $fixture "source" +$artifact = Join-Path $fixture "GraphCode-source-custody.zip" +$artifactCopy = Join-Path $fixture "GraphCode-source-custody-copy.zip" +$expanded = Join-Path $fixture "expanded" +$restored = Join-Path $fixture "restored" +$tag = "0.0.0-custody-test" +$oldTerminalPrompt = $env:GIT_TERMINAL_PROMPT +$oldHttpProxy = $env:HTTP_PROXY +$oldHttpsProxy = $env:HTTPS_PROXY + +function Invoke-Git([string] $root, [string[]] $arguments) { + $output = & git -C $root @arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "git -C '$root' $($arguments -join ' ') failed: $($output -join [Environment]::NewLine)" + } + return @($output) +} + +function Invoke-Custody( + [string] $entryPoint, + [string[]] $arguments, + [string] $powerShell = "pwsh" +) { + $output = & $powerShell -NoProfile -File $entryPoint @arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "source custody command failed: $($output -join [Environment]::NewLine)" + } + return @($output) +} + +try { + New-Item -ItemType Directory -Path (Join-Path $source "assets") -Force | Out-Null + Invoke-Git $fixture @("init", "--initial-branch", "main", $source) | Out-Null + Invoke-Git $source @("config", "user.name", "Source Custody Test") | Out-Null + Invoke-Git $source @("config", "user.email", "source-custody@example.invalid") | Out-Null + Invoke-Git $source @("lfs", "install", "--local") | Out-Null + [IO.File]::WriteAllText( + (Join-Path $source ".gitattributes"), + "assets/*.bin filter=lfs diff=lfs merge=lfs -text`n", + [Text.UTF8Encoding]::new($false) + ) + [IO.File]::WriteAllText( + (Join-Path $source ".lfsconfig"), + "[lfs]`nurl = http://127.0.0.1:1/network-must-not-be-used`n", + [Text.UTF8Encoding]::new($false) + ) + $payload = [byte[]]::new(8192) + for ($index = 0; $index -lt $payload.Length; $index++) { + $payload[$index] = ($index * 31 + 17) % 256 + } + [IO.File]::WriteAllBytes((Join-Path $source "assets\fixture.bin"), $payload) + Invoke-Git $source @("add", ".gitattributes", ".lfsconfig", "assets/fixture.bin") | Out-Null + Invoke-Git $source @("commit", "-m", "Add representative LFS fixture") | Out-Null + Invoke-Git $source @("tag", "-a", $tag, "-m", "Source custody fixture") | Out-Null + $commit = [string] (Invoke-Git $source @("rev-parse", "HEAD") | Select-Object -Last 1) + + Invoke-Custody $script @( + "-Command", "Create", + "-Repository", $source, + "-Candidate", $commit, + "-Tag", $tag, + "-Artifact", $artifact + ) | Out-Null + if (-not (Test-Path -LiteralPath $artifact -PathType Leaf)) { + throw "source custody creation produced no artifact" + } + Write-Output "Source custody creation: PASS" + + Invoke-Custody $script @("-Command", "Verify", "-Artifact", $artifact) | Out-Null + Expand-Archive -LiteralPath $artifact -DestinationPath $expanded + $manifest = Get-Content -LiteralPath (Join-Path $expanded "custody-manifest.json") -Raw | + ConvertFrom-Json + if ([string] $manifest.candidateCommit -ne $commit -or + [string] $manifest.tag -ne $tag -or + [int] $manifest.lfs.objectCount -ne 1) { + throw "source custody manifest lost exact candidate/tag/LFS identity" + } + Write-Output "Source custody verification: PASS" + + Invoke-Custody $script @( + "-Command", "Create", + "-Repository", $source, + "-Candidate", $commit, + "-Tag", $tag, + "-Artifact", $artifactCopy + ) | Out-Null + $hash = (Get-FileHash -LiteralPath $artifact -Algorithm SHA256).Hash + $copyHash = (Get-FileHash -LiteralPath $artifactCopy -Algorithm SHA256).Hash + if ($hash -cne $copyHash) { + throw "identical source custody inputs produced different artifacts: $hash != $copyHash" + } + Write-Output "Source custody determinism: PASS" + + Remove-Item -LiteralPath $source -Recurse -Force + $env:GIT_TERMINAL_PROMPT = "0" + $env:HTTP_PROXY = "http://127.0.0.1:1" + $env:HTTPS_PROXY = "http://127.0.0.1:1" + $restoreScript = Join-Path $expanded "Restore-GraphCodeSource.ps1" + Invoke-Custody $restoreScript @( + "-Command", "Restore", + "-ArtifactRoot", $expanded, + "-Destination", $restored + ) "powershell.exe" | Out-Null + + $restoredHead = [string] (Invoke-Git $restored @("rev-parse", "HEAD") | Select-Object -Last 1) + $restoredTag = [string] ( + Invoke-Git $restored @("rev-parse", "$tag^{commit}") | Select-Object -Last 1 + ) + $status = @(Invoke-Git $restored @("status", "--short")) + if ($restoredHead -ne $commit -or $restoredTag -ne $commit) { + throw "offline restore lost exact revision/tag: HEAD=$restoredHead tag=$restoredTag expected=$commit" + } + if ($status.Count -ne 0) { + throw "offline restore is dirty: $($status -join [Environment]::NewLine)" + } + $restoredPayload = [IO.File]::ReadAllBytes((Join-Path $restored "assets\fixture.bin")) + if (-not [Linq.Enumerable]::SequenceEqual[byte]($payload, $restoredPayload)) { + throw "offline restore did not materialize the exact LFS payload" + } + $lfsFiles = (Invoke-Git $restored @("lfs", "ls-files", "--json", $commit) | Out-String) | + ConvertFrom-Json + if (@($lfsFiles.files).Count -ne 1 -or + -not [bool] $lfsFiles.files[0].checkout -or + -not [bool] $lfsFiles.files[0].downloaded) { + throw "offline restore did not materialize its LFS object from local custody" + } + Write-Output "Offline exact clean restore: PASS" + Write-Output "Source custody regression cases: PASS (4/4)" +} finally { + $env:GIT_TERMINAL_PROMPT = $oldTerminalPrompt + $env:HTTP_PROXY = $oldHttpProxy + $env:HTTPS_PROXY = $oldHttpsProxy + Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/Tools/windows/source-custody.ps1 b/Tools/windows/source-custody.ps1 new file mode 100644 index 00000000..981fdbd0 --- /dev/null +++ b/Tools/windows/source-custody.ps1 @@ -0,0 +1,479 @@ +<# +.SYNOPSIS + Creates, verifies, or restores a self-contained source custody artifact. + +.DESCRIPTION + A Git bundle contains Git objects but not Git LFS media. This tool packages + the exact candidate tag with every LFS object needed by that candidate tree. + Restore checks out pointers without smudging, seeds local LFS storage, and + materializes from that storage without fetching. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet("Create", "Verify", "Restore")] + [string] $Command, + [string] $Repository, + [string] $Candidate, + [string] $Tag, + [string] $Artifact, + [string] $ArtifactRoot, + [string] $Destination, + [string] $GitCli = "git" +) + +$ErrorActionPreference = "Stop" +$script:TemporaryPaths = [Collections.Generic.List[string]]::new() + +function Get-FullPath([string] $path, [string] $base = (Get-Location).Path) { + if ([IO.Path]::IsPathRooted($path)) { + return [IO.Path]::GetFullPath($path) + } + return [IO.Path]::GetFullPath((Join-Path $base $path)) +} + +function Invoke-Git([string] $root, [string[]] $arguments) { + $invocation = @() + if ($root) { $invocation += @("-C", $root) } + $invocation += $arguments + $oldErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $output = & $GitCli @invocation 2>&1 + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } + if ($exitCode -ne 0) { + throw "git $($invocation -join ' ') failed: $(@($output) -join [Environment]::NewLine)" + } + return @($output | ForEach-Object { [string] $_ }) +} + +function Resolve-Commit([string] $root, [string] $revision, [string] $description) { + $commit = [string] ( + Invoke-Git $root @("rev-parse", "--verify", "$revision^{commit}") | + Select-Object -Last 1 + ) + $commit = $commit.Trim().ToLowerInvariant() + if ($commit -notmatch "^[0-9a-f]{40}$") { + throw "$description '$revision' resolved to invalid commit '$commit'" + } + return $commit +} + +function Assert-TagName([string] $value) { + if (-not $value -or $value -notmatch "^[0-9A-Za-z][0-9A-Za-z._-]*$") { + throw "custody tag is missing or unsafe: '$value'" + } +} + +function Get-Sha256([string] $path) { + return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Write-Utf8File([string] $path, [string] $content) { + [IO.File]::WriteAllText($path, $content, [Text.UTF8Encoding]::new($false)) +} + +function New-TemporaryDirectory([string] $label) { + $path = Join-Path ([IO.Path]::GetTempPath()) "$label-$([guid]::NewGuid())" + New-Item -ItemType Directory -Path $path | Out-Null + $script:TemporaryPaths.Add($path) + return $path +} + +function New-DeterministicZip([string] $sourceRoot, [string] $destination) { + Add-Type -AssemblyName System.IO.Compression + $stream = [IO.File]::Open( + $destination, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None + ) + try { + $archive = [IO.Compression.ZipArchive]::new( + $stream, + [IO.Compression.ZipArchiveMode]::Create, + $false, + [Text.Encoding]::UTF8 + ) + try { + $files = @( + Get-ChildItem -LiteralPath $sourceRoot -Recurse -File | + Sort-Object { $_.FullName.Substring($sourceRoot.Length).Replace("\", "/") } + ) + foreach ($file in $files) { + $relative = $file.FullName.Substring($sourceRoot.Length).TrimStart("\", "/") + $entryName = $relative.Replace("\", "/") + $entry = $archive.CreateEntry($entryName, [IO.Compression.CompressionLevel]::Optimal) + $entry.LastWriteTime = [DateTimeOffset]::new( + 1980, 1, 1, 0, 0, 0, [TimeSpan]::Zero + ) + $entry.ExternalAttributes = 0 + $input = [IO.File]::OpenRead($file.FullName) + try { + $output = $entry.Open() + try { $input.CopyTo($output) } finally { $output.Dispose() } + } finally { + $input.Dispose() + } + } + } finally { + $archive.Dispose() + } + } finally { + $stream.Dispose() + } +} + +function Get-LfsFiles([string] $root, [string] $commit) { + $json = (Invoke-Git $root @("lfs", "ls-files", "--json", "--long", "--size", $commit) | + Out-String) + $document = $json | ConvertFrom-Json + $files = @($document.files) + foreach ($file in $files) { + $oid = ([string] $file.oid).ToLowerInvariant() + if ([string] $file.oid_type -ne "sha256" -or $oid -notmatch "^[0-9a-f]{64}$") { + throw "candidate LFS entry '$($file.name)' has invalid object identity" + } + if ([long] $file.size -lt 0) { + throw "candidate LFS entry '$($file.name)' has invalid size" + } + } + return $files +} + +function Get-LfsObjectPath([string] $objectsRoot, [string] $oid) { + return Join-Path $objectsRoot (Join-Path $oid.Substring(0, 2) ( + Join-Path $oid.Substring(2, 2) $oid + )) +} + +function New-CustodyArtifact { + if (-not $Repository -or -not $Candidate -or -not $Tag -or -not $Artifact) { + throw "Create requires -Repository, -Candidate, -Tag, and -Artifact" + } + Assert-TagName $Tag + $repositoryRoot = (Resolve-Path -LiteralPath $Repository).Path + $artifactPath = Get-FullPath $Artifact + if ([IO.Path]::GetExtension($artifactPath) -ine ".zip") { + throw "source custody artifact must be a .zip file" + } + if (Test-Path -LiteralPath $artifactPath) { + throw "source custody artifact already exists: $artifactPath" + } + $artifactParent = Split-Path -Parent $artifactPath + New-Item -ItemType Directory -Path $artifactParent -Force | Out-Null + + $candidateCommit = Resolve-Commit $repositoryRoot $Candidate "candidate" + $tagReference = "refs/tags/$Tag" + $tagType = [string] ( + Invoke-Git $repositoryRoot @("cat-file", "-t", $tagReference) | + Select-Object -Last 1 + ) + if ($tagType.Trim() -ne "tag") { + throw "custody tag '$Tag' must be an annotated tag" + } + $tagCommit = Resolve-Commit $repositoryRoot $tagReference "custody tag" + if ($tagCommit -ne $candidateCommit) { + throw "custody tag '$Tag' peels to $tagCommit, expected candidate $candidateCommit" + } + + $stage = New-TemporaryDirectory "graphcode-source-custody-create" + $bundle = Join-Path $stage "GraphCode-source.bundle" + Invoke-Git $repositoryRoot @("bundle", "create", $bundle, $tagReference) | Out-Null + Invoke-Git $repositoryRoot @("bundle", "verify", $bundle) | Out-Null + + $commonDirectory = [string] ( + Invoke-Git $repositoryRoot @("rev-parse", "--git-common-dir") | + Select-Object -Last 1 + ) + if (-not [IO.Path]::IsPathRooted($commonDirectory)) { + $commonDirectory = Get-FullPath $commonDirectory $repositoryRoot + } + $sourceObjects = Join-Path $commonDirectory "lfs\objects" + $lfsFiles = @(Get-LfsFiles $repositoryRoot $candidateCommit) + $objects = [Collections.Generic.List[object]]::new() + foreach ($group in @($lfsFiles | Group-Object { ([string] $_.oid).ToLowerInvariant() } | + Sort-Object Name)) { + $oid = [string] $group.Name + $declaredSizes = @($group.Group | ForEach-Object { [long] $_.size } | Select-Object -Unique) + if ($declaredSizes.Count -ne 1) { + throw "candidate LFS object $oid has inconsistent declared sizes" + } + $sourceObject = Get-LfsObjectPath $sourceObjects $oid + if (-not (Test-Path -LiteralPath $sourceObject -PathType Leaf)) { + throw "candidate LFS object is unavailable locally: $oid" + } + $actualSize = (Get-Item -LiteralPath $sourceObject).Length + if ($actualSize -ne $declaredSizes[0]) { + throw "candidate LFS object $oid has size $actualSize, expected $($declaredSizes[0])" + } + $actualHash = Get-Sha256 $sourceObject + if ($actualHash -ne $oid) { + throw "candidate LFS object $oid has SHA-256 $actualHash" + } + $relative = "lfs/objects/$($oid.Substring(0, 2))/$($oid.Substring(2, 2))/$oid" + $destinationObject = Join-Path $stage $relative.Replace("/", "\") + New-Item -ItemType Directory -Path (Split-Path -Parent $destinationObject) -Force | + Out-Null + Copy-Item -LiteralPath $sourceObject -Destination $destinationObject + $objects.Add([ordered]@{ + oid = $oid + size = $actualSize + path = $relative + sha256 = $actualHash + }) + } + + $restoreName = "Restore-GraphCodeSource.ps1" + $restoreScript = Join-Path $stage $restoreName + Copy-Item -LiteralPath $PSCommandPath -Destination $restoreScript + $manifest = [ordered]@{ + schemaVersion = 1 + candidateCommit = $candidateCommit + tag = $Tag + tagCommit = $tagCommit + bundle = [ordered]@{ + path = "GraphCode-source.bundle" + sha256 = Get-Sha256 $bundle + } + restoreScript = [ordered]@{ + path = $restoreName + sha256 = Get-Sha256 $restoreScript + } + lfs = [ordered]@{ + trackedFileCount = $lfsFiles.Count + objectCount = $objects.Count + objects = @($objects) + } + } + $manifestJson = $manifest | ConvertTo-Json -Depth 8 + Write-Utf8File (Join-Path $stage "custody-manifest.json") ($manifestJson + "`n") + New-DeterministicZip $stage $artifactPath + Write-Output "Source custody creation: PASS" + Write-Output "Candidate: $candidateCommit" + Write-Output "Tag: $Tag" + Write-Output "LFS objects: $($objects.Count) for $($lfsFiles.Count) tracked files" + Write-Output "Artifact: $artifactPath" + Write-Output "SHA-256: $(Get-Sha256 $artifactPath)" +} + +function Open-CustodyArtifact { + if ($Artifact -and $ArtifactRoot) { + throw "use either -Artifact or -ArtifactRoot, not both" + } + if ($ArtifactRoot) { + return (Resolve-Path -LiteralPath $ArtifactRoot).Path + } + if (-not $Artifact) { + throw "$Command requires -Artifact or -ArtifactRoot" + } + $artifactPath = (Resolve-Path -LiteralPath $Artifact).Path + $root = New-TemporaryDirectory "graphcode-source-custody-open" + Expand-Archive -LiteralPath $artifactPath -DestinationPath $root + return $root +} + +function Test-CustodyRoot([string] $root) { + $manifestPath = Join-Path $root "custody-manifest.json" + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw "source custody manifest is missing" + } + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + if ([int] $manifest.schemaVersion -ne 1) { + throw "unsupported source custody schema version '$($manifest.schemaVersion)'" + } + $candidateCommit = ([string] $manifest.candidateCommit).ToLowerInvariant() + if ($candidateCommit -notmatch "^[0-9a-f]{40}$") { + throw "source custody candidate commit is invalid" + } + Assert-TagName ([string] $manifest.tag) + if (([string] $manifest.tagCommit).ToLowerInvariant() -ne $candidateCommit) { + throw "source custody tag commit does not equal its candidate" + } + + $expectedFiles = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + [void] $expectedFiles.Add("custody-manifest.json") + foreach ($record in @($manifest.bundle, $manifest.restoreScript)) { + $relative = [string] $record.path + if ($relative -notin @("GraphCode-source.bundle", "Restore-GraphCodeSource.ps1")) { + throw "source custody contains an unexpected control path '$relative'" + } + $path = Join-Path $root $relative + if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or + (Get-Sha256 $path) -ne ([string] $record.sha256).ToLowerInvariant()) { + throw "source custody control file failed integrity: $relative" + } + [void] $expectedFiles.Add($relative) + } + + $objects = @($manifest.lfs.objects) + if ([int] $manifest.lfs.objectCount -ne $objects.Count) { + throw "source custody LFS object count is inconsistent" + } + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($object in $objects) { + $oid = ([string] $object.oid).ToLowerInvariant() + $expectedRelative = if ($oid -match "^[0-9a-f]{64}$") { + "lfs/objects/$($oid.Substring(0, 2))/$($oid.Substring(2, 2))/$oid" + } else { + throw "source custody LFS object identity is invalid" + } + if (-not $seen.Add($oid) -or [string] $object.path -cne $expectedRelative -or + ([string] $object.sha256).ToLowerInvariant() -ne $oid) { + throw "source custody LFS object manifest is invalid for $oid" + } + $path = Join-Path $root $expectedRelative.Replace("/", "\") + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "source custody LFS object is missing: $oid" + } + $file = Get-Item -LiteralPath $path + if ($file.Length -ne [long] $object.size -or (Get-Sha256 $path) -ne $oid) { + throw "source custody LFS object failed integrity: $oid" + } + [void] $expectedFiles.Add($expectedRelative) + } + $actualFiles = @( + Get-ChildItem -LiteralPath $root -Recurse -File | + ForEach-Object { + $_.FullName.Substring($root.Length).TrimStart("\", "/").Replace("\", "/") + } + ) + foreach ($relative in $actualFiles) { + if (-not $expectedFiles.Contains($relative)) { + throw "source custody contains unmanifested file '$relative'" + } + } + if ($actualFiles.Count -ne $expectedFiles.Count) { + throw "source custody is missing one or more manifested files" + } + + $bundle = Join-Path $root ([string] $manifest.bundle.path) + $verifyRepository = New-TemporaryDirectory "graphcode-source-custody-verify" + Invoke-Git $null @("init", "--initial-branch", "custody-verify", $verifyRepository) | + Out-Null + Invoke-Git $verifyRepository @("bundle", "verify", $bundle) | Out-Null + $tagReference = "refs/tags/$($manifest.tag)" + Invoke-Git $verifyRepository @( + "fetch", "--no-tags", $bundle, "$tagReference`:$tagReference" + ) | Out-Null + $tagType = [string] ( + Invoke-Git $verifyRepository @("cat-file", "-t", $tagReference) | + Select-Object -Last 1 + ) + $tagCommit = Resolve-Commit $verifyRepository $tagReference "custody tag" + if ($tagType.Trim() -ne "tag" -or $tagCommit -ne $candidateCommit) { + throw "source custody bundle does not contain the exact annotated candidate tag" + } + + return [pscustomobject]@{ + Root = $root + Manifest = $manifest + Bundle = $bundle + } +} + +function Test-CustodyArtifact { + $root = Open-CustodyArtifact + $custody = Test-CustodyRoot $root + Write-Output "Source custody verification: PASS" + Write-Output "Candidate: $($custody.Manifest.candidateCommit)" + Write-Output "Tag: $($custody.Manifest.tag)" + Write-Output "LFS objects: $($custody.Manifest.lfs.objectCount)" +} + +function Restore-CustodyArtifact { + if (-not $ArtifactRoot -or $Artifact) { + throw "Restore requires extracted -ArtifactRoot and does not accept -Artifact" + } + if (-not $Destination) { + throw "Restore requires -Destination" + } + $root = Open-CustodyArtifact + $custody = Test-CustodyRoot $root + $destinationPath = Get-FullPath $Destination + if (Test-Path -LiteralPath $destinationPath) { + throw "restore destination already exists: $destinationPath" + } + New-Item -ItemType Directory -Path (Split-Path -Parent $destinationPath) -Force | + Out-Null + + $oldSkipSmudge = $env:GIT_LFS_SKIP_SMUDGE + try { + $env:GIT_LFS_SKIP_SMUDGE = "1" + Invoke-Git $null @("init", "--initial-branch", "custody", $destinationPath) | Out-Null + Invoke-Git $destinationPath @("lfs", "install", "--local") | Out-Null + $tagReference = "refs/tags/$($custody.Manifest.tag)" + Invoke-Git $destinationPath @( + "fetch", "--no-tags", $custody.Bundle, "$tagReference`:$tagReference" + ) | Out-Null + $destinationObjects = Join-Path $destinationPath ".git\lfs\objects" + foreach ($object in @($custody.Manifest.lfs.objects)) { + $oid = [string] $object.oid + $relative = ([string] $object.path).Substring("lfs/objects/".Length) + $sourceObject = Join-Path $root ([string] $object.path).Replace("/", "\") + $destinationObject = Join-Path $destinationObjects $relative.Replace("/", "\") + New-Item -ItemType Directory -Path (Split-Path -Parent $destinationObject) -Force | + Out-Null + Copy-Item -LiteralPath $sourceObject -Destination $destinationObject + } + Invoke-Git $destinationPath @( + "checkout", "--detach", [string] $custody.Manifest.candidateCommit + ) | Out-Null + } catch { + if (Test-Path -LiteralPath $destinationPath) { + Remove-Item -LiteralPath $destinationPath -Recurse -Force -ErrorAction SilentlyContinue + } + throw + } finally { + $env:GIT_LFS_SKIP_SMUDGE = $oldSkipSmudge + } + + try { + Invoke-Git $destinationPath @("lfs", "checkout") | Out-Null + $candidateCommit = ([string] $custody.Manifest.candidateCommit).ToLowerInvariant() + $head = Resolve-Commit $destinationPath "HEAD" "restored HEAD" + $tagCommit = Resolve-Commit $destinationPath "refs/tags/$($custody.Manifest.tag)" "restored tag" + if ($head -ne $candidateCommit -or $tagCommit -ne $candidateCommit) { + throw "restored source identity mismatch: HEAD=$head tag=$tagCommit expected=$candidateCommit" + } + $lfsFiles = @(Get-LfsFiles $destinationPath $candidateCommit) + if ($lfsFiles.Count -ne [int] $custody.Manifest.lfs.trackedFileCount) { + throw "restored LFS file count does not match custody manifest" + } + foreach ($file in $lfsFiles) { + if (-not [bool] $file.checkout -or -not [bool] $file.downloaded) { + throw "restored LFS file was not materialized from custody: $($file.name)" + } + } + $status = @(Invoke-Git $destinationPath @("status", "--short")) + if ($status.Count -ne 0) { + throw "restored source checkout is dirty: $($status -join [Environment]::NewLine)" + } + } catch { + Remove-Item -LiteralPath $destinationPath -Recurse -Force -ErrorAction SilentlyContinue + throw + } + + Write-Output "Source custody restore: PASS" + Write-Output "Candidate: $candidateCommit" + Write-Output "Tag: $($custody.Manifest.tag)" + Write-Output "Clean status: PASS" + Write-Output "Destination: $destinationPath" +} + +try { + switch ($Command) { + "Create" { New-CustodyArtifact } + "Verify" { Test-CustodyArtifact } + "Restore" { Restore-CustodyArtifact } + } +} finally { + foreach ($path in $script:TemporaryPaths) { + Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/Tools/windows/validate.ps1 b/Tools/windows/validate.ps1 index a5d6e8d4..9da9cbcc 100644 --- a/Tools/windows/validate.ps1 +++ b/Tools/windows/validate.ps1 @@ -1244,6 +1244,10 @@ function Invoke-Task([string] $name) { } "packaging" { if ($PackagingPart -ne "real") { + & (Join-Path $repoRoot "Tools\windows\Tests\SourceCustody.Tests.ps1") + if ($LASTEXITCODE -ne 0) { + throw "Windows source custody tests failed with exit code $LASTEXITCODE" + } & (Join-Path $repoRoot "Tools\windows\Tests\Release.Tests.ps1") & (Join-Path $repoRoot "Tools\windows\Tests\PreviewCore.Tests.ps1") & (Join-Path $repoRoot "Tools\windows\Tests\Packaging.Signing.Tests.ps1") diff --git a/investigation/windows-preview-devbox-qualification-plan.md b/investigation/windows-preview-devbox-qualification-plan.md index cd6eac2d..3e40041a 100644 --- a/investigation/windows-preview-devbox-qualification-plan.md +++ b/investigation/windows-preview-devbox-qualification-plan.md @@ -47,8 +47,8 @@ GraphCode-DevBox-Handoff\ graphcode-windows-x86_64.zip.sha256 candidate-manifest.json source\ - GraphCode-source.bundle - GraphCode-source.bundle.sha256 + GraphCode-source-custody.zip + GraphCode-source-custody.zip.sha256 plans\ windows-preview-devbox-qualification-plan.md approvals\ @@ -87,7 +87,7 @@ Stop immediately if a required file is absent. ``` 2. Verify `hashes.sha256`. -3. Verify ZIP and source-bundle SHA-256 against the candidate manifest. +3. Verify product and source-custody ZIP SHA-256 against the candidate manifest. 4. Verify every Approval A field is filled. 5. Verify the plan file hash matches the manifest. 6. Record Dev Box: @@ -150,16 +150,26 @@ corporate resource names, unrelated processes, or unrelated environment data. Git may be installed on the Dev Box. ```powershell -git clone C:\GraphCode-Handoff\source\GraphCode-source.bundle C:\GraphCode-Evidence\source +Expand-Archive ` + -LiteralPath C:\GraphCode-Handoff\source\GraphCode-source-custody.zip ` + -DestinationPath C:\GraphCode-Evidence\source-custody +powershell.exe -NoProfile ` + -File C:\GraphCode-Evidence\source-custody\Restore-GraphCodeSource.ps1 ` + -Command Verify -ArtifactRoot C:\GraphCode-Evidence\source-custody +powershell.exe -NoProfile ` + -File C:\GraphCode-Evidence\source-custody\Restore-GraphCodeSource.ps1 ` + -Command Restore -ArtifactRoot C:\GraphCode-Evidence\source-custody ` + -Destination C:\GraphCode-Evidence\source Set-Location C:\GraphCode-Evidence\source -git checkout --detach git rev-parse HEAD git rev-parse "^{commit}" git status --short ``` Require HEAD and the peeled tag to equal the candidate manifest SHA. Require a -clean checkout. +clean checkout and the restore command's positive materialized-LFS count. Do not +set `GIT_LFS_SKIP_SMUDGE` as a workaround and do not accept pointer-only or +dirty source. The source checkout is used only for evidence scripts and inspection. The installed product must execute from the package installation. @@ -459,4 +469,3 @@ The Dev Box agent does not publish. It returns evidence to the local plan. - [ ] uninstall/reinstall lifecycle passed - [ ] cleanup passed - [ ] return bundle hashed and transferred - diff --git a/investigation/windows-preview-local-qualification-plan.md b/investigation/windows-preview-local-qualification-plan.md index 5e191e29..5a25d33a 100644 --- a/investigation/windows-preview-local-qualification-plan.md +++ b/investigation/windows-preview-local-qualification-plan.md @@ -173,8 +173,8 @@ GraphCode-DevBox-Handoff\ graphcode-windows-x86_64.zip.sha256 candidate-manifest.json source\ - GraphCode-source.bundle - GraphCode-source.bundle.sha256 + GraphCode-source-custody.zip + GraphCode-source-custody.zip.sha256 plans\ windows-preview-devbox-qualification-plan.md approvals\ @@ -193,21 +193,31 @@ GraphCode-DevBox-Handoff\ - package unsigned state; - provider/toolchain identities; - Dev Box plan SHA-256; +- source custody ZIP SHA-256, embedded candidate/tag identity, and LFS + object/file counts; - Approval A values; - creation UTC and operator. -### Source bundle +### Source custody -Create a Git bundle containing the candidate commit and local tag: +Create a deterministic custody ZIP containing the candidate's annotated tag, +Git bundle, exact candidate-tree LFS objects, integrity manifest, and standalone +restore script: ```powershell -git bundle create GraphCode-source.bundle "refs/tags/" -git bundle verify GraphCode-source.bundle +pwsh -NoProfile -File Tools\windows\source-custody.ps1 -Command Create ` + -Repository . -Candidate -Tag ` + -Artifact GraphCode-source-custody.zip +pwsh -NoProfile -File Tools\windows\source-custody.ps1 -Command Verify ` + -Artifact GraphCode-source-custody.zip ``` -The source bundle is for evidence scripts and source inspection on the Dev Box. -The installed product must still run from the package installation, not the -checkout. +Do not substitute a bare Git bundle. Git bundles do not contain LFS media, and a +bundle file is not a valid Git LFS standalone-file remote. The custody verifier +requires the bundle, annotated tag, candidate SHA, restore script, and every +manifested LFS object to agree. The source custody ZIP is for evidence scripts +and source inspection on the Dev Box. The installed product must still run from +the package installation, not the checkout. ### Handoff integrity @@ -332,4 +342,3 @@ Only after Approval B: - [ ] tester packet complete - [ ] Approval B received - [ ] exact release published and verified - diff --git a/investigation/windows-preview-release-plan.md b/investigation/windows-preview-release-plan.md index f08eddda..318d9010 100644 --- a/investigation/windows-preview-release-plan.md +++ b/investigation/windows-preview-release-plan.md @@ -82,8 +82,9 @@ pass cannot settle that uncertainty. ### 2026-10-02 local qualification execution record -The local plan was executed through creation and independent reverification of -the replacement Dev Box handoff: +The local plan was executed through creation and independent hash reverification +of the replacement Dev Box handoff. Dev Box source restoration later failed, so +the handoff is not accepted as restorable custody: - **Phase L0 repository audit — Passed.** The replacement source is the exact #604 merge `a406a28cb9aec856c934e7253d1a79c2cc6706ed`, with parents @@ -167,7 +168,8 @@ the replacement Dev Box handoff: packaged binary's `--worktrees-preview-state` output is exactly `deferred`. Source SHA, peeled tag SHA and package version agree; signing is `UNSIGNED (not code signed)`; publication is false. -- **Phase L3 handoff — Passed.** The self-contained +- **Phase L3 handoff — Failed operational custody qualification.** The + self-contained `GraphCode-DevBox-Handoff-0.1.78-beta2` contains the candidate, Approval A, Dev Box plan and a verified source bundle containing the annotated beta2 tag. The source-bundle SHA-256 is @@ -180,16 +182,25 @@ the replacement Dev Box handoff: `78eed36dd0450d979caf223511a37f0cf30546e30b343e47f7fed3ca1b55da2a`; and the handoff `hashes.sha256` SHA-256 is `87cf8ab5720d114621d4aa113694e66b3ee5ca3c2192826223cc0168ef29f862`. - All **8** payload hashes verified. The coordinator independently reverified - the versioned beta2 handoff. + All **8** payload hashes verified, and the coordinator independently + reverified those hashes. That evidence proves byte integrity only, not an + offline clean restore. On the Dev Box, cloning `GraphCode-source.bundle` + caused Git LFS to invoke the standalone-file transfer adapter against the + bundle file path and fail. Retrying with LFS smudging disabled resolved HEAD + and `0.1.78-beta2^{commit}` to the approved SHA but left **43** tracked PNG + files modified. The clean-checkout gate therefore failed and execution + stopped; neither attempt is passing evidence. The beta2 commit, local tag, + product ZIP, and original handoff remain immutable. - **Phases L4-L6 — NotExecuted.** No Dev Box installation, native UI/backend turn, destructive fixture, lifecycle flight, returned evidence bundle, tester packet, Approval B, pushed tag or release asset exists. Dev Box provisioning/profile identities, install/native/UIA/backend/destructive/ lifecycle flight, other layouts/IMEs, screen-reader claims, upgrade/rollback, dump, transfer, Approval B and publication are explicitly **NotExecuted**. - The exact-artifact gate is complete for beta2; the other six alpha gates - remain open. + Product-package identity remains verified, but source custody and the other + six alpha gates remain open. Because the product commit, tag, and ZIP do not + change, policy requires a newly generated and rehashed handoff/source-custody + ZIP, not a fresh candidate build. ## Delivery lanes @@ -463,7 +474,7 @@ input, clipboard, display and destructive tests need an owned Windows desktop lease or equivalent authorized hosted evidence. No desktop available means a proof gap, not PASS; hosted server evidence must not be relabelled client proof. -- [x] **Exact artifact:** candidate source +- [ ] **Exact artifact:** candidate source `a406a28cb9aec856c934e7253d1a79c2cc6706ed`, version/tag `0.1.78-beta2`, peeled tag SHA, package SHA-256, payload manifest and provider provenance are recorded in @@ -472,7 +483,10 @@ proof gap, not PASS; hosted server evidence must not be relabelled client proof. explicitly declares `UNSIGNED (not code signed)`, records `previewFeatures.worktreesDeferred=true`, reports preview state `deferred`, and contains production daemon/CLI/runtime inputs. The coordinator - independently reverified the handoff and all eight payload hashes. + independently reverified the handoff and all eight payload hashes. Those + product/package identity checks passed, but the gate remains open because the + source custody artifact could not restore its 43 LFS-tracked files into a + clean checkout. - [ ] **Clean installation and recovery:** install the extracted candidate on the declared client profile without Git/Swift/Zig/SDK developer dependencies. Observe the installed scheduled daemon endpoint and normal app launch. @@ -518,9 +532,10 @@ proof gap, not PASS; hosted server evidence must not be relabelled client proof. steps, recovery locations and a bug-report route. Never ask testers to bypass security policy. Invite only after the core gates have actual evidence. -The **exact-artifact gate passed**. The other **six** gates remain open and -require evidence that source, hosted CI, and hidden-window tests cannot -manufacture: +All **seven** gates remain open. The candidate product/package identity +subchecks passed, but exact-artifact custody requires a corrected, rehashed +handoff that restores cleanly. The other gates require evidence that source, +hosted CI, and hidden-window tests cannot manufacture: | Required external capability | Exact permission/evidence needed | |---|---| @@ -645,8 +660,8 @@ permission-bound; it should not start with another parity-row sweep: evidence exists. Approval A authorizes no full dump and keeps #560 open. A future dump-backed diagnosis requires separate authorization and a new candidate if product code changes. -3. **Keep the release gates honest:** the exact-artifact gate passed; the other - **six** gates remain open. The +3. **Keep the release gates honest:** all **seven** gates remain open. Candidate + product/package identity passed, but exact-artifact custody did not. The installed production-core result, native input and destructive fixture permission, named authenticated backend authorization, exact artifact provenance, and publication permission are independent. A green