From 820c7c4d2d78eb05f746080f83c6a962d414ae12 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:16:41 +0700 Subject: [PATCH 1/3] ci(p3): rebase sealed package pipeline onto reproducible P3A base --- .github/workflows/build.yml | 180 ++++++- .../workflows/ci-p0-workflow-integrity.yml | 9 + .github/workflows/installer-windows.yml | 171 +++++-- .../audits/CI_P3_CANONICAL_WINDOWS_PACKAGE.md | 130 +++++ evidence/ci-workflow-budget.json | 66 ++- scripts/build-windows-installer.ps1 | 51 +- scripts/test-ci-package-reuse.py | 331 +++++++++++++ scripts/verify-ci-package-reuse.py | 462 ++++++++++++++++++ scripts/write-ci-package-manifest.py | 282 +++++++++++ 9 files changed, 1628 insertions(+), 54 deletions(-) create mode 100644 docs/audits/CI_P3_CANONICAL_WINDOWS_PACKAGE.md create mode 100644 scripts/test-ci-package-reuse.py create mode 100644 scripts/verify-ci-package-reuse.py create mode 100644 scripts/write-ci-package-manifest.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b3336e71e..e35cf016c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,7 +30,7 @@ jobs: "ARSAS_SOURCE_COMMIT=$actual" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append Write-Host "Testing and packaging exact ARSAS source $actual" - - name: Resolve immutable ARIEC61850 integration lock + - name: Resolve immutable engine and native bridge locks shell: powershell run: | $lockPath = ".\ArIED61850Tester\engines\ARIEC61850.lock.json" @@ -43,10 +43,28 @@ jobs: $lock.commit -notmatch '^[0-9a-f]{40}$') { throw "ARIEC61850 lock metadata is invalid." } + + $ardirecPath = ".\ArIED61850Tester\engines\ARDIREC.lock.json" + if (!(Test-Path $ardirecPath)) { + throw "ArdIrec lock file was not found." + } + $ardirec = Get-Content $ardirecPath -Raw | ConvertFrom-Json + if ($ardirec.schema -ne 3 -or + $ardirec.repository -notmatch '^[^/]+/[^/]+$' -or + $ardirec.ref -notmatch '^[A-Za-z0-9._/-]+$' -or + $ardirec.commit -notmatch '^[0-9a-f]{40}$' -or + $ardirec.bridge.abi -ne 1 -or + $ardirec.bridge.mode -ne 'native-only' -or + $ardirec.bridge.relativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll') { + throw "ArdIrec native bridge lock metadata is invalid." + } + "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARIEC61850_REF=$($lock.ref)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARIEC61850_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - + "ARDIREC_REPOSITORY=$($ardirec.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "ARDIREC_REF=$($ardirec.ref)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "ARDIREC_COMMIT=$($ardirec.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Verify source, website and license boundaries shell: powershell run: .\ArIED61850Tester\scripts\verify-source-clean.ps1 @@ -295,6 +313,20 @@ jobs: throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." } + - name: Checkout immutable ArdIrec native bridge revision + shell: powershell + run: | + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARDIREC_REPOSITORY.git" ArdIrec + if ($LASTEXITCODE -ne 0) { throw "Could not clone pinned ArdIrec repository." } + git -C .\ArdIrec fetch --quiet --depth 1 origin $env:ARDIREC_COMMIT + if ($LASTEXITCODE -ne 0) { throw "Could not fetch pinned ArdIrec revision." } + git -C .\ArdIrec checkout --quiet --detach $env:ARDIREC_COMMIT + if ($LASTEXITCODE -ne 0) { throw "Could not checkout pinned ArdIrec revision." } + $actual = (git -C .\ArdIrec rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -ne $env:ARDIREC_COMMIT) { + throw "ArdIrec pin mismatch. Expected $env:ARDIREC_COMMIT, got $actual." + } + - name: Verify required ARIEC61850 APIs shell: powershell run: | @@ -401,7 +433,79 @@ jobs: ArIED61850Tester\TestResults\ci-canonical-authority.json if-no-files-found: error - - name: Publish real portable single EXE x64 + - name: Build and test exact pinned ArdIrec native bridge once + shell: powershell + run: | + $bridgeDir = Join-Path $env:RUNNER_TEMP 'arsas-canonical-ardirec-bridge' + $bridgeBuild = Join-Path $env:RUNNER_TEMP 'arsas-canonical-ardirec-build' + $bridge = [string]( + .\ArIED61850Tester\scripts\build-ardirec-bridge.ps1 ` + -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` + -OutputDirectory $bridgeDir ` + -BuildDirectory $bridgeBuild | + Select-Object -Last 1 + ) + $bridge = $bridge.Trim() + if (-not (Test-Path $bridge -PathType Leaf)) { + throw "Canonical ArdIrec bridge build did not produce a DLL: $bridge" + } + "ARDIREC_BRIDGE_PATH=$bridge" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARDIREC_BRIDGE_SHA256=$((Get-FileHash $bridge -Algorithm SHA256).Hash.ToLowerInvariant())" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Publish canonical installer-input folder + shell: powershell + run: | + .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` + -Version $env:ARSAS_VERSION ` + -Runtime win-x64 ` + -SingleFile $false ` + -SelfContained $true ` + -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" ` + -ArdIrecBridgePath $env:ARDIREC_BRIDGE_PATH + if ($LASTEXITCODE -ne 0) { throw "Canonical installer-input publish failed." } + + $publish = ".\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64" + $ardirecDir = Join-Path $publish 'Tools\ArdIrec' + New-Item -ItemType Directory -Force $ardirecDir | Out-Null + $license = ".\ArdIrec\LICENSE" + if (Test-Path $license -PathType Leaf) { + Copy-Item $license (Join-Path $ardirecDir 'LICENSE.txt') -Force + } + + $packagedBridge = Join-Path $ardirecDir 'ardirec_bridge.dll' + if (-not (Test-Path $packagedBridge -PathType Leaf)) { + throw "Canonical installer-input folder is missing the native bridge." + } + $packagedHash = (Get-FileHash $packagedBridge -Algorithm SHA256).Hash.ToLowerInvariant() + if ($packagedHash -ne $env:ARDIREC_BRIDGE_SHA256) { + throw "Installer-input bridge bytes differ from the tested canonical bridge." + } + + - name: Exercise exact packaged native bridge through managed tests + shell: powershell + run: | + $env:ARSAS_ARDIREC_BRIDGE_PATH = "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64\Tools\ArdIrec\ardirec_bridge.dll" + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\minimal_1999.cfg" + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" + foreach ($required in @( + $env:ARSAS_ARDIREC_BRIDGE_PATH, + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG, + $env:ARSAS_NATIVE_LOCUS_TEST_CFG + )) { + if (-not (Test-Path $required -PathType Leaf)) { + throw "Canonical native bridge verification prerequisite is missing: $required" + } + } + dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj ` + -c Release --no-build --no-restore ` + --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + if ($LASTEXITCODE -ne 0) { + throw "Canonical packaged ArdIrec bridge failed managed integration tests." + } + + - name: Publish real portable single EXE from the same native bridge shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -410,7 +514,9 @@ jobs: -SingleFile $true ` -SelfContained $true ` -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` - -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" ` + -ArdIrecBridgePath $env:ARDIREC_BRIDGE_PATH + if ($LASTEXITCODE -ne 0) { throw "Canonical portable publish failed." } - name: Smoke-test real portable single EXE shell: powershell @@ -430,6 +536,72 @@ jobs: throw "Portable single EXE smoke test failed with exit code $($process.ExitCode)." } + - name: Stage package verification runtime and fixtures + shell: powershell + run: | + $verification = ".\ArIED61850Tester\TestResults\windows-package-verification" + if (Test-Path $verification) { Remove-Item $verification -Recurse -Force } + $tests = Join-Path $verification 'ARSAS.Tests' + $fixtures = Join-Path $verification 'fixtures' + New-Item -ItemType Directory -Force -Path $tests, $fixtures | Out-Null + + $testOutput = ".\ArIED61850Tester\tests\ARSAS.Tests\bin\Release\net8.0-windows" + if (-not (Test-Path (Join-Path $testOutput 'ARSAS.Tests.dll') -PathType Leaf)) { + throw "Canonical prebuilt ARSAS.Tests runtime is missing." + } + Copy-Item (Join-Path $testOutput '*') $tests -Recurse -Force + + foreach ($stem in @('minimal_1999', 'distance_p1')) { + $matches = @(Get-ChildItem ".\ArdIrec\tests\data\$stem.*" -File -ErrorAction SilentlyContinue) + if (-not ($matches | Where-Object Extension -eq '.cfg')) { + throw "Pinned ArdIrec fixture is missing: $stem.cfg" + } + foreach ($file in $matches) { + Copy-Item $file.FullName (Join-Path $fixtures $file.Name) -Force + } + } + + - name: Seal exact tested Windows package payload + shell: powershell + run: | + $artifactRoot = ".\ArIED61850Tester\TestResults\windows-package-artifact" + python .\ArIED61850Tester\scripts\write-ci-package-manifest.py ` + --artifact-root $artifactRoot ` + --portable ".\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable.exe" ` + --installer-input ".\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64" ` + --verification-dir ".\ArIED61850Tester\TestResults\windows-package-verification" ` + --evidence-dir ".\ArIED61850Tester\TestResults" ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --version $env:ARSAS_VERSION ` + --runtime win-x64 ` + --run-id $env:GITHUB_RUN_ID ` + --attempt $env:GITHUB_RUN_ATTEMPT ` + --event-name $env:GITHUB_EVENT_NAME + if ($LASTEXITCODE -ne 0) { throw "Canonical Windows package sealing failed." } + + $manifest = Get-Content (Join-Path $artifactRoot 'ci-windows-package-authority.json') -Raw | ConvertFrom-Json + if ($manifest.sourceSha -ne $env:GITHUB_SHA -or + $manifest.engineSha -ne $env:ARIEC61850_COMMIT -or + $manifest.ardirecSha -ne $env:ARDIREC_COMMIT -or + -not [bool]$manifest.fullRegressionPassed -or + -not [bool]$manifest.nativeBridgeIntegrationPassed -or + -not [bool]$manifest.portableSmokePassed -or + [bool]$manifest.installerBuilt -or + [bool]$manifest.releasePromotionAuthority -or + $manifest.nativeBridge.sha256 -ne $env:ARDIREC_BRIDGE_SHA256) { + throw "Canonical Windows package manifest does not match the tested build authority." + } + + - name: Upload sealed Windows package input + uses: actions/upload-artifact@v7 + with: + name: ARSAS-windows-package-input + path: ArIED61850Tester\TestResults\windows-package-artifact + if-no-files-found: error + retention-days: 30 + - name: Upload portable single EXE uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/ci-p0-workflow-integrity.yml b/.github/workflows/ci-p0-workflow-integrity.yml index bda091ed5..55b9a7f95 100644 --- a/.github/workflows/ci-p0-workflow-integrity.yml +++ b/.github/workflows/ci-p0-workflow-integrity.yml @@ -12,6 +12,9 @@ on: - "scripts/write-ci-canonical-proof.py" - "scripts/verify-ci-canonical-reuse.py" - "scripts/test-ci-canonical-reuse.py" + - "scripts/write-ci-package-manifest.py" + - "scripts/verify-ci-package-reuse.py" + - "scripts/test-ci-package-reuse.py" - ".github/actions/resolve-ariec-engine/**" - "evidence/ci-workflow-budget.json" - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" @@ -25,6 +28,9 @@ on: - "scripts/write-ci-canonical-proof.py" - "scripts/verify-ci-canonical-reuse.py" - "scripts/test-ci-canonical-reuse.py" + - "scripts/write-ci-package-manifest.py" + - "scripts/verify-ci-package-reuse.py" + - "scripts/test-ci-package-reuse.py" - ".github/actions/resolve-ariec-engine/**" - "evidence/ci-workflow-budget.json" - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" @@ -60,6 +66,9 @@ jobs: - name: Run canonical CI P2 evidence safety fixtures run: python3 scripts/test-ci-canonical-reuse.py + - name: Run canonical CI P3 package safety fixtures + run: python3 scripts/test-ci-package-reuse.py + - name: Validate tracked workflow topology and budget run: | python3 scripts/audit-ci-workflows.py \ diff --git a/.github/workflows/installer-windows.yml b/.github/workflows/installer-windows.yml index 16bac10f4..35395b0ed 100644 --- a/.github/workflows/installer-windows.yml +++ b/.github/workflows/installer-windows.yml @@ -9,6 +9,10 @@ on: - "scripts/create-windows-release-checksums.ps1" - "scripts/publish-windows-portable.ps1" - "scripts/stage-ardirec-viewer.ps1" + - "scripts/write-ci-package-manifest.py" + - "scripts/verify-ci-package-reuse.py" + - "scripts/test-ci-package-reuse.py" + - ".github/workflows/build.yml" - ".github/workflows/installer-windows.yml" - "FaultRecordWindow.ComtradeOpen.cs" - "ComtradeWorkspaceWindow*" @@ -35,6 +39,10 @@ on: - "scripts/create-windows-release-checksums.ps1" - "scripts/publish-windows-portable.ps1" - "scripts/stage-ardirec-viewer.ps1" + - "scripts/write-ci-package-manifest.py" + - "scripts/verify-ci-package-reuse.py" + - "scripts/test-ci-package-reuse.py" + - ".github/workflows/build.yml" - ".github/workflows/installer-windows.yml" - "FaultRecordWindow.ComtradeOpen.cs" - "ComtradeWorkspaceWindow*" @@ -58,6 +66,7 @@ on: permissions: contents: read + actions: read jobs: installer: @@ -66,10 +75,22 @@ jobs: timeout-minutes: 45 steps: - - name: Checkout ARSAS application + - name: Checkout exact ARSAS application revision uses: actions/checkout@v7 with: path: ArIED61850Tester + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Verify exact installer source identity + shell: powershell + run: | + $actual = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() + $expected = $env:GITHUB_SHA.Trim().ToLowerInvariant() + if ($actual -ne $expected) { + throw "Installer source mismatch. Expected $expected, got $actual." + } - name: Resolve application version and immutable engine locks shell: powershell @@ -106,7 +127,97 @@ jobs: "ARDIREC_REPOSITORY=$($ardirecLock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_COMMIT=$($ardirecLock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - name: Checkout immutable ARIEC61850 engine revision + - name: Setup Python for sealed package verification + if: github.event_name != 'workflow_dispatch' + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Run offline package verifier safety fixtures + if: github.event_name != 'workflow_dispatch' + run: python .\ArIED61850Tester\scripts\test-ci-package-reuse.py + + - name: Reuse exact tested canonical Windows package + if: github.event_name != 'workflow_dispatch' + shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if ($env:GITHUB_EVENT_NAME -eq 'pull_request') { + $eventName = 'pull_request' + $headBranch = $env:PR_HEAD_BRANCH + $headSha = $env:PR_HEAD_SHA + } + else { + $eventName = 'push' + $headBranch = 'main' + $headSha = $env:GITHUB_SHA + } + + $packageRoot = Join-Path $env:RUNNER_TEMP 'arsas-canonical-windows-package' + $proofPath = Join-Path $env:RUNNER_TEMP 'arsas-canonical-windows-package-proof.json' + python .\ArIED61850Tester\scripts\verify-ci-package-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --head-branch $headBranch ` + --head-sha $headSha ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --event-name $eventName ` + --wait-seconds 1200 ` + --allow-in-progress-artifact ` + --output-dir $packageRoot ` + --proof-output $proofPath + if ($LASTEXITCODE -ne 0) { + throw "Installer requires the exact sealed Build ARSAS package payload." + } + + $proof = Get-Content $proofPath -Raw | ConvertFrom-Json + if ($proof.sourceSha -ne $env:GITHUB_SHA -or + $proof.engineSha -ne $env:ARIEC61850_COMMIT -or + $proof.ardirecSha -ne $env:ARDIREC_COMMIT -or + $proof.version -ne $env:APP_VERSION -or + [int]$proof.failed -ne 0 -or + [int]$proof.notExecuted -ne 0 -or + [int]$proof.passed -lt 1 -or + [bool]$proof.releasePromotionAuthority) { + throw "Canonical Windows package proof does not match installer source authority." + } + + $installerInput = Join-Path $packageRoot ($proof.installerInputRoot -replace '/', '\') + $testAssembly = Join-Path $packageRoot ($proof.testAssemblyPath -replace '/', '\') + $comtradeFixture = Join-Path $packageRoot ($proof.comtradeFixturePath -replace '/', '\') + $locusFixture = Join-Path $packageRoot ($proof.locusFixturePath -replace '/', '\') + foreach ($required in @($installerInput, $testAssembly, $comtradeFixture, $locusFixture)) { + if (-not (Test-Path $required)) { + throw "Verified package materialization is incomplete: $required" + } + } + + "INSTALLER_INPUT_DIR=$installerInput" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_TEST_ASSEMBLY=$testAssembly" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_COMTRADE_FIXTURE=$comtradeFixture" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_LOCUS_FIXTURE=$locusFixture" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_PACKAGE_RUN=$($proof.canonicalRunId)/$($proof.canonicalRunAttempt)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_PACKAGE_ARTIFACT_SHA256=$($proof.artifactSha256)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + @( + '## CI-P3 installer package reuse', + '', + "- exact source: $env:GITHUB_SHA", + "- engine: $env:ARIEC61850_COMMIT", + "- ArdIrec: $env:ARDIREC_COMMIT", + "- Build ARSAS run: $($proof.canonicalRunId)/$($proof.canonicalRunAttempt)", + "- full regression: $($proof.passed) passed, $($proof.failed) failed", + "- package files: $($proof.fileCount)", + "- proof stage: $($proof.proofStage)", + '- installer remains separate compile/install/runtime-smoke authority' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: Checkout immutable ARIEC61850 engine revision for manual build + if: github.event_name == 'workflow_dispatch' shell: powershell run: | git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 @@ -115,7 +226,8 @@ jobs: $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() if ($actual -ne $env:ARIEC61850_COMMIT) { throw "ARIEC61850 pin mismatch." } - - name: Checkout immutable ArdIrec engine revision + - name: Checkout immutable ArdIrec engine revision for manual build + if: github.event_name == 'workflow_dispatch' shell: powershell run: | git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARDIREC_REPOSITORY.git" ArdIrec @@ -124,7 +236,7 @@ jobs: $actual = (git -C .\ArdIrec rev-parse HEAD).Trim() if ($actual -ne $env:ARDIREC_COMMIT) { throw "ArdIrec pin mismatch. Expected $env:ARDIREC_COMMIT got $actual." } - - name: Setup .NET 8 + - name: Setup .NET 8 for native verification uses: actions/setup-dotnet@v6 with: dotnet-version: 8.0.x @@ -139,7 +251,8 @@ jobs: $after = (Get-FileHash $native -Algorithm SHA256).Hash if ($before -ne $after) { throw 'Installer verification mutated discovery source.' } - - name: Restore build and test application solution + - name: Restore build and test application solution for manual packaging + if: github.event_name == 'workflow_dispatch' shell: powershell run: | dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln @@ -149,7 +262,8 @@ jobs: dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore if ($LASTEXITCODE -ne 0) { throw "Application regression tests failed." } - - name: Publish installer source + - name: Publish installer source for manual packaging + if: github.event_name == 'workflow_dispatch' shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -159,41 +273,43 @@ jobs: -SelfContained $true ` -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" + if ($LASTEXITCODE -ne 0) { throw "Manual installer source publish failed." } - - name: Stage pinned ArdIrec native analysis bridge + - name: Stage pinned ArdIrec native analysis bridge for manual packaging + if: github.event_name == 'workflow_dispatch' shell: powershell run: | .\ArIED61850Tester\scripts\stage-ardirec-viewer.ps1 ` -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` -PublishedDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64" ` -BuildDirectory "$env:RUNNER_TEMP\ardirec-installer-build" + if ($LASTEXITCODE -ne 0) { throw "Manual ArdIrec staging failed." } - - name: Exercise managed analysis bridge before packaging - shell: powershell - run: | - $publish = "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64" - $bridge = Join-Path $publish "Tools\ArdIrec\ardirec_bridge.dll" - if (-not (Test-Path $bridge -PathType Leaf)) { throw "Native bridge was not staged: $bridge" } - $env:ARSAS_ARDIREC_BRIDGE_PATH = $bridge - $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\minimal_1999.cfg" - $env:ARSAS_NATIVE_LOCUS_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" - dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj ` - -c Release --no-build --no-restore ` - --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" - if ($LASTEXITCODE -ne 0) { throw "Managed ArdIrec bridge/locus integration test failed." } + "INSTALLER_INPUT_DIR=$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_TEST_ASSEMBLY=$env:GITHUB_WORKSPACE\ArIED61850Tester\tests\ARSAS.Tests\bin\Release\net8.0-windows\ARSAS.Tests.dll" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_COMTRADE_FIXTURE=$env:GITHUB_WORKSPACE\ArdIrec\tests\data\minimal_1999.cfg" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_LOCUS_FIXTURE=$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "CANONICAL_PACKAGE_RUN=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - name: Install Inno Setup compiler shell: powershell run: choco install innosetup --no-progress --yes - - name: Compile installer + - name: Compile installer from exact tested payload shell: powershell run: | .\ArIED61850Tester\scripts\build-windows-installer.ps1 ` -Version $env:APP_VERSION ` -Runtime win-x64 ` - -PublishedDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64" ` - -OutputDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist" + -PublishedDirectory $env:INSTALLER_INPUT_DIR ` + -OutputDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist" ` + -TestAssemblyPath $env:CANONICAL_TEST_ASSEMBLY ` + -FixtureCfg $env:CANONICAL_COMTRADE_FIXTURE ` + -LocusFixtureCfg $env:CANONICAL_LOCUS_FIXTURE - name: Silent install and field-runtime smoke test shell: powershell @@ -231,11 +347,10 @@ jobs: } $env:ARSAS_ARDIREC_BRIDGE_PATH = Join-Path $installRoot "Tools\ArdIrec\ardirec_bridge.dll" - $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\minimal_1999.cfg" - $env:ARSAS_NATIVE_LOCUS_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" - dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj ` - -c Release --no-build --no-restore ` - --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $env:CANONICAL_COMTRADE_FIXTURE + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $env:CANONICAL_LOCUS_FIXTURE + & dotnet vstest $env:CANONICAL_TEST_ASSEMBLY ` + "--TestCaseFilter:FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" if ($LASTEXITCODE -ne 0) { throw "Installed bridge/locus runtime validation failed." } $uninstaller = Join-Path $installRoot "unins000.exe" diff --git a/docs/audits/CI_P3_CANONICAL_WINDOWS_PACKAGE.md b/docs/audits/CI_P3_CANONICAL_WINDOWS_PACKAGE.md new file mode 100644 index 000000000..c4f09cdae --- /dev/null +++ b/docs/audits/CI_P3_CANONICAL_WINDOWS_PACKAGE.md @@ -0,0 +1,130 @@ +# CI-P3 — Canonical Windows package artifact + +Issue #444. + +## Objective + +Move Windows CI from repeated application rebuilds toward a build-once/package-many +model without weakening regression, native bridge, installer, physical or release +authority. + +The central rule is: + +> Test exact source once, seal exact package bytes, then package/promote those exact +> bytes after independently verifying their identity and hashes. + +Rebuilding the same source is not treated as proof that the same binary was produced. + +## Why exact-byte promotion + +CI-P2F observed that two Windows single-file publishes using the same ARSAS source +and ARIEC61850 integration SHA were not byte-identical. CI-P3 therefore does not +require reproducible builds before eliminating downstream rebuilds. + +Instead, Build ARSAS creates an immutable package artifact and downstream consumers +verify that artifact byte-for-byte before use. + +Reproducible-build work remains useful hardening, but it is not the trust boundary. + +## Canonical Build ARSAS responsibilities + +After the normal full regression suite succeeds, Build ARSAS: + +1. resolves the exact ARIEC61850 and ArdIrec lock SHAs; +2. checks out the exact ArdIrec revision; +3. builds and tests one native-only ArdIrec bridge; +4. publishes the installer-input folder using that exact bridge file; +5. runs managed native bridge and distance-locus integration tests against the + bridge in the installer-input folder; +6. publishes the portable single EXE using the same bridge file; +7. smoke-tests the portable EXE; +8. stages the already-built ARSAS.Tests runtime plus the exact COMTRADE fixtures + needed for package validation; +9. writes a hash-complete package manifest; +10. uploads ARSAS-windows-package-input. + +The old ARSAS-win-x64-portable-single-exe artifact remains available for normal +portable testing and download. + +## Sealed artifact contract + +The package artifact contains: + +- ci-windows-package-authority.json; +- canonical full-regression manifest and TRX; +- portable single EXE; +- complete self-contained installer-input folder; +- canonical prebuilt test runtime; +- pinned native integration fixtures. + +The manifest binds: + +- source SHA; +- ARIEC61850 SHA; +- ArdIrec SHA; +- Build ARSAS run ID, attempt and event; +- version/runtime; +- full-regression counters and TRX SHA-256; +- portable SHA-256; +- native bridge SHA-256; +- every payload file path, size and SHA-256. + +It explicitly states that the installer has not yet been built and that the +artifact has no stable-release promotion authority. + +## Cross-run verifier + +verify-ci-package-reuse.py treats workflow artifacts as untrusted ZIP data. + +Before extraction it verifies: + +- repository/event/head/branch/run identity; +- exact source, engine and ArdIrec SHAs; +- latest matching Build ARSAS run semantics; +- completed failures are never reusable; +- safe normalized ZIP paths, no symlinks or encrypted entries; +- bounded file/archive sizes; +- manifest-to-archive file equality; +- every file SHA-256 and size; +- canonical TRX SHA-256 and all-pass counters; +- portable/native bridge special records; +- verification runtime and fixture references. + +Only after all checks pass may it materialize files for installer use. + +## Installer workflow + +For pull_request and push/main, installer validation no longer restores, builds, +tests or publishes the ARSAS application. + +It consumes the exact sealed package, then independently: + +- verifies the physical-proven Smart Discovery source route without mutation; +- runs the packaged native bridge tests from the canonical prebuilt test runtime; +- compiles the Inno Setup installer from the verified installer-input directory; +- performs silent install/uninstall; +- rejects removed ArdIrec desktop/Qt runtime files; +- runs the same native bridge/locus tests against the installed bridge; +- writes checksums and uploads the installer artifact. + +workflow_dispatch retains the historical local-build fallback so manual engineering +validation is not coupled to the existence of a cross-run package artifact. + +## Authority boundaries + +Build ARSAS owns application regression and sealed package bytes. + +Installer workflow owns installer compilation plus installed-package smoke. + +Smart Discovery physical evidence remains unchanged. + +Stable release publication remains unchanged in this milestone. Existing +publish-verified-release.yml already demonstrates artifact-ID + SHA-256 publication +and is the preferred direction for the later release migration. + +## Rollback + +Restore installer PR/main source restore/build/test/publish/stage steps and stop +consuming ARSAS-windows-package-input. + +Do not relax package identity, TRX, ZIP safety or SHA-256 checks to make reuse pass. diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index 73812cbc1..a0c4fd84c 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P3A: reproducible portable build identity", - "policy": "P0-P2 reduced duplicate regression work. P3A does not add a heavy workflow or change static CI counts; it establishes deterministic Windows packaging inputs and hash-bound build identity so later artifact promotion can reuse only proven identical bits.", + "baselineName": "CI-P3: sealed canonical Windows package reused by installer validation", + "policy": "P0 historical workflow-sprawl baseline remains immutable. CI-P3 establishes build-once/package-many for Windows installer validation: Build ARSAS seals exact tested portable + installer-input bytes with source/engine/ArdIrec/TRX/file digests; PR/main installer runs verify and consume those bytes instead of rebuilding the application. Manual workflow_dispatch retains a local fallback. Stable release mutation remains unchanged in this milestone.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -20,11 +20,11 @@ "workflowFiles": 31, "actionsCheckout": 35, "actionsSetupDotnet": 11, - "actionsSetupPython": 17, + "actionsSetupPython": 18, "dotnetRestore": 11, "dotnetBuild": 11, - "dotnetTest": 13, - "actionsUploadArtifact": 34, + "dotnetTest": 12, + "actionsUploadArtifact": 35, "workflowsWithDotnetBuild": 10, "workflowsWithDotnetTest": 9 }, @@ -292,6 +292,62 @@ "manualReusableRegressionRetained": true, "rollback": "Restore direct Field Capture restore/build/dotnet test steps; do not substitute canonical EXE unless future reproducible-build evidence proves byte identity." }, + "ciP3CanonicalWindowsPackage": { + "issueNumber": 444, + "lane": "canonical-windows-package-and-installer-reuse", + "approach": "Build ARSAS builds/tests once, builds one pinned ArdIrec bridge, uses those exact bridge bytes for installer-input and portable outputs, runs managed native integration tests, portable smoke, seals all package/test bytes into a hash-complete artifact, and installer PR/main consumes the exact package rather than restoring/building/publishing ARSAS again.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 17, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 13, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 18, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 35, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "staticBudgetNote": "Manual installer workflow_dispatch fallback intentionally keeps restore/build/full-test source commands in YAML, so static restore/build counts do not fall even though PR/main execution removes them.", + "artifact": "ARSAS-windows-package-input", + "exactIdentity": [ + "source SHA", + "ARIEC61850 SHA", + "ArdIrec SHA", + "Build ARSAS run ID/attempt/event" + ], + "proof": [ + "canonical all-pass TRX digest/counters", + "portable SHA-256", + "native bridge SHA-256", + "every installer-input and verification payload file SHA-256" + ], + "nativeBridgeSingleBuild": true, + "portableAndInstallerInputShareExactBridgeBytes": true, + "installerApplicationRebuildOnPullRequest": false, + "installerApplicationRebuildOnMainPush": false, + "installerManualFallbackRetained": true, + "installerStillCompilesInno": true, + "installerStillRunsSilentInstallUninstall": true, + "installerStillRunsInstalledNativeBridgeTests": true, + "releasePromotionAuthority": false, + "releaseWorkflowUnchanged": true, + "reproducibleBuildRequiredForReuse": false, + "exactBytePromotion": true, + "rollback": "Restore installer PR/main source restore/build/test/publish/stage steps and stop consuming ARSAS-windows-package-input. Keep sealed artifact producer/verifier available for diagnosis; never weaken hash or identity checks." + }, "ciP3ReproduciblePortableBuild": { "issueNumber": 447, "lane": "canonical-and-field-capture-windows-packaging", diff --git a/scripts/build-windows-installer.ps1 b/scripts/build-windows-installer.ps1 index 24925f7c8..605a7be8e 100644 --- a/scripts/build-windows-installer.ps1 +++ b/scripts/build-windows-installer.ps1 @@ -5,7 +5,10 @@ param( [string]$Runtime = "win-x64", [string]$PublishedDirectory = "", [string]$OutputDirectory = "", - [string]$InnoCompiler = "" + [string]$InnoCompiler = "", + [string]$TestAssemblyPath = "", + [string]$FixtureCfg = "", + [string]$LocusFixtureCfg = "" ) $ErrorActionPreference = "Stop" @@ -97,29 +100,42 @@ foreach ($file in $forbiddenRuntimeFiles) { } # Run the managed -> C ABI -> ardirec_core smoke on the exact bridge that is about to be -# packaged. The fixture belongs to ARSAS, so release validation does not depend on an external -# test-data path after the engine has been staged. -$testProject = Join-Path $root "tests\ARSAS.Tests\ARSAS.Tests.csproj" -$testAssembly = Join-Path $root "tests\ARSAS.Tests\bin\Release\net8.0-windows\ARSAS.Tests.dll" -$fixtureCfg = Join-Path $root "tests\fixtures\comtrade\p1-release-smoke.cfg" +# packaged. CI-P3 can supply a prebuilt canonical test runtime so installer validation does +# not need to compile the application again. The defaults preserve the historical local/release path. +if ([string]::IsNullOrWhiteSpace($TestAssemblyPath)) { + $TestAssemblyPath = Join-Path $root "tests\ARSAS.Tests\bin\Release\net8.0-windows\ARSAS.Tests.dll" +} +if ([string]::IsNullOrWhiteSpace($FixtureCfg)) { + $FixtureCfg = Join-Path $root "tests\fixtures\comtrade\p1-release-smoke.cfg" +} + +$TestAssemblyPath = [System.IO.Path]::GetFullPath($TestAssemblyPath) +$FixtureCfg = [System.IO.Path]::GetFullPath($FixtureCfg) +if (-not [string]::IsNullOrWhiteSpace($LocusFixtureCfg)) { + $LocusFixtureCfg = [System.IO.Path]::GetFullPath($LocusFixtureCfg) +} + $bridgePath = Join-Path $PublishedDirectory "Tools\ArdIrec\ardirec_bridge.dll" -if (-not (Test-Path $testProject -PathType Leaf) -or - -not (Test-Path $testAssembly -PathType Leaf) -or - -not (Test-Path $fixtureCfg -PathType Leaf)) { - throw "Native managed bridge smoke prerequisites are missing. Build the Release test project before packaging the installer." +if (-not (Test-Path $TestAssemblyPath -PathType Leaf) -or + -not (Test-Path $FixtureCfg -PathType Leaf) -or + -not (Test-Path $bridgePath -PathType Leaf)) { + throw "Native managed bridge smoke prerequisites are missing. Supply the tested assembly, fixture and packaged bridge." +} +if (-not [string]::IsNullOrWhiteSpace($LocusFixtureCfg) -and + -not (Test-Path $LocusFixtureCfg -PathType Leaf)) { + throw "Native locus fixture was not found: $LocusFixtureCfg" } $previousBridgePath = $env:ARSAS_ARDIREC_BRIDGE_PATH $previousFixtureCfg = $env:ARSAS_NATIVE_COMTRADE_TEST_CFG +$previousLocusCfg = $env:ARSAS_NATIVE_LOCUS_TEST_CFG try { $env:ARSAS_ARDIREC_BRIDGE_PATH = $bridgePath - $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $fixtureCfg - Write-Host "==> Validating native COMTRADE bridge and Locus session through ARSAS managed interop" - & dotnet test $testProject ` - -c Release ` - --no-build ` - --no-restore ` - --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $FixtureCfg + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $LocusFixtureCfg + Write-Host "==> Validating exact packaged native COMTRADE bridge through canonical prebuilt tests" + & dotnet vstest $TestAssemblyPath ` + "--TestCaseFilter:FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" if ($LASTEXITCODE -ne 0) { throw "Staged native COMTRADE bridge failed the managed bridge/Locus integration smoke test." } @@ -127,6 +143,7 @@ try { finally { $env:ARSAS_ARDIREC_BRIDGE_PATH = $previousBridgePath $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $previousFixtureCfg + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $previousLocusCfg } if ([string]::IsNullOrWhiteSpace($InnoCompiler)) { diff --git a/scripts/test-ci-package-reuse.py b/scripts/test-ci-package-reuse.py new file mode 100644 index 000000000..727023aaa --- /dev/null +++ b/scripts/test-ci-package-reuse.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Offline fail-closed tests for CI-P3 canonical Windows package sealing/reuse.""" +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def load(name: str, filename: str): + spec = importlib.util.spec_from_file_location(name, ROOT / filename) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +producer = load("arsas_package_producer", "write-ci-package-manifest.py") +verifier = load("arsas_package_verifier", "verify-ci-package-reuse.py") + +SOURCE = "a" * 40 +ENGINE = "b" * 40 +ARDIREC = "c" * 40 +HEAD = "d" * 40 +BRANCH = "ci/p3-canonical-windows-package" +RUN = 4321 +TRX = b""" + + + + +""" + + +def canonical_manifest(event_name: str = "pull_request") -> dict: + counters = producer.counters_from_trx(TRX) + return { + "schemaVersion": 1, + "kind": "arsas-canonical-regression-evidence", + "canonicalWorkflow": "Build ARSAS", + "workflowRunId": RUN, + "runAttempt": 1, + "eventName": event_name, + "exactSourceSha": SOURCE, + "exactEngineSha": ENGINE, + "testFile": "arsas-tests.trx", + "testTrxSha256": hashlib.sha256(TRX).hexdigest(), + "testCounters": counters, + "fullRegressionPassed": True, + "releasePromotionAuthority": False, + } + + +def make_payload(event_name: str = "pull_request") -> tuple[bytes, dict]: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + evidence = root / "evidence-source" + evidence.mkdir() + (evidence / "ci-canonical-authority.json").write_text( + json.dumps(canonical_manifest(event_name), sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + (evidence / "arsas-tests.trx").write_bytes(TRX) + + portable = root / "ARSAS-1.6.40-win-x64-portable.exe" + portable.write_bytes(b"portable-tested-bits") + + installer = root / "ARSAS-1.6.40-win-x64" + bridge = installer / "Tools" / "ArdIrec" / "ardirec_bridge.dll" + bridge.parent.mkdir(parents=True) + bridge.write_bytes(b"native-bridge") + (installer / "ARSAS.exe").write_bytes(b"folder-app") + (installer / "LICENSE").write_text("license", encoding="utf-8") + + verification = root / "verification-source" + tests = verification / "ARSAS.Tests" + fixtures = verification / "fixtures" + tests.mkdir(parents=True) + fixtures.mkdir(parents=True) + (tests / "ARSAS.Tests.dll").write_bytes(b"test-assembly") + (tests / ".coverage-transient").write_text("must-not-promote", encoding="utf-8") + (fixtures / "minimal_1999.cfg").write_text("cfg", encoding="utf-8") + (fixtures / "distance_p1.cfg").write_text("locus", encoding="utf-8") + + artifact = root / "artifact" + manifest = producer.stage_and_create_manifest( + artifact_root=artifact, + portable=portable, + installer_input=installer, + verification_dir=verification, + evidence_dir=evidence, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + version="1.6.40", + runtime="win-x64", + workflow_run_id=RUN, + run_attempt=1, + event_name=event_name, + test_assembly_relative="ARSAS.Tests/ARSAS.Tests.dll", + comtrade_fixture_relative="fixtures/minimal_1999.cfg", + locus_fixture_relative="fixtures/distance_p1.cfg", + ) + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in sorted(artifact.rglob("*")): + if path.is_file(): + archive.write(path, "payload/" + path.relative_to(artifact).as_posix()) + return buffer.getvalue(), manifest + + +class FakeApi: + def __init__( + self, + *, + event_name: str = "pull_request", + status: str = "completed", + conclusion: str | None = "success", + artifact_available: bool = True, + ): + self.blob, _ = make_payload(event_name) + self.artifact_available = artifact_available + self.runs = [{ + "id": RUN, + "run_attempt": 1, + "name": "Build ARSAS", + "head_sha": HEAD, + "head_branch": BRANCH, + "event": event_name, + "status": status, + "conclusion": conclusion, + }] + + def get(self, url: str, *, binary: bool = False): + if binary: + return self.blob + if "/workflows/build.yml/runs?" in url: + return {"workflow_runs": self.runs} + if "/runs/" in url and "/artifacts" in url: + if not self.artifact_available: + return {"artifacts": []} + return {"artifacts": [{ + "id": 99, + "name": "ARSAS-windows-package-input", + "expired": False, + "archive_download_url": "https://example.invalid/package.zip", + "workflow_run": {"id": RUN}, + }]} + raise RuntimeError("Unexpected fake API URL " + url) + + +class PackageReuseTests(unittest.TestCase): + def test_producer_and_verifier_accept_exact_payload(self): + blob, manifest = make_payload() + proof = verifier.validate_package_archive( + blob, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + ) + self.assertEqual(proof["passed"], 1320) + self.assertEqual(proof["portableSha256"], manifest["portable"]["sha256"]) + self.assertEqual(proof["nativeBridgeSha256"], manifest["nativeBridge"]["sha256"]) + + def test_safe_materialization_occurs_after_verification(self): + blob, _ = make_payload() + with tempfile.TemporaryDirectory() as temp: + out = Path(temp) / "out" + verifier.validate_package_archive( + blob, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + output_dir=out, + ) + self.assertTrue((out / "ci-windows-package-authority.json").is_file()) + self.assertTrue((out / "installer-input/ARSAS-1.6.40-win-x64/ARSAS.exe").is_file()) + + def test_rejects_wrong_ardirec_identity(self): + blob, _ = make_payload() + with self.assertRaisesRegex(verifier.PackageProofError, "identity or authority"): + verifier.validate_package_archive( + blob, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha="f" * 40, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + ) + + def test_rejects_tampered_payload_bytes(self): + blob, _ = make_payload() + src = zipfile.ZipFile(io.BytesIO(blob)) + buffer = io.BytesIO() + with src, zipfile.ZipFile(buffer, "w") as dst: + for info in src.infolist(): + data = src.read(info) + if info.filename.endswith("ARSAS-1.6.40-win-x64-portable.exe"): + data += b"tamper" + dst.writestr(info.filename, data) + with self.assertRaisesRegex(verifier.PackageProofError, "size differs|digest differs"): + verifier.validate_package_archive( + buffer.getvalue(), + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + ) + + def test_rejects_unsafe_archive_path(self): + blob, _ = make_payload() + src = zipfile.ZipFile(io.BytesIO(blob)) + buffer = io.BytesIO() + with src, zipfile.ZipFile(buffer, "w") as dst: + for info in src.infolist(): + dst.writestr(info.filename, src.read(info)) + dst.writestr("../escape.txt", b"bad") + with self.assertRaisesRegex(verifier.PackageProofError, "unsafe|outside"): + verifier.validate_package_archive( + buffer.getvalue(), + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + ) + + def test_manifest_never_claims_installer_or_release_authority(self): + _, manifest = make_payload() + self.assertIs(manifest["installerBuilt"], False) + self.assertIs(manifest["releasePromotionAuthority"], False) + self.assertTrue(manifest["nativeBridgeIntegrationPassed"]) + self.assertTrue(manifest["portableSmokePassed"]) + + def test_hidden_build_transients_are_not_promoted(self): + blob, manifest = make_payload() + self.assertFalse(any("/." in "/" + entry["path"] for entry in manifest["files"])) + with zipfile.ZipFile(io.BytesIO(blob)) as archive: + self.assertFalse(any("/." in "/" + name for name in archive.namelist())) + + def test_installer_script_and_workflow_share_prebuilt_test_contract(self): + installer_script = (ROOT / "build-windows-installer.ps1").read_text(encoding="utf-8") + workflow = (ROOT.parent / ".github/workflows/installer-windows.yml").read_text(encoding="utf-8") + for token in ( + "[string]$TestAssemblyPath", + "[string]$FixtureCfg", + "[string]$LocusFixtureCfg", + "& dotnet vstest $TestAssemblyPath", + ): + self.assertIn(token, installer_script) + for token in ( + "-TestAssemblyPath $env:CANONICAL_TEST_ASSEMBLY", + "-FixtureCfg $env:CANONICAL_COMTRADE_FIXTURE", + "-LocusFixtureCfg $env:CANONICAL_LOCUS_FIXTURE", + "& dotnet vstest $env:CANONICAL_TEST_ASSEMBLY", + ): + self.assertIn(token, workflow) + + def test_in_progress_exact_artifact_can_be_reused(self): + api = FakeApi(status="in_progress", conclusion=None) + proof = verifier.verify_canonical_package( + api, + repository="masarray/arsas", + branch=BRANCH, + head_sha=HEAD, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + wait_seconds=0, + poll_seconds=1, + allow_in_progress_artifact=True, + ) + self.assertEqual(proof["proofStage"], "sealed-package-artifact-ready") + self.assertFalse(proof["workflowCompleted"]) + + def test_completed_failed_latest_run_is_never_reused(self): + api = FakeApi(status="completed", conclusion="failure") + with self.assertRaisesRegex(verifier.PackageProofError, "Latest canonical Build ARSAS run failed"): + verifier.verify_canonical_package( + api, + repository="masarray/arsas", + branch=BRANCH, + head_sha=HEAD, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + wait_seconds=0, + poll_seconds=1, + allow_in_progress_artifact=True, + ) + + def test_missing_in_progress_artifact_fails_closed_at_bound(self): + api = FakeApi(status="in_progress", conclusion=None, artifact_available=False) + with self.assertRaisesRegex(verifier.PackageProofError, "Timed out"): + verifier.verify_canonical_package( + api, + repository="masarray/arsas", + branch=BRANCH, + head_sha=HEAD, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + wait_seconds=0, + poll_seconds=1, + allow_in_progress_artifact=True, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-ci-package-reuse.py b/scripts/verify-ci-package-reuse.py new file mode 100644 index 000000000..f044ae960 --- /dev/null +++ b/scripts/verify-ci-package-reuse.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +"""Verify and safely materialize the exact canonical Windows package artifact.""" +from __future__ import annotations + +import argparse +import hashlib +import io +import importlib.util +import json +import os +import shutil +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from pathlib import Path, PurePosixPath + +_here = Path(__file__).resolve().parent + +_spec = importlib.util.spec_from_file_location( + "arsas_ci_package_producer", _here / "write-ci-package-manifest.py" +) +assert _spec and _spec.loader +_producer = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_producer) +MANIFEST_NAME = _producer.MANIFEST_NAME +KIND = _producer.KIND +CANONICAL_MANIFEST = _producer.CANONICAL_MANIFEST +CANONICAL_TRX = _producer.CANONICAL_TRX +counters_from_trx = _producer.counters_from_trx +sha = _producer.sha + +EXPECTED_ARTIFACT = "ARSAS-windows-package-input" +MAX_ARCHIVE_BYTES = 768 * 1024 * 1024 +MAX_FILE_BYTES = 256 * 1024 * 1024 +MAX_TOTAL_BYTES = 1024 * 1024 * 1024 +MAX_FILES = 5000 + + +class PackageProofError(RuntimeError): + pass + + +def validate_identity( + repository: str, branch: str, head_sha: str, source_sha: str, + engine_sha: str, ardirec_sha: str, event_name: str +) -> None: + if repository != "masarray/arsas": + raise PackageProofError("Canonical package reuse is restricted to masarray/arsas") + for label, value in ( + ("head", head_sha), + ("source", source_sha), + ("engine", engine_sha), + ("ArdIrec", ardirec_sha), + ): + try: + sha(value, label) + except ValueError as exc: + raise PackageProofError(str(exc)) from exc + if not branch or len(branch) > 200 or "\n" in branch: + raise PackageProofError("Invalid canonical branch identity") + if event_name not in {"pull_request", "push"}: + raise PackageProofError("Unsupported canonical package event") + + +def candidate_runs(payload: dict, head_sha: str, branch: str, event_name: str) -> list[dict]: + runs = payload.get("workflow_runs", []) + if not isinstance(runs, list): + raise PackageProofError("Build ARSAS run listing is malformed") + matches = [ + run for run in runs + if run.get("event") == event_name + and run.get("name") == "Build ARSAS" + and run.get("head_sha") == head_sha + and run.get("head_branch") == branch + and isinstance(run.get("id"), int) + and isinstance(run.get("run_attempt"), int) + ] + return sorted(matches, key=lambda run: (run["id"], run["run_attempt"]), reverse=True) + + +def choose_artifact(payload: dict, run_id: int, *, allow_missing: bool) -> dict | None: + artifacts = payload.get("artifacts", []) + if not isinstance(artifacts, list): + raise PackageProofError("Canonical package artifact listing is malformed") + matches = [ + obj for obj in artifacts + if obj.get("name") == EXPECTED_ARTIFACT + and obj.get("expired") is False + and isinstance(obj.get("id"), int) + and obj.get("workflow_run", {}).get("id") == run_id + ] + if not matches and allow_missing: + return None + if len(matches) != 1: + raise PackageProofError("Expected exactly one non-expired canonical package artifact") + return matches[0] + + +def _zip_relative(info: zipfile.ZipInfo, prefix: PurePosixPath) -> str: + name = info.filename.replace("\\", "/") + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or ":" in name: + raise PackageProofError("Canonical package archive contains an unsafe path") + try: + rel = path.relative_to(prefix) if str(prefix) != "." else path + except ValueError as exc: + raise PackageProofError("Canonical package archive contains files outside its root") from exc + rel_text = rel.as_posix() + if not rel_text or rel_text == ".": + raise PackageProofError("Canonical package archive contains an invalid root entry") + return rel_text + + +def _is_symlink(info: zipfile.ZipInfo) -> bool: + return ((info.external_attr >> 16) & 0o170000) == 0o120000 + + +def _hash_zip_entry(archive: zipfile.ZipFile, info: zipfile.ZipInfo) -> str: + h = hashlib.sha256() + with archive.open(info, "r") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def validate_package_archive( + payload: bytes, *, source_sha: str, engine_sha: str, ardirec_sha: str, + workflow_run_id: int, run_attempt: int, event_name: str, + output_dir: Path | None = None +) -> dict: + if not payload or len(payload) > MAX_ARCHIVE_BYTES: + raise PackageProofError("Canonical package artifact is missing or exceeds bounded size") + try: + archive = zipfile.ZipFile(io.BytesIO(payload)) + except (zipfile.BadZipFile, OSError) as exc: + raise PackageProofError("Canonical package artifact is not a valid ZIP") from exc + + with archive: + raw_files = [info for info in archive.infolist() if not info.is_dir()] + if not raw_files or len(raw_files) > MAX_FILES: + raise PackageProofError("Canonical package archive file count is invalid") + manifest_infos = [ + info for info in raw_files + if PurePosixPath(info.filename.replace("\\", "/")).name == MANIFEST_NAME + ] + if len(manifest_infos) != 1: + raise PackageProofError("Canonical package manifest is missing or duplicated") + manifest_info = manifest_infos[0] + prefix = PurePosixPath(manifest_info.filename.replace("\\", "/")).parent + + infos: dict[str, zipfile.ZipInfo] = {} + total_uncompressed = 0 + for info in raw_files: + if info.flag_bits & 0x1 or _is_symlink(info): + raise PackageProofError("Encrypted files and symlinks are not permitted") + if info.file_size < 0 or info.file_size > MAX_FILE_BYTES: + raise PackageProofError("Canonical package contains an oversized file") + total_uncompressed += info.file_size + if total_uncompressed > MAX_TOTAL_BYTES: + raise PackageProofError("Canonical package exceeds bounded uncompressed size") + rel = _zip_relative(info, prefix) + if rel in infos: + raise PackageProofError("Canonical package contains duplicate normalized paths") + infos[rel] = info + + if MANIFEST_NAME not in infos: + raise PackageProofError("Canonical package manifest must be rooted in artifact") + + try: + manifest = json.loads(archive.read(infos[MANIFEST_NAME])) + except (ValueError, KeyError, OSError, RuntimeError) as exc: + raise PackageProofError("Canonical package manifest cannot be decoded") from exc + + if ( + manifest.get("schemaVersion") != 1 + or manifest.get("kind") != KIND + or manifest.get("canonicalWorkflow") != "Build ARSAS" + or manifest.get("workflowRunId") != workflow_run_id + or manifest.get("runAttempt") != run_attempt + or manifest.get("eventName") != event_name + or manifest.get("sourceSha") != source_sha + or manifest.get("engineSha") != engine_sha + or manifest.get("ardirecSha") != ardirec_sha + or manifest.get("runtime") != "win-x64" + or manifest.get("fullRegressionPassed") is not True + or manifest.get("nativeBridgeIntegrationPassed") is not True + or manifest.get("portableSmokePassed") is not True + or manifest.get("installerBuilt") is not False + or manifest.get("releasePromotionAuthority") is not False + ): + raise PackageProofError("Canonical package manifest identity or authority is invalid") + + records = manifest.get("files") + if not isinstance(records, list) or not records or len(records) > MAX_FILES: + raise PackageProofError("Canonical package file manifest is malformed") + recorded: dict[str, dict] = {} + for record in records: + if not isinstance(record, dict): + raise PackageProofError("Canonical package file record is malformed") + path = record.get("path") + if not isinstance(path, str) or not path: + raise PackageProofError("Canonical package file path is malformed") + parsed = PurePosixPath(path) + if parsed.is_absolute() or ".." in parsed.parts or ":" in path or path in recorded: + raise PackageProofError("Canonical package file manifest has unsafe/duplicate paths") + if not isinstance(record.get("size"), int) or record["size"] < 0: + raise PackageProofError("Canonical package file size is invalid") + digest = record.get("sha256") + if not isinstance(digest, str) or len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest): + raise PackageProofError("Canonical package file digest is invalid") + recorded[path] = record + + archive_payload_paths = set(infos) - {MANIFEST_NAME} + if set(recorded) != archive_payload_paths: + raise PackageProofError("Canonical package archive and file manifest disagree") + if manifest.get("fileCount") != len(recorded): + raise PackageProofError("Canonical package file count disagrees with manifest") + if manifest.get("totalBytes") != sum(r["size"] for r in records): + raise PackageProofError("Canonical package byte count disagrees with manifest") + + for path, record in recorded.items(): + info = infos[path] + if info.file_size != record["size"]: + raise PackageProofError(f"Canonical package file size differs: {path}") + if _hash_zip_entry(archive, info) != record["sha256"]: + raise PackageProofError(f"Canonical package file digest differs: {path}") + + canonical_manifest_path = f"evidence/{CANONICAL_MANIFEST}" + trx_path = f"evidence/{CANONICAL_TRX}" + if canonical_manifest_path not in infos or trx_path not in infos: + raise PackageProofError("Canonical regression evidence is absent from package") + canonical_bytes = archive.read(infos[canonical_manifest_path]) + trx = archive.read(infos[trx_path]) + try: + canonical = json.loads(canonical_bytes) + counters = counters_from_trx(trx) + except (ValueError, ArithmeticError) as exc: + raise PackageProofError("Canonical regression evidence is invalid") from exc + if ( + canonical.get("schemaVersion") != 1 + or canonical.get("kind") != "arsas-canonical-regression-evidence" + or canonical.get("canonicalWorkflow") != "Build ARSAS" + or canonical.get("workflowRunId") != workflow_run_id + or canonical.get("runAttempt") != run_attempt + or canonical.get("eventName") != event_name + or canonical.get("exactSourceSha") != source_sha + or canonical.get("exactEngineSha") != engine_sha + or canonical.get("fullRegressionPassed") is not True + or canonical.get("releasePromotionAuthority") is not False + or canonical.get("testTrxSha256") != hashlib.sha256(trx).hexdigest() + or canonical.get("testCounters") != counters + or manifest.get("testCounters") != counters + or manifest.get("testTrxSha256") != hashlib.sha256(trx).hexdigest() + or manifest.get("canonicalTestManifestSha256") != hashlib.sha256(canonical_bytes).hexdigest() + ): + raise PackageProofError("Canonical regression proof does not match package manifest") + + for key in ("portable", "nativeBridge"): + record = manifest.get(key) + if not isinstance(record, dict) or recorded.get(record.get("path")) != record: + raise PackageProofError(f"Canonical package {key} record is invalid") + verification = manifest.get("verification") + if not isinstance(verification, dict): + raise PackageProofError("Canonical package verification references are missing") + for key in ("testAssemblyPath", "comtradeFixturePath", "locusFixturePath"): + path = verification.get(key) + if not isinstance(path, str) or path not in recorded: + raise PackageProofError(f"Canonical package verification path is invalid: {key}") + + if output_dir is not None: + if output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True) + for rel, info in infos.items(): + target = output_dir / PurePosixPath(rel) + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(info, "r") as src, target.open("wb") as dst: + shutil.copyfileobj(src, dst, 1024 * 1024) + + return { + "canonicalRunId": workflow_run_id, + "canonicalRunAttempt": run_attempt, + "sourceSha": source_sha, + "engineSha": engine_sha, + "ardirecSha": ardirec_sha, + "version": manifest["version"], + "passed": manifest["testCounters"]["passed"], + "failed": manifest["testCounters"]["failed"], + "notExecuted": manifest["testCounters"]["notExecuted"], + "fileCount": manifest["fileCount"], + "totalBytes": manifest["totalBytes"], + "portableSha256": manifest["portable"]["sha256"], + "nativeBridgeSha256": manifest["nativeBridge"]["sha256"], + "installerInputRoot": manifest["installerInputRoot"], + "testAssemblyPath": manifest["verification"]["testAssemblyPath"], + "comtradeFixturePath": manifest["verification"]["comtradeFixturePath"], + "locusFixturePath": manifest["verification"]["locusFixturePath"], + "artifactSha256": hashlib.sha256(payload).hexdigest(), + } + + +class StripCrossOriginAuthorization(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, msg, headers, newurl): + redirected = super().redirect_request(request, fp, code, msg, headers, newurl) + if redirected is not None: + before = urllib.parse.urlparse(request.full_url).netloc.lower() + after = urllib.parse.urlparse(newurl).netloc.lower() + if before != after: + redirected.remove_header("Authorization") + return redirected + + +class GitHubReadOnly: + def __init__(self, token: str): + if not token: + raise PackageProofError("A read-only GITHUB_TOKEN is required") + self.token = token + + def get(self, url: str, *, binary: bool = False): + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "ARSAS-CI-P3-Package-Reuse", + }, + method="GET", + ) + try: + opener = urllib.request.build_opener(StripCrossOriginAuthorization()) + with opener.open(req, timeout=60) as reply: + limit = MAX_ARCHIVE_BYTES + 1 if binary else 4 * 1024 * 1024 + data = reply.read(limit) + if len(data) >= limit: + raise PackageProofError("GitHub package response exceeds bounded size") + return data if binary else json.loads(data) + except PackageProofError: + raise + except (urllib.error.HTTPError, urllib.error.URLError, ValueError) as exc: + raise PackageProofError(f"GitHub package evidence request failed: {exc}") from exc + + +def verify_canonical_package( + api: GitHubReadOnly, *, repository: str, branch: str, + head_sha: str, source_sha: str, engine_sha: str, ardirec_sha: str, + event_name: str = "pull_request", wait_seconds: int = 1200, + poll_seconds: int = 10, allow_in_progress_artifact: bool = False, + output_dir: Path | None = None +) -> dict: + validate_identity( + repository, branch, head_sha, source_sha, engine_sha, ardirec_sha, event_name + ) + if wait_seconds < 0 or poll_seconds < 1: + raise PackageProofError("Invalid bounded wait policy") + + base = "https://api.github.com/repos/" + repository + query = urllib.parse.urlencode( + {"event": event_name, "head_sha": head_sha, "per_page": 100} + ) + url = f"{base}/actions/workflows/build.yml/runs?{query}" + deadline = time.monotonic() + wait_seconds + previous = None + + while True: + runs = candidate_runs(api.get(url), head_sha, branch, event_name) + if runs: + run = runs[0] + run_id, attempt = run["id"], run["run_attempt"] + status, conclusion = run.get("status"), run.get("conclusion") + previous = f"{run_id}/{attempt} {status}/{conclusion}" + if status == "completed" and conclusion != "success": + raise PackageProofError(f"Latest canonical Build ARSAS run failed: {previous}") + + can_read = status == "completed" or ( + allow_in_progress_artifact and status == "in_progress" + ) + if can_read: + artifact = choose_artifact( + api.get(f"{base}/actions/runs/{run_id}/artifacts?per_page=100"), + run_id, + allow_missing=(status != "completed"), + ) + if artifact is not None: + blob = api.get(artifact["archive_download_url"], binary=True) + try: + proof = validate_package_archive( + blob, + source_sha=source_sha, + engine_sha=engine_sha, + ardirec_sha=ardirec_sha, + workflow_run_id=run_id, + run_attempt=attempt, + event_name=event_name, + output_dir=output_dir, + ) + except PackageProofError as exc: + if "identity or authority is invalid" not in str(exc): + raise + previous = f"{previous} (stale/different package identity)" + else: + proof.update({ + "canonicalRunStatus": status, + "canonicalRunConclusion": conclusion, + "workflowCompleted": status == "completed", + "proofStage": ( + "completed-workflow" + if status == "completed" + else "sealed-package-artifact-ready" + ), + "releasePromotionAuthority": False, + }) + return proof + + if time.monotonic() >= deadline: + raise PackageProofError( + "Timed out waiting for exact canonical Windows package artifact " + f"for head={head_sha}, source={source_sha}, last={previous}" + ) + time.sleep(min(poll_seconds, max(0.0, deadline - time.monotonic()))) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", required=True) + parser.add_argument("--head-branch", required=True) + parser.add_argument("--head-sha", required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--engine-sha", required=True) + parser.add_argument("--ardirec-sha", required=True) + parser.add_argument("--event-name", choices=("pull_request", "push"), default="pull_request") + parser.add_argument("--wait-seconds", type=int, default=1200) + parser.add_argument("--allow-in-progress-artifact", action="store_true") + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--proof-output", type=Path) + args = parser.parse_args() + + proof = verify_canonical_package( + GitHubReadOnly(os.environ.get("GITHUB_TOKEN", "")), + repository=args.repository, + branch=args.head_branch, + head_sha=args.head_sha, + source_sha=args.source_sha, + engine_sha=args.engine_sha, + ardirec_sha=args.ardirec_sha, + event_name=args.event_name, + wait_seconds=args.wait_seconds, + allow_in_progress_artifact=args.allow_in_progress_artifact, + output_dir=args.output_dir, + ) + payload = json.dumps(proof, sort_keys=True) + print("Canonical Windows package reuse PASS: " + payload) + if args.proof_output: + args.proof_output.parent.mkdir(parents=True, exist_ok=True) + args.proof_output.write_text(payload + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/write-ci-package-manifest.py b/scripts/write-ci-package-manifest.py new file mode 100644 index 000000000..f12314900 --- /dev/null +++ b/scripts/write-ci-package-manifest.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Seal the exact tested Windows package payload produced by Build ARSAS.""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import re +import shutil +from pathlib import Path, PurePosixPath + +_here = Path(__file__).resolve().parent +_spec = importlib.util.spec_from_file_location( + "arsas_ci_canonical_proof", _here / "write-ci-canonical-proof.py" +) +assert _spec and _spec.loader +_canonical = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_canonical) +counters_from_trx = _canonical.counters_from_trx +sha = _canonical.sha + +MANIFEST_NAME = "ci-windows-package-authority.json" +CANONICAL_MANIFEST = "ci-canonical-authority.json" +CANONICAL_TRX = "arsas-tests.trx" +KIND = "arsas-canonical-windows-package" +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$") + + +def digest(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def safe_relative(path: Path, root: Path) -> str: + rel = path.relative_to(root).as_posix() + parsed = PurePosixPath(rel) + if parsed.is_absolute() or ".." in parsed.parts or ":" in rel: + raise ValueError(f"Unsafe staged path: {rel}") + return rel + + +def copy_tree(source: Path, destination: Path) -> None: + if not source.is_dir(): + raise FileNotFoundError(f"Directory not found: {source}") + for item in source.rglob("*"): + relative = item.relative_to(source) + # actions/upload-artifact excludes hidden files by default. Hidden build/ + # coverage transients are not runtime package inputs, so exclude them + # deliberately rather than widening artifact upload to hidden content. + if any(part.startswith(".") for part in relative.parts): + continue + if item.is_symlink(): + raise ValueError(f"Symlinks are not permitted in package input: {item}") + if not item.is_file(): + continue + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, target) + + +def verify_canonical_evidence( + evidence_dir: Path, source_sha: str, engine_sha: str, + run_id: int, run_attempt: int, event_name: str +) -> tuple[dict, bytes]: + manifest_path = evidence_dir / CANONICAL_MANIFEST + trx_path = evidence_dir / CANONICAL_TRX + if not manifest_path.is_file() or not trx_path.is_file(): + raise FileNotFoundError("Canonical regression manifest/TRX is missing") + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + trx = trx_path.read_bytes() + counters = counters_from_trx(trx) + if ( + manifest.get("schemaVersion") != 1 + or manifest.get("kind") != "arsas-canonical-regression-evidence" + or manifest.get("canonicalWorkflow") != "Build ARSAS" + or manifest.get("workflowRunId") != run_id + or manifest.get("runAttempt") != run_attempt + or manifest.get("eventName") != event_name + or manifest.get("exactSourceSha") != source_sha + or manifest.get("exactEngineSha") != engine_sha + or manifest.get("fullRegressionPassed") is not True + or manifest.get("releasePromotionAuthority") is not False + or manifest.get("testFile") != CANONICAL_TRX + or manifest.get("testTrxSha256") != hashlib.sha256(trx).hexdigest() + or manifest.get("testCounters") != counters + ): + raise ValueError("Canonical regression evidence does not match package identity") + return manifest, trx + + +def file_record(path: Path, root: Path) -> dict: + return { + "path": safe_relative(path, root), + "size": path.stat().st_size, + "sha256": digest(path), + } + + +def stage_and_create_manifest( + *, + artifact_root: Path, + portable: Path, + installer_input: Path, + verification_dir: Path, + evidence_dir: Path, + source_sha: str, + engine_sha: str, + ardirec_sha: str, + version: str, + runtime: str, + workflow_run_id: int, + run_attempt: int, + event_name: str, + test_assembly_relative: str, + comtrade_fixture_relative: str, + locus_fixture_relative: str, +) -> dict: + source_sha = sha(source_sha, "source") + engine_sha = sha(engine_sha, "engine") + ardirec_sha = sha(ardirec_sha, "ArdIrec") + if not VERSION_RE.fullmatch(version): + raise ValueError("Invalid package version") + if runtime != "win-x64": + raise ValueError("Canonical Windows package runtime must be win-x64") + if workflow_run_id <= 0 or run_attempt <= 0: + raise ValueError("Invalid workflow run identity") + if event_name not in {"push", "pull_request", "workflow_dispatch"}: + raise ValueError("Unexpected workflow event") + if not portable.is_file() or not installer_input.is_dir(): + raise FileNotFoundError("Portable or installer-input payload is missing") + + canonical, trx = verify_canonical_evidence( + evidence_dir, source_sha, engine_sha, workflow_run_id, run_attempt, event_name + ) + + if artifact_root.exists(): + shutil.rmtree(artifact_root) + artifact_root.mkdir(parents=True) + + portable_target = artifact_root / "portable" / portable.name + portable_target.parent.mkdir(parents=True) + shutil.copy2(portable, portable_target) + + installer_target = artifact_root / "installer-input" / installer_input.name + copy_tree(installer_input, installer_target) + + evidence_target = artifact_root / "evidence" + evidence_target.mkdir() + shutil.copy2(evidence_dir / CANONICAL_MANIFEST, evidence_target / CANONICAL_MANIFEST) + (evidence_target / CANONICAL_TRX).write_bytes(trx) + + verification_target = artifact_root / "verification" + copy_tree(verification_dir, verification_target) + + bridge = installer_target / "Tools" / "ArdIrec" / "ardirec_bridge.dll" + test_assembly = verification_target / PurePosixPath(test_assembly_relative) + comtrade_fixture = verification_target / PurePosixPath(comtrade_fixture_relative) + locus_fixture = verification_target / PurePosixPath(locus_fixture_relative) + for label, path in ( + ("native bridge", bridge), + ("test assembly", test_assembly), + ("COMTRADE fixture", comtrade_fixture), + ("locus fixture", locus_fixture), + ): + if not path.is_file(): + raise FileNotFoundError(f"Required {label} missing from sealed payload: {path}") + + files = [] + for path in sorted(artifact_root.rglob("*"), key=lambda p: p.as_posix().lower()): + if path.is_symlink(): + raise ValueError(f"Symlink not allowed in sealed payload: {path}") + if path.is_file() and path.name != MANIFEST_NAME: + files.append(file_record(path, artifact_root)) + + by_path = {entry["path"]: entry for entry in files} + portable_rel = safe_relative(portable_target, artifact_root) + bridge_rel = safe_relative(bridge, artifact_root) + test_rel = safe_relative(test_assembly, artifact_root) + comtrade_rel = safe_relative(comtrade_fixture, artifact_root) + locus_rel = safe_relative(locus_fixture, artifact_root) + + manifest = { + "schemaVersion": 1, + "kind": KIND, + "canonicalWorkflow": "Build ARSAS", + "workflowRunId": workflow_run_id, + "runAttempt": run_attempt, + "eventName": event_name, + "sourceSha": source_sha, + "engineSha": engine_sha, + "ardirecSha": ardirec_sha, + "version": version, + "runtime": runtime, + "fullRegressionPassed": True, + "testCounters": canonical["testCounters"], + "canonicalTestManifestSha256": digest(evidence_target / CANONICAL_MANIFEST), + "testTrxSha256": hashlib.sha256(trx).hexdigest(), + "nativeBridgeIntegrationPassed": True, + "portableSmokePassed": True, + "installerBuilt": False, + "releasePromotionAuthority": False, + "installerInputRoot": safe_relative(installer_target, artifact_root), + "portable": by_path[portable_rel], + "nativeBridge": by_path[bridge_rel], + "verification": { + "testAssemblyPath": test_rel, + "comtradeFixturePath": comtrade_rel, + "locusFixturePath": locus_rel, + }, + "fileCount": len(files), + "totalBytes": sum(entry["size"] for entry in files), + "files": files, + } + (artifact_root / MANIFEST_NAME).write_text( + json.dumps(manifest, sort_keys=True, indent=2) + "\n", encoding="utf-8" + ) + return manifest + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifact-root", type=Path, required=True) + parser.add_argument("--portable", type=Path, required=True) + parser.add_argument("--installer-input", type=Path, required=True) + parser.add_argument("--verification-dir", type=Path, required=True) + parser.add_argument("--evidence-dir", type=Path, required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--engine-sha", required=True) + parser.add_argument("--ardirec-sha", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--runtime", default="win-x64") + parser.add_argument("--run-id", type=int, required=True) + parser.add_argument("--attempt", type=int, required=True) + parser.add_argument("--event-name", required=True) + parser.add_argument( + "--test-assembly-relative", + default="ARSAS.Tests/ARSAS.Tests.dll", + ) + parser.add_argument( + "--comtrade-fixture-relative", + default="fixtures/minimal_1999.cfg", + ) + parser.add_argument( + "--locus-fixture-relative", + default="fixtures/distance_p1.cfg", + ) + args = parser.parse_args() + + manifest = stage_and_create_manifest( + artifact_root=args.artifact_root, + portable=args.portable, + installer_input=args.installer_input, + verification_dir=args.verification_dir, + evidence_dir=args.evidence_dir, + source_sha=args.source_sha, + engine_sha=args.engine_sha, + ardirec_sha=args.ardirec_sha, + version=args.version, + runtime=args.runtime, + workflow_run_id=args.run_id, + run_attempt=args.attempt, + event_name=args.event_name, + test_assembly_relative=args.test_assembly_relative, + comtrade_fixture_relative=args.comtrade_fixture_relative, + locus_fixture_relative=args.locus_fixture_relative, + ) + print( + "Canonical Windows package sealed: " + f"source={manifest['sourceSha']} engine={manifest['engineSha']} " + f"ardirec={manifest['ardirecSha']} files={manifest['fileCount']} " + f"bytes={manifest['totalBytes']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From b16c5b8ebc7497690bc7923a6d246554fe028f83 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:18:41 +0700 Subject: [PATCH 2/3] security(ci-p3): reject Windows path aliases before package extraction --- scripts/verify-ci-package-reuse.py | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/scripts/verify-ci-package-reuse.py b/scripts/verify-ci-package-reuse.py index f044ae960..3848e8d97 100644 --- a/scripts/verify-ci-package-reuse.py +++ b/scripts/verify-ci-package-reuse.py @@ -113,6 +113,38 @@ def _zip_relative(info: zipfile.ZipInfo, prefix: PurePosixPath) -> str: return rel_text +WINDOWS_RESERVED_NAMES = { + "con", "prn", "aux", "nul", + *(f"com{i}" for i in range(1, 10)), + *(f"lpt{i}" for i in range(1, 10)), +} + + +def _windows_path_key(path_text: str) -> str: + """Return a Windows-canonical collision key or reject unsafe aliases.""" + path = PurePosixPath(path_text) + normalized = [] + for part in path.parts: + if ( + not part + or part in {".", ".."} + or part.endswith((" ", ".")) + or any(ord(ch) < 32 for ch in part) + ): + raise PackageProofError( + "Canonical package contains a Windows-unsafe path component" + ) + device = part.split(".", 1)[0].casefold() + if device in WINDOWS_RESERVED_NAMES: + raise PackageProofError( + "Canonical package contains a reserved Windows device path" + ) + normalized.append(part.casefold()) + if not normalized: + raise PackageProofError("Canonical package contains an empty Windows path") + return "/".join(normalized) + + def _is_symlink(info: zipfile.ZipInfo) -> bool: return ((info.external_attr >> 16) & 0o170000) == 0o120000 @@ -151,6 +183,7 @@ def validate_package_archive( prefix = PurePosixPath(manifest_info.filename.replace("\\", "/")).parent infos: dict[str, zipfile.ZipInfo] = {} + windows_archive_paths: dict[str, str] = {} total_uncompressed = 0 for info in raw_files: if info.flag_bits & 0x1 or _is_symlink(info): @@ -163,6 +196,13 @@ def validate_package_archive( rel = _zip_relative(info, prefix) if rel in infos: raise PackageProofError("Canonical package contains duplicate normalized paths") + windows_key = _windows_path_key(rel) + previous = windows_archive_paths.get(windows_key) + if previous is not None and previous != rel: + raise PackageProofError( + "Canonical package contains a Windows-normalized path collision" + ) + windows_archive_paths[windows_key] = rel infos[rel] = info if MANIFEST_NAME not in infos: @@ -196,6 +236,7 @@ def validate_package_archive( if not isinstance(records, list) or not records or len(records) > MAX_FILES: raise PackageProofError("Canonical package file manifest is malformed") recorded: dict[str, dict] = {} + windows_record_paths: dict[str, str] = {} for record in records: if not isinstance(record, dict): raise PackageProofError("Canonical package file record is malformed") @@ -205,6 +246,13 @@ def validate_package_archive( parsed = PurePosixPath(path) if parsed.is_absolute() or ".." in parsed.parts or ":" in path or path in recorded: raise PackageProofError("Canonical package file manifest has unsafe/duplicate paths") + windows_key = _windows_path_key(path) + previous = windows_record_paths.get(windows_key) + if previous is not None and previous != path: + raise PackageProofError( + "Canonical package file manifest has a Windows-normalized path collision" + ) + windows_record_paths[windows_key] = path if not isinstance(record.get("size"), int) or record["size"] < 0: raise PackageProofError("Canonical package file size is invalid") digest = record.get("sha256") From babbc698c789db0521f956e06b1e081b03e88d25 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:18:45 +0700 Subject: [PATCH 3/3] test(ci-p3): cover Windows path collision hardening --- scripts/test-ci-package-reuse.py | 50 +++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/scripts/test-ci-package-reuse.py b/scripts/test-ci-package-reuse.py index 727023aaa..61774ab11 100644 --- a/scripts/test-ci-package-reuse.py +++ b/scripts/test-ci-package-reuse.py @@ -9,7 +9,7 @@ import tempfile import unittest import zipfile -from pathlib import Path +from pathlib import Path, PurePosixPath ROOT = Path(__file__).resolve().parent @@ -246,6 +246,54 @@ def test_rejects_unsafe_archive_path(self): event_name="pull_request", ) + def test_rejects_windows_casefold_archive_collision(self): + blob, _ = make_payload() + src = zipfile.ZipFile(io.BytesIO(blob)) + buffer = io.BytesIO() + with src, zipfile.ZipFile(buffer, "w") as dst: + names = src.namelist() + for info in src.infolist(): + dst.writestr(info.filename, src.read(info)) + target = next( + name for name in names + if name.endswith("installer-input/ARSAS-1.6.40-win-x64/ARSAS.exe") + ) + dst.writestr(target[:-9] + "arsas.exe", b"collision") + with self.assertRaisesRegex( + verifier.PackageProofError, "Windows-normalized path collision" + ): + verifier.validate_package_archive( + buffer.getvalue(), + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + ) + + def test_rejects_windows_trailing_dot_archive_path(self): + blob, _ = make_payload() + src = zipfile.ZipFile(io.BytesIO(blob)) + buffer = io.BytesIO() + with src, zipfile.ZipFile(buffer, "w") as dst: + for info in src.infolist(): + dst.writestr(info.filename, src.read(info)) + prefix = PurePosixPath(src.namelist()[0]).parts[0] + dst.writestr(f"{prefix}/installer-input/bad./payload.txt", b"bad") + with self.assertRaisesRegex( + verifier.PackageProofError, "Windows-unsafe path component" + ): + verifier.validate_package_archive( + buffer.getvalue(), + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + ) + def test_manifest_never_claims_installer_or_release_authority(self): _, manifest = make_payload() self.assertIs(manifest["installerBuilt"], False)