diff --git a/.github/actions/resolve-ariec-engine/action.yml b/.github/actions/resolve-ariec-engine/action.yml new file mode 100644 index 000000000..5d37b8e06 --- /dev/null +++ b/.github/actions/resolve-ariec-engine/action.yml @@ -0,0 +1,40 @@ +name: Resolve immutable ARIEC61850 engine lock +description: Validate the tracked ARIEC61850 exact SHA and expose it without rewriting historical field authority. +inputs: + lock-path: + description: Path to tracked engine lock, relative to the runner workspace + required: false + default: ARSAS/engines/ARIEC61850.lock.json +outputs: + repository: + description: Trusted pinned engine repository + value: ${{ steps.resolve.outputs.repository }} + ref: + description: Canonical repository ref metadata + value: ${{ steps.resolve.outputs.ref }} + commit: + description: Exact immutable integration engine SHA + value: ${{ steps.resolve.outputs.commit }} + physical-tested-commit: + description: Historical physical engine head; never substituted for integration SHA + value: ${{ steps.resolve.outputs.physical_tested_commit }} + merged-main-commit: + description: Historical merged-main engine head + value: ${{ steps.resolve.outputs.merged_main_commit }} + baseline-is-current: + description: Whether integration SHA equals historical merged-main baseline + value: ${{ steps.resolve.outputs.baseline_is_current }} + +runs: + using: composite + steps: + - id: resolve + name: Validate pinned engine lock without remote lookups + shell: pwsh + env: + CI_LOCK_PATH: ${{ inputs.lock-path }} + run: | + $ErrorActionPreference = 'Stop' + $resolver = Join-Path $env:GITHUB_ACTION_PATH '..\..\..\scripts\resolve-ariec-lock.ps1' + $lockPath = Join-Path $env:GITHUB_WORKSPACE $env:CI_LOCK_PATH + & $resolver -LockPath $lockPath -GitHubOutput $env:GITHUB_OUTPUT | Out-Null diff --git a/.github/workflows/_ci-p1-windows-build-test.yml b/.github/workflows/_ci-p1-windows-build-test.yml new file mode 100644 index 000000000..92eaa2e61 --- /dev/null +++ b/.github/workflows/_ci-p1-windows-build-test.yml @@ -0,0 +1,148 @@ +name: Reusable ARSAS Windows Build and Test + +on: + workflow_call: + inputs: + source_sha: + description: Exact ARSAS source SHA (PR merge tree or main commit) + type: string + required: true + shadow_mode: + description: Prevent this primitive from being mistaken for release/promotion authority + type: boolean + default: true + +permissions: + contents: read + +jobs: + windows-build-and-test: + name: Shared exact-SHA Windows compilation and regression + runs-on: windows-latest + timeout-minutes: 45 + steps: + - name: Checkout exact ARSAS source + uses: actions/checkout@v7 + with: + path: ARSAS + ref: ${{ inputs.source_sha }} + persist-credentials: false + fetch-depth: 1 + + - name: Assert source identity and shadow-only execution + shell: pwsh + env: + EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} + SHADOW_ONLY: ${{ inputs.shadow_mode }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:SHADOW_ONLY -ne 'true') { + throw 'CI-P1 reusable job is not approved as production/release authority.' + } + if ($env:EXPECTED_SOURCE_SHA -cnotmatch '^[0-9a-f]{40}$') { + throw 'CI-P1 source_sha must be an immutable 40-character Git SHA.' + } + $actual = (git -C .\ARSAS rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -cne $env:EXPECTED_SOURCE_SHA) { + throw "ARSAS source SHA mismatch: expected $env:EXPECTED_SOURCE_SHA, got $actual." + } + + - name: Verify immutable engine resolver synthetic fixtures + shell: pwsh + run: .\ARSAS\scripts\test-resolve-ariec-lock.ps1 + + - id: engine + name: Resolve exact pinned ARIEC61850 engine identity + uses: ./ARSAS/.github/actions/resolve-ariec-engine + with: + lock-path: ARSAS/engines/ARIEC61850.lock.json + + - name: Checkout exact engine SHA + uses: actions/checkout@v7 + with: + repository: ${{ steps.engine.outputs.repository }} + ref: ${{ steps.engine.outputs.commit }} + path: ARIEC61850 + fetch-depth: 1 + persist-credentials: false + + - name: Verify exact engine and immutable history provenance + shell: pwsh + env: + EXPECTED_ENGINE_SHA: ${{ steps.engine.outputs.commit }} + run: | + $ErrorActionPreference = 'Stop' + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -cne $env:EXPECTED_ENGINE_SHA) { + throw "ARIEC61850 pinned checkout drifted: expected $env:EXPECTED_ENGINE_SHA, got $actual." + } + Write-Host "Reproducible compilation authority: ARSAS + pinned ARIEC61850." + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 8.0.x + + - name: Cache immutable NuGet packages only + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ci-p1-nuget-win-x64-${{ hashFiles('ARSAS/**/*.csproj', 'ARIEC61850/**/*.csproj', 'ARSAS/Directory.Packages.props', 'ARIEC61850/Directory.Packages.props') }} + restore-keys: | + ci-p1-nuget-win-x64- + + - name: Restore solution once + run: dotnet restore .\ARSAS\ArIED61850Tester.sln + + - name: Build solution once + run: dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore + + - name: Run full ARSAS regression suite once + run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=ci-p1-shadow.trx" --results-directory .\ARSAS\TestResults + + - name: Write non-promotion build authority manifest + if: always() + shell: pwsh + env: + ENGINE_SHA: ${{ steps.engine.outputs.commit }} + ENGINE_REPOSITORY: ${{ steps.engine.outputs.repository }} + HISTORICAL_ENGINE_SHA: ${{ steps.engine.outputs.merged-main-commit }} + PHYSICAL_TESTED_ENGINE_SHA: ${{ steps.engine.outputs.physical-tested-commit }} + run: | + $ErrorActionPreference = 'Stop' + $sourceSha = (git -C .\ARSAS rev-parse HEAD).Trim().ToLowerInvariant() + $engineActual = if (Test-Path -LiteralPath .\ARIEC61850\.git) { + (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + } else { '' } + $reportFiles = @(Get-ChildItem -Path .\ARSAS\TestResults -Filter '*.trx' -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Name) + $manifest = [ordered]@{ + schemaVersion = 1 + kind = 'ci-p1-nonpromotion-shadow' + exactArsasSha = $sourceSha + exactEngineSha = $engineActual + expectedEngineSha = $env:ENGINE_SHA + engineRepository = $env:ENGINE_REPOSITORY + historicalMergedEngineSha = $env:HISTORICAL_ENGINE_SHA + historicalPhysicalTestedSha = $env:PHYSICAL_TESTED_ENGINE_SHA + engineExactShaVerified = ($engineActual -ceq $env:ENGINE_SHA) + testReports = $reportFiles + workflowRunId = $env:GITHUB_RUN_ID + noReleaseOrPromotion = $true + } + $path = Join-Path $env:RUNNER_TEMP 'ci-p1-shadow-manifest.json' + $manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $path -Encoding UTF8 + if ($manifest.engineExactShaVerified -ne $true -or $reportFiles.Count -eq 0) { + throw 'CI-P1 shadow SHA/TRX proof is incomplete; fail closed.' + } + + - name: Upload inspectable shadow TRX and SHA manifest + if: always() + uses: actions/upload-artifact@v7 + with: + name: ci-p1-shadow-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ARSAS/TestResults/*.trx + ${{ runner.temp }}/ci-p1-shadow-manifest.json + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d11d32785..dd6aa6470 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,10 @@ on: pull_request: workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: build-windows: name: Build, test, validate and package Windows application @@ -30,7 +34,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 +47,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 @@ -55,6 +77,25 @@ jobs: shell: powershell run: .\ArIED61850Tester\scripts\test-source-clean-guard.ps1 + - name: Validate Windows packaging PowerShell syntax + shell: powershell + run: | + $scripts = @( + '.\ArIED61850Tester\scripts\build-ardirec-bridge.ps1', + '.\ArIED61850Tester\scripts\publish-windows-portable.ps1', + '.\ArIED61850Tester\scripts\build-windows-installer.ps1' + ) + foreach ($script in $scripts) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $script, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -ne 0) { + $messages = ($errors | ForEach-Object Message) -join '; ' + throw "PowerShell syntax validation failed for ${script}: $messages" + } + } + - name: Set up Python for read-only maintenance inventory uses: actions/setup-python@v6 with: @@ -65,6 +106,10 @@ jobs: run: | python .\ArIED61850Tester\scripts\test-maintenance-dependencies.py if ($LASTEXITCODE -ne 0) { throw "Maintenance inventory tests failed." } + python .\ArIED61850Tester\scripts\test-verify-p10-ge-field-scl.py + if ($LASTEXITCODE -ne 0) { throw "P10 GE trusted-IID SCL field gate fixture regressions failed." } + python .\ArIED61850Tester\scripts\test-compare-p10-brcb-sessions.py + if ($LASTEXITCODE -ne 0) { throw "P10.8D exact-slot BRCB comparator regressions failed." } python .\ArIED61850Tester\scripts\audit-maintenance-dependencies.py --root .\ArIED61850Tester --output "$env:RUNNER_TEMP\arsas-maintenance-dependencies.json" if ($LASTEXITCODE -ne 0) { throw "Maintenance inventory generation failed." } @@ -276,6 +321,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: | @@ -353,18 +412,142 @@ jobs: - name: Build solution run: dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore + - name: Run pinned engine P10 identity and canonical SCL regression + run: dotnet test .\ARIEC61850\tests\AR.Iec61850.Tests\AR.Iec61850.Tests.csproj -c Release --filter "FullyQualifiedName~TrustedSclIedIdentityMatcherTests|FullyQualifiedName~CanonicalLiveIedSclExporterTests" --logger "trx;LogFileName=engine-p10-identity.trx" --results-directory .\ArIED61850Tester\EngineTestResults + - name: Run ARSAS application regression tests run: dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-tests.trx" --results-directory .\ArIED61850Tester\TestResults + - name: Write exact-SHA canonical test attestation + shell: powershell + run: | + python .\ArIED61850Tester\scripts\write-ci-canonical-proof.py ` + --app-dir .\ArIED61850Tester ` + --engine-dir .\ARIEC61850 ` + --test-results-dir .\ArIED61850Tester\TestResults ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --run-id $env:GITHUB_RUN_ID ` + --attempt $env:GITHUB_RUN_ATTEMPT ` + --event-name $env:GITHUB_EVENT_NAME + if ($LASTEXITCODE -ne 0) { + throw 'Canonical exact-SHA full-regression attestation failed.' + } + - name: Upload test evidence if: always() uses: actions/upload-artifact@v7 with: name: ARSAS-test-evidence - path: ArIED61850Tester\TestResults\*.trx + path: | + ArIED61850Tester\TestResults\*.trx + ArIED61850Tester\TestResults\ci-canonical-authority.json + if-no-files-found: error + + - 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' + $ctestJunit = Join-Path $env:RUNNER_TEMP 'arsas-canonical-native-ctest.xml' + $bridge = [string]( + .\ArIED61850Tester\scripts\build-ardirec-bridge.ps1 ` + -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` + -OutputDirectory $bridgeDir ` + -BuildDirectory $bridgeBuild ` + -CTestJunitPath $ctestJunit | + 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" + } + $ctestProof = Join-Path $env:RUNNER_TEMP 'ci-native-ctest-authority.json' + python .\ArIED61850Tester\scripts\write-ci-native-ctest-proof.py ` + --junit $ctestJunit ` + --bridge $bridge ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --run-id $env:GITHUB_RUN_ID ` + --attempt $env:GITHUB_RUN_ATTEMPT ` + --event $env:GITHUB_EVENT_NAME ` + --output $ctestProof + if ($LASTEXITCODE -ne 0) { throw "Canonical native CTest proof sealing failed." } + if ($env:GITHUB_EVENT_NAME -eq 'pull_request') { + $shadowDir = '.\ArIED61850Tester\TestResults\native-shadow' + New-Item -ItemType Directory -Path $shadowDir -Force | Out-Null + Copy-Item $ctestJunit (Join-Path $shadowDir 'ctest.xml') -Force + Copy-Item $ctestProof (Join-Path $shadowDir 'ci-native-ctest-authority.json') -Force + } + "NATIVE_CTEST_JUNIT=$ctestJunit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "NATIVE_CTEST_PROOF=$ctestProof" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "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: Upload canonical native CTest shadow evidence + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: ARSAS-native-ctest-shadow + path: ArIED61850Tester/TestResults/native-shadow/ if-no-files-found: error + retention-days: 14 + + - 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: Publish real portable single EXE x64 + - 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 ` @@ -373,7 +556,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 @@ -393,9 +578,105 @@ 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' + $native = Join-Path $verification 'native' + New-Item -ItemType Directory -Force -Path $tests, $fixtures, $native | Out-Null + $stagedCtest = Join-Path $native 'ctest.xml' + $stagedNativeProof = Join-Path $native 'ci-native-ctest-authority.json' + Copy-Item $env:NATIVE_CTEST_JUNIT $stagedCtest -Force + Copy-Item $env:NATIVE_CTEST_PROOF $stagedNativeProof -Force + python .\ArIED61850Tester\scripts\write-ci-native-ctest-proof.py ` + --junit $stagedCtest ` + --bridge $env:ARDIREC_BRIDGE_PATH ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --run-id $env:GITHUB_RUN_ID ` + --attempt $env:GITHUB_RUN_ATTEMPT ` + --event $env:GITHUB_EVENT_NAME ` + --output $stagedNativeProof ` + --verify-only + if ($LASTEXITCODE -ne 0) { throw 'Staged native CTest proof verification failed.' } + + $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 + } + } + + foreach ($extension in @('cfg', 'dat')) { + $releaseFixture = ".\ArIED61850Tester\tests\fixtures\comtrade\p1-release-smoke.$extension" + if (-not (Test-Path $releaseFixture -PathType Leaf)) { + throw "Canonical release native fixture is missing: $releaseFixture" + } + Copy-Item $releaseFixture (Join-Path $fixtures "p1-release-smoke.$extension") -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" ` + --portable-identity ".\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable-build-identity.json" ` + --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 -or + -not $manifest.portableIdentity -or + $manifest.portableIdentity.path -notmatch 'portable-build-identity\.json$') { + 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: name: ARSAS-win-x64-portable-single-exe - path: ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + path: | + ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + ArIED61850Tester\dist\ARSAS-*-win-x64-portable-build-identity.json if-no-files-found: error diff --git a/.github/workflows/ci-p0-workflow-integrity.yml b/.github/workflows/ci-p0-workflow-integrity.yml new file mode 100644 index 000000000..f12f055ae --- /dev/null +++ b/.github/workflows/ci-p0-workflow-integrity.yml @@ -0,0 +1,131 @@ +name: CI P0 Workflow Integrity + +on: + push: + branches: [ main ] + paths: + - ".github/workflows/**" + - "scripts/audit-ci-workflows.py" + - "scripts/test-ci-workflows.py" + - "scripts/classify-ci-changes.py" + - "scripts/test-classify-ci-changes.py" + - "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" + - "scripts/write-ci-native-ctest-proof.py" + - "scripts/test-ci-native-ctest-proof.py" + - "scripts/compare-ci-native-shadow.py" + - "scripts/test-ci-native-shadow.py" + - "scripts/write-ci-installer-manifest.py" + - "scripts/verify-ci-installer-reuse.py" + - "scripts/test-ci-installer-reuse.py" + - "scripts/wait-ci-release-ready.py" + - "scripts/test-ci-release-ready.py" + - ".github/actions/resolve-ariec-engine/**" + - "evidence/ci-workflow-budget.json" + - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" + pull_request: + paths: + - ".github/workflows/**" + - "scripts/audit-ci-workflows.py" + - "scripts/test-ci-workflows.py" + - "scripts/classify-ci-changes.py" + - "scripts/test-classify-ci-changes.py" + - "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" + - "scripts/write-ci-native-ctest-proof.py" + - "scripts/test-ci-native-ctest-proof.py" + - "scripts/compare-ci-native-shadow.py" + - "scripts/test-ci-native-shadow.py" + - "scripts/write-ci-installer-manifest.py" + - "scripts/verify-ci-installer-reuse.py" + - "scripts/test-ci-installer-reuse.py" + - "scripts/wait-ci-release-ready.py" + - "scripts/test-ci-release-ready.py" + - ".github/actions/resolve-ariec-engine/**" + - "evidence/ci-workflow-budget.json" + - "docs/audits/CI_P0_WORKFLOW_BASELINE.md" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-p0-workflow-integrity-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + workflow-integrity: + name: Validate workflow structure and CI-sprawl budget + runs-on: ubuntu-latest + steps: + - name: Checkout exact candidate + uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Verify standard-library Python is available + run: python3 --version + + - name: Run offline CI workflow guard tests + run: python3 scripts/test-ci-workflows.py + + - name: Run conservative CI P1 classifier fixtures + run: python3 scripts/test-classify-ci-changes.py + + - 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: Run CI-P3J native CTest authority safety fixtures + run: python3 scripts/test-ci-native-ctest-proof.py + + - name: Run CI-P3J-C native shadow comparison fixtures + run: python3 scripts/test-ci-native-shadow.py + + - name: Run validated installer CI P3D safety fixtures + run: python3 scripts/test-ci-installer-reuse.py + + - name: Run release-ready CI P3G safety fixtures + run: python3 scripts/test-ci-release-ready.py + + - name: Validate tracked workflow topology and budget + run: | + python3 scripts/audit-ci-workflows.py \ + --root . \ + --budget evidence/ci-workflow-budget.json \ + --output "${RUNNER_TEMP}/ci-workflow-inventory.json" + + - name: Summarize CI topology + shell: bash + run: | + python3 - <<'PY' + import json + import os + from pathlib import Path + + report = json.loads( + (Path(os.environ["RUNNER_TEMP"]) / "ci-workflow-inventory.json").read_text() + ) + totals = report["totals"] + summary = Path(os.environ["GITHUB_STEP_SUMMARY"]) + with summary.open("a", encoding="utf-8") as handle: + handle.write("## CI-P0 workflow inventory\n\n") + handle.write(f"- workflow files: {totals['workflowFiles']}\n") + handle.write(f"- .NET restore/build/test: {totals['dotnetRestore']}/{totals['dotnetBuild']}/{totals['dotnetTest']}\n") + handle.write(f"- setup-dotnet uses: {totals['actionsSetupDotnet']}\n") + handle.write(f"- checkout uses: {totals['actionsCheckout']}\n") + handle.write(f"- workflows containing .NET build: {totals['workflowsWithDotnetBuild']}\n") + handle.write(f"- workflows containing .NET test: {totals['workflowsWithDotnetTest']}\n") + handle.write("\nNo structural or budget violation was detected.\n") + PY diff --git a/.github/workflows/ci-p1-shadow.yml b/.github/workflows/ci-p1-shadow.yml new file mode 100644 index 000000000..cb522daad --- /dev/null +++ b/.github/workflows/ci-p1-shadow.yml @@ -0,0 +1,87 @@ +name: CI P1 Advisory Classifier and Opt-in Shadow + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + paths: + - ".github/workflows/**" + - ".github/actions/**" + - "scripts/classify-ci-changes.py" + - "scripts/test-classify-ci-changes.py" + - "scripts/resolve-ariec-lock.ps1" + - "scripts/test-resolve-ariec-lock.ps1" + - "engines/**" + - "Services/**" + - "Models/**" + - "tests/**" + - "evidence/**" + - "engine-patches/**" + - "*.cs" + - "*.csproj" + - "*.sln" + - "*.xaml" + - "MainWindow*.cs" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-p1-shadow-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + classify: + name: Classify changed areas (advisory, fail-safe) + runs-on: ubuntu-latest + outputs: + build: ${{ steps.changed.outputs.build }} + tests: ${{ steps.changed.outputs.tests }} + iec: ${{ steps.changed.outputs.iec }} + sv: ${{ steps.changed.outputs.sv }} + io: ${{ steps.changed.outputs.io }} + packaging: ${{ steps.changed.outputs.packaging }} + ci: ${{ steps.changed.outputs.ci }} + risk: ${{ steps.changed.outputs.risk }} + steps: + - name: Checkout PR merge source + uses: actions/checkout@v7 + with: + fetch-depth: 2 + persist-credentials: false + + - name: Run conservative classifier fixtures + run: python3 scripts/test-classify-ci-changes.py + + - id: changed + name: Classify exact PR merge diff or conservative fallback + shell: bash + run: | + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + python3 scripts/classify-ci-changes.py \ + --from-pr-merge \ + --github-output "$GITHUB_OUTPUT" \ + --summary "$GITHUB_STEP_SUMMARY" + else + python3 scripts/classify-ci-changes.py \ + --paths "__unclassified_source_change__" \ + --github-output "$GITHUB_OUTPUT" \ + --summary "$GITHUB_STEP_SUMMARY" + fi + + windows-shadow: + name: Compare reusable build/test with legacy authority + needs: classify + if: >- + ${{ + (github.event_name == 'workflow_dispatch' || + github.head_ref == 'ci/p1-fast-gate-shadow' || + contains(github.event.pull_request.labels.*.name, 'ci-p1-shadow')) && + (needs.classify.outputs.build == 'true' || + github.head_ref == 'ci/p1-fast-gate-shadow') + }} + uses: ./.github/workflows/_ci-p1-windows-build-test.yml + with: + source_sha: ${{ github.sha }} + shadow_mode: true + secrets: {} diff --git a/.github/workflows/comtrade-viewer-integration.yml b/.github/workflows/comtrade-viewer-integration.yml index 06b070ed6..61a635ed0 100644 --- a/.github/workflows/comtrade-viewer-integration.yml +++ b/.github/workflows/comtrade-viewer-integration.yml @@ -18,6 +18,10 @@ on: - "tests/ARSAS.Tests/ArdIrecLocusNativeSessionIntegrationTests.cs" - "tests/ARSAS.Tests/Comtrade*.cs" - "scripts/build-ardirec-bridge.ps1" + - "scripts/write-ci-native-ctest-proof.py" + - "scripts/compare-ci-native-shadow.py" + - "scripts/test-ci-native-shadow.py" + - ".github/workflows/build.yml" - "scripts/publish-windows-portable.ps1" - "engines/ARDIREC.lock.json" - "docs/COMTRADE_VIEWER_INTEGRATION.md" @@ -26,6 +30,11 @@ on: permissions: contents: read + actions: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true jobs: windows-viewer-integration: @@ -68,6 +77,12 @@ jobs: } } + $engineLock = Get-Content ".\ARSAS\engines\ARIEC61850.lock.json" -Raw | ConvertFrom-Json + if ($engineLock.commit -notmatch '^[0-9a-f]{40}$') { + throw "Canonical shadow comparison needs an exact ARIEC61850 engine SHA." + } + "ARIEC61850_COMMIT=$($engineLock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "ARDIREC_REPOSITORY=$($lock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_REF=$($lock.ref)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append @@ -87,16 +102,19 @@ jobs: shell: powershell run: | $stage = Join-Path $env:RUNNER_TEMP "arsas-native-comtrade" + $nativeJunit = Join-Path $env:RUNNER_TEMP "comtrade-independent-ctest.xml" .\ARSAS\scripts\build-ardirec-bridge.ps1 ` -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` -OutputDirectory $stage ` - -BuildDirectory "$env:RUNNER_TEMP\ardirec-native-build" + -BuildDirectory "$env:RUNNER_TEMP\ardirec-native-build" ` + -CTestJunitPath $nativeJunit $bridge = Join-Path $stage "ardirec_bridge.dll" if (-not (Test-Path $bridge -PathType Leaf)) { throw "Pinned ArdIrec native bridge was not staged: $bridge" } "NATIVE_BRIDGE_PATH=$bridge" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "INDEPENDENT_CTEST_JUNIT=$nativeJunit" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Enforce field-tested native-only ARSAS COMTRADE routing shell: powershell @@ -144,3 +162,105 @@ jobs: } Write-Host "COMTRADE Open uses the field-tested in-process workstation with Phasor, Harmonics and Locus; no Qt/external ArdIrec process launch remains." + + if ($env:GITHUB_EVENT_NAME -eq 'pull_request') { + $nativeInputs = ".\ARSAS\TestResults\native-shadow-independent" + New-Item -ItemType Directory -Force -Path $nativeInputs | Out-Null + Copy-Item $env:INDEPENDENT_CTEST_JUNIT (Join-Path $nativeInputs "ctest.xml") -Force + Copy-Item $env:NATIVE_BRIDGE_PATH (Join-Path $nativeInputs "ardirec_bridge.dll") -Force + if (-not (Test-Path (Join-Path $nativeInputs "ctest.xml") -PathType Leaf) -or + -not (Test-Path (Join-Path $nativeInputs "ardirec_bridge.dll") -PathType Leaf)) { + throw "Independently tested COMTRADE native evidence could not be staged." + } + } + + + + - name: Upload independent tested COMTRADE native inputs for shadow comparison + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: ARSAS-independent-native-ctest-shadow + path: ARSAS/TestResults/native-shadow-independent/ + if-no-files-found: error + retention-days: 14 + + native-shadow-observer: + name: Advisory native byte and CTest parity (Ubuntu, no native rebuild) + if: github.event_name == 'pull_request' + needs: windows-viewer-integration + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout exact PR synthetic merge source + uses: actions/checkout@v7 + with: + path: ARSAS + persist-credentials: false + + - name: Download independently tested COMTRADE DLL and JUnit + uses: actions/download-artifact@v8 + with: + name: ARSAS-independent-native-ctest-shadow + path: native-input + + - name: Verify independently built native inputs and source locks + shell: bash + run: | + test -s native-input/ardirec_bridge.dll + test -s native-input/ctest.xml + python3 - <<'PY' + import json + from pathlib import Path + for name in ('ARIEC61850', 'ARDIREC'): + lock = json.loads(Path(f'ARSAS/engines/{name}.lock.json').read_text()) + value = lock.get('commit') + if not isinstance(value, str) or len(value) != 40 or any( + c not in '0123456789abcdef' for c in value + ): + raise SystemExit(f'Invalid exact {name} lock SHA') + PY + + - name: Advisory exact-SHA native comparison (never deduplication authority) + continue-on-error: true + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + ENGINE_SHA="$(python3 -c 'import json;print(json.load(open("ARSAS/engines/ARIEC61850.lock.json"))["commit"])')" + ARDIREC_SHA="$(python3 -c 'import json;print(json.load(open("ARSAS/engines/ARDIREC.lock.json"))["commit"])')" + set +e + python3 ARSAS/scripts/compare-ci-native-shadow.py \ + --head-sha "$PR_HEAD_SHA" \ + --head-branch "$PR_HEAD_BRANCH" \ + --source-sha "$GITHUB_SHA" \ + --engine-sha "$ENGINE_SHA" \ + --ardirec-sha "$ARDIREC_SHA" \ + --independent-junit native-input/ctest.xml \ + --independent-bridge native-input/ardirec_bridge.dll \ + --wait-seconds 600 \ + --require-completed-canonical \ + --output native-shadow-parity.json + verdict=$? + set -e + { + echo '### CI-P3J-D native parity observer (advisory only)' + if [ -f native-shadow-parity.json ]; then + python3 - <<'PY' + import json + p = json.load(open('native-shadow-parity.json')) + print(f"- Canonical run: {p['canonicalRunId']}/{p['canonicalRunAttempt']} ({p['canonicalRunStatus']})") + print(f"- Native CTest case inventory identical: {p['ctestInventoryEqual']}") + print(f"- Independently tested native DLL SHA-256 identical: {p['bridgeBytesEqual']}") + print(f"- Canonical completed-success: {p['fullCanonicalBuildPassed']}") + print(f"- Provisional: {p['provisional']}") + PY + else + echo '- Canonical parity unavailable; no native equivalence may be claimed' + fi + echo '- Independent Windows CMake/CTest and native-only route guards remain mandatory' + echo '- This observer does not authorize binary reuse or release promotion' + } >> "$GITHUB_STEP_SUMMARY" + exit "$verdict" diff --git a/.github/workflows/installer-windows.yml b/.github/workflows/installer-windows.yml index 16bac10f4..c0701d288 100644 --- a/.github/workflows/installer-windows.yml +++ b/.github/workflows/installer-windows.yml @@ -9,6 +9,13 @@ 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" + - "scripts/write-ci-installer-manifest.py" + - "scripts/verify-ci-installer-reuse.py" + - "scripts/test-ci-installer-reuse.py" + - ".github/workflows/build.yml" - ".github/workflows/installer-windows.yml" - "FaultRecordWindow.ComtradeOpen.cs" - "ComtradeWorkspaceWindow*" @@ -26,6 +33,7 @@ on: - "scripts/enable-smart-discovery-capture.ps1" - "tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs" - "VERSION" + - ".release/windows.json" - "engines/ARIEC61850.lock.json" - "engines/ARDIREC.lock.json" pull_request: @@ -35,6 +43,13 @@ 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" + - "scripts/write-ci-installer-manifest.py" + - "scripts/verify-ci-installer-reuse.py" + - "scripts/test-ci-installer-reuse.py" + - ".github/workflows/build.yml" - ".github/workflows/installer-windows.yml" - "FaultRecordWindow.ComtradeOpen.cs" - "ComtradeWorkspaceWindow*" @@ -52,24 +67,147 @@ on: - "scripts/enable-smart-discovery-capture.ps1" - "tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs" - "VERSION" + - ".release/windows.json" - "engines/ARIEC61850.lock.json" - "engines/ARDIREC.lock.json" workflow_dispatch: permissions: contents: read + actions: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true jobs: + package-ready: + name: Wait for exact canonical package metadata + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 25 + outputs: + canonical_run_id: ${{ steps.wait.outputs.canonical_run_id }} + canonical_run_attempt: ${{ steps.wait.outputs.canonical_run_attempt }} + package_artifact_id: ${{ steps.wait.outputs.package_artifact_id }} + steps: + - name: Wait for exact Build ARSAS package artifact metadata + id: wait + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + $ErrorActionPreference = 'Stop' + if (-not $env:GITHUB_TOKEN) { throw 'Read-only GitHub token is required for package-ready gating.' } + + if ($env:GITHUB_EVENT_NAME -eq 'pull_request') { + $eventName = 'pull_request' + $headBranch = $env:PR_HEAD_BRANCH + $headSha = $env:PR_HEAD_SHA + } + elseif ($env:GITHUB_EVENT_NAME -eq 'push') { + $eventName = 'push' + $headBranch = 'main' + $headSha = $env:GITHUB_SHA + } + else { throw "Unsupported package-ready event: $env:GITHUB_EVENT_NAME" } + + if ($headSha.Length -ne 40 -or $headSha -match '[^0-9a-f]' -or + [string]::IsNullOrWhiteSpace($headBranch) -or + $headBranch.Contains([Environment]::NewLine)) { + throw 'Invalid exact Build ARSAS identity for package-ready gate.' + } + + $headers = @{ + Authorization = "Bearer $env:GITHUB_TOKEN" + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + 'User-Agent' = 'ARSAS-CI-P3F-Package-Ready-Gate' + } + $base = "https://api.github.com/repos/$env:GITHUB_REPOSITORY" + $escapedHead = [uri]::EscapeDataString($headSha) + $escapedEvent = [uri]::EscapeDataString($eventName) + $runsUrl = "$base/actions/workflows/build.yml/runs?event=$escapedEvent&head_sha=$escapedHead&per_page=100" + $deadline = [DateTimeOffset]::UtcNow.AddMinutes(20) + $last = 'no matching Build ARSAS run yet' + $ready = $false + + while ([DateTimeOffset]::UtcNow -lt $deadline) { + $payload = Invoke-RestMethod -Uri $runsUrl -Headers $headers -Method Get + $matches = @($payload.workflow_runs | Where-Object { + $_.name -eq 'Build ARSAS' -and $_.event -eq $eventName -and + $_.head_sha -eq $headSha -and $_.head_branch -eq $headBranch -and + [long]$_.id -gt 0 -and [int]$_.run_attempt -gt 0 + } | Sort-Object @{Expression='id';Descending=$true}, @{Expression='run_attempt';Descending=$true}) + + if ($matches.Count -gt 0) { + $run = $matches[0] + $last = "$($run.id)/$($run.run_attempt) $($run.status)/$($run.conclusion)" + if ($run.status -eq 'completed' -and $run.conclusion -ne 'success') { + throw "Latest exact Build ARSAS run failed before package readiness: $last" + } + if ($run.status -in @('in_progress', 'completed')) { + $artifactUrl = "$base/actions/runs/$($run.id)/artifacts?per_page=100" + $artifactPayload = Invoke-RestMethod -Uri $artifactUrl -Headers $headers -Method Get + $artifacts = @($artifactPayload.artifacts | Where-Object { + $_.name -eq 'ARSAS-windows-package-input' -and + -not [bool]$_.expired -and $_.workflow_run.id -eq $run.id + }) + if ($artifacts.Count -gt 1) { throw 'Package-ready gate found duplicate canonical package artifacts.' } + if ($artifacts.Count -eq 1) { + $artifact = $artifacts[0] + "canonical_run_id=$($run.id)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "canonical_run_attempt=$($run.run_attempt)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "package_artifact_id=$($artifact.id)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + @( + '## CI-P3F canonical package-ready gate', + '', + "- event/head: $eventName / $headBranch @ $headSha", + "- Build ARSAS run: $($run.id)/$($run.run_attempt)", + "- package artifact id: $($artifact.id)", + '- authority: scheduling metadata only; package bytes remain untrusted until Windows verifier completes' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + $ready = $true + break + } + $last = "$last (canonical package metadata not uploaded yet)" + } + } + Start-Sleep -Seconds 10 + } + if (-not $ready) { throw "Timed out waiting for exact canonical package metadata. Last=$last" } + installer: name: Build install and field-runtime smoke test + needs: [package-ready] + if: >- + ${{ + always() && + (github.event_name == 'workflow_dispatch' || + needs.package-ready.result == 'success') + }} runs-on: windows-latest 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 +244,103 @@ 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 and installer verifier safety fixtures + if: github.event_name != 'workflow_dispatch' + run: | + python .\ArIED61850Tester\scripts\test-ci-package-reuse.py + if ($LASTEXITCODE -ne 0) { throw 'Canonical package verifier fixtures failed.' } + python .\ArIED61850Tester\scripts\test-ci-installer-reuse.py + if ($LASTEXITCODE -ne 0) { throw 'Validated installer verifier fixtures failed.' } + + - 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 ` + --require-native-ctest-proof ` + --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)", + "- package-ready gate: ${{ needs.package-ready.outputs.canonical_run_id }}/${{ needs.package-ready.outputs.canonical_run_attempt }}", + "- 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 +349,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 +359,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 +374,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 +385,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 +396,44 @@ 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 + "CANONICAL_PACKAGE_ARTIFACT_SHA256=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 +471,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" @@ -253,6 +492,25 @@ jobs: -Runtime win-x64 ` -DistDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist" + - name: Seal validated installer authority + shell: powershell + run: | + $installer = ".\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64-setup.exe" + $manifest = ".\ArIED61850Tester\dist\ci-installer-authority.json" + python .\ArIED61850Tester\scripts\write-ci-installer-manifest.py ` + --installer $installer ` + --output $manifest ` + --version $env:APP_VERSION ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --package-run $env:CANONICAL_PACKAGE_RUN ` + --package-artifact-sha256 $env:CANONICAL_PACKAGE_ARTIFACT_SHA256 ` + --workflow-run-id $env:GITHUB_RUN_ID ` + --run-attempt $env:GITHUB_RUN_ATTEMPT ` + --event-name $env:GITHUB_EVENT_NAME + if ($LASTEXITCODE -ne 0) { throw 'Validated installer authority manifest could not be sealed.' } + - name: Upload installer artifact uses: actions/upload-artifact@v7 with: @@ -261,3 +519,4 @@ jobs: path: | ArIED61850Tester/dist/ARSAS-${{ env.APP_VERSION }}-win-x64-setup.exe ArIED61850Tester/dist/SHA256SUMS.txt + ArIED61850Tester/dist/ci-installer-authority.json diff --git a/.github/workflows/interoperability-reference-guard.yml b/.github/workflows/interoperability-reference-guard.yml index ed1313fb5..709c3f377 100644 --- a/.github/workflows/interoperability-reference-guard.yml +++ b/.github/workflows/interoperability-reference-guard.yml @@ -18,7 +18,7 @@ on: workflow_dispatch: concurrency: - group: interoperability-reference-${{ github.event.pull_request.number || github.ref }} + group: interoperability-reference-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} cancel-in-progress: true jobs: @@ -84,6 +84,8 @@ jobs: -not [bool]$association.physicalAcceptance.actualInformationReportObserved) { throw 'Accepted post-baseline SCL association authority drifted or lost physical evidence.' } + . .\ARSAS\scripts\test-p10-9f-core-routing-trial.ps1 + $coreRoutingTrialValid = Test-P109FCoreRoutingTrial -Lock $lock $trial = $lock.smartInteroperabilityCodeTrial $serverAt = $lock.sclServerAtTrial $originalTrial = $lock.commit -eq $trial.exactCommit -and @@ -101,6 +103,22 @@ jobs: [bool]$closure.physicalQualificationRequired -and [bool]$closure.fullNameListFallbackForbidden -and [bool]$closure.cyclicMmsProcessPollingForbidden + $p10 = $lock.p10IdentityTrial + $p10Trial = $null -ne $p10 -and + $p10.status -eq 'code-trial-not-physical' -and + [int]$p10.enginePullRequest -eq 161 -and + [int]$lock.sourcePullRequest -eq 161 -and + $lock.commit -eq '00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1' -and + $p10.exactCommit -eq $lock.commit -and + $p10.baseEngineCommit -eq $closure.exactCommit -and + $p10.identitySource -eq 'TrustedSclExactDomainMatch' -and + $lock.ref -eq 'main' -and + [bool]$p10.physicalQualificationRequired -and + [bool]$p10.fullNameListFallbackForbidden -and + [bool]$p10.cyclicMmsProcessPollingForbidden -and + [bool]$closure.physicalQualificationRequired -and + [bool]$closure.fullNameListFallbackForbidden -and + [bool]$closure.cyclicMmsProcessPollingForbidden $serverAtTrial = $null -ne $serverAt -and $lock.commit -eq $serverAt.exactCommit -and $serverAt.exactCommit -eq '352c81e6a798635c6addcee0683235ca87ad416d' -and @@ -115,7 +133,7 @@ jobs: if ($trial.status -ne 'code-verified-not-physical' -or [int]$trial.issue -ne 144 -or [int]$trial.integrationPullRequest -ne 148 -or - -not ($originalTrial -or $serverAtTrial -or $datasetTrial) -or + -not ($originalTrial -or $serverAtTrial -or $datasetTrial -or $p10Trial -or $coreRoutingTrialValid) -or -not [bool]$trial.fullDiscoveryFallbackForbidden -or -not [bool]$trial.vendorBranchingForbidden -or -not [bool]$trial.physicalPromotionRequired) { diff --git a/.github/workflows/rcb-export-guard.yml b/.github/workflows/rcb-export-guard.yml index 6adca0000..b4349d2e8 100644 --- a/.github/workflows/rcb-export-guard.yml +++ b/.github/workflows/rcb-export-guard.yml @@ -32,6 +32,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: verify: name: Verify selected-RCB CID workflow diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 25c643863..cf981206a 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -27,20 +27,141 @@ on: permissions: contents: write + actions: read id-token: write attestations: write jobs: + release-ready: + name: Wait for exact package and validated installer metadata + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 50 + permissions: + contents: read + actions: read + outputs: + version: ${{ steps.ready.outputs.version }} + engine_sha: ${{ steps.ready.outputs.engine_sha }} + ardirec_sha: ${{ steps.ready.outputs.ardirec_sha }} + build_run_id: ${{ steps.ready.outputs.build_run_id }} + package_artifact_id: ${{ steps.ready.outputs.package_artifact_id }} + installer_run_id: ${{ steps.ready.outputs.installer_run_id }} + installer_artifact_id: ${{ steps.ready.outputs.installer_artifact_id }} + steps: + - name: Checkout exact release source for metadata gating + uses: actions/checkout@v7 + with: + path: ArIED61850Tester + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Setup Python for release-ready gate + uses: actions/setup-python@v7 + with: + python-version: '3.12' + + - name: Run offline release-ready and installer-verifier fixtures + run: | + python .\ArIED61850Tester\scripts\test-ci-release-ready.py + if ($LASTEXITCODE -ne 0) { throw 'Release-ready metadata gate fixtures failed.' } + python .\ArIED61850Tester\scripts\test-ci-installer-reuse.py + if ($LASTEXITCODE -ne 0) { throw 'Validated-installer verifier fixtures failed.' } + + - name: Wait for exact immutable release dependencies + id: ready + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $proofPath = Join-Path $env:RUNNER_TEMP 'arsas-release-ready.json' + python .\ArIED61850Tester\scripts\wait-ci-release-ready.py ` + --repository $env:GITHUB_REPOSITORY ` + --source-dir .\ArIED61850Tester ` + --source-sha $env:GITHUB_SHA ` + --ref-type $env:GITHUB_REF_TYPE ` + --ref-name $env:GITHUB_REF_NAME ` + --wait-seconds 2700 ` + --output $proofPath + if ($LASTEXITCODE -ne 0) { + throw 'Release dependencies did not become metadata-ready within the bounded gate.' + } + + $proof = Get-Content $proofPath -Raw | ConvertFrom-Json + if (-not [bool]$proof.metadataReady -or + [bool]$proof.releasePromotionAuthority -or + $proof.sourceSha -ne $env:GITHUB_SHA) { + throw 'Release-ready metadata proof is incomplete or overclaims authority.' + } + + "version=$($proof.version)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "engine_sha=$($proof.engineSha)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "ardirec_sha=$($proof.ardirecSha)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "build_run_id=$($proof.buildRunId)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "package_artifact_id=$($proof.packageArtifactId)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "installer_run_id=$($proof.installerRunId)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "installer_artifact_id=$($proof.installerArtifactId)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + + @( + '## CI-P3G release-ready scheduling gate', + '', + "- source: $($proof.sourceSha)", + "- version: $($proof.version)", + "- engine: $($proof.engineSha)", + "- ArdIrec: $($proof.ardirecSha)", + "- canonical Build ARSAS: $($proof.buildRunId)/$($proof.buildRunAttempt)", + "- package artifact id: $($proof.packageArtifactId)", + "- validated installer: $($proof.installerRunId)/$($proof.installerRunAttempt)", + "- installer artifact id: $($proof.installerArtifactId)", + '- authority: scheduling metadata only; Windows release revalidates all artifact bytes' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + windows-release: name: Build, test and release ARSAS Windows packages + needs: [release-ready] + if: >- + ${{ + always() && + (github.event_name == 'workflow_dispatch' || + needs.release-ready.result == 'success') + }} runs-on: windows-latest steps: - - name: Checkout ARSAS application + - name: Checkout exact ARSAS release source uses: actions/checkout@v7 with: path: ArIED61850Tester + ref: ${{ github.sha }} fetch-depth: 0 + persist-credentials: false + + - name: Verify exact release 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 "Release source mismatch. Expected $expected, checked out $actual." + } + "RELEASE_SOURCE_COMMIT=$actual" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Record release-ready scheduling metadata + if: github.event_name != 'workflow_dispatch' + shell: powershell + run: | + @( + '## CI-P3G Windows runner allocation', + '', + '- Windows release runner started only after metadata-ready gate', + "- gate version: ${{ needs.release-ready.outputs.version }}", + "- gate engine: ${{ needs.release-ready.outputs.engine_sha }}", + "- gate ArdIrec: ${{ needs.release-ready.outputs.ardirec_sha }}", + "- canonical Build run: ${{ needs.release-ready.outputs.build_run_id }}", + "- validated installer run: ${{ needs.release-ready.outputs.installer_run_id }}", + '- authority: diagnostic scheduling metadata only' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - name: Resolve release identity and immutable engine locks id: release @@ -128,7 +249,8 @@ jobs: "ARDIREC_BRIDGE_ABI=$($ardirecLock.bridge.abi)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_BRIDGE_RELATIVE_LIBRARY=$($ardirecLock.bridge.relativeLibrary)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - name: Checkout immutable ARIEC61850 engine revision + - name: Checkout immutable ARIEC61850 engine revision for manual fallback + 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 @@ -139,7 +261,8 @@ jobs: throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." } - - name: Checkout immutable ArdIrec native engine revision + - name: Checkout immutable ArdIrec native engine revision for manual fallback + 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 @@ -185,7 +308,112 @@ jobs: } } - - name: Restore, build and test exact release source + - name: Run offline sealed-package safety fixtures + if: github.event_name != 'workflow_dispatch' + shell: powershell + run: | + python .\ArIED61850Tester\scripts\test-ci-package-reuse.py + if ($LASTEXITCODE -ne 0) { throw 'Canonical package verifier fixtures failed.' } + python .\ArIED61850Tester\scripts\test-ci-installer-reuse.py + if ($LASTEXITCODE -ne 0) { throw 'Validated installer verifier fixtures failed.' } + + - name: Promote exact sealed canonical Windows package for release + if: github.event_name != 'workflow_dispatch' + shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $packageRoot = Join-Path $env:RUNNER_TEMP 'arsas-release-canonical-package' + $proofPath = Join-Path $env:RUNNER_TEMP 'arsas-release-canonical-package-proof.json' + python .\ArIED61850Tester\scripts\verify-ci-package-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --head-branch main ` + --head-sha $env:GITHUB_SHA ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --event-name push ` + --wait-seconds 1200 ` + --allow-in-progress-artifact ` + --output-dir $packageRoot ` + --proof-output $proofPath + if ($LASTEXITCODE -ne 0) { + throw 'Production release requires the exact sealed Build ARSAS package for this main commit.' + } + + $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:RELEASE_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 package identity does not match release authority.' + } + + $installerInput = Join-Path $packageRoot ($proof.installerInputRoot -replace '/', '\') + $portable = Join-Path $packageRoot ($proof.portablePath -replace '/', '\') + $testAssembly = Join-Path $packageRoot ($proof.testAssemblyPath -replace '/', '\') + $releaseFixture = Join-Path $packageRoot ($proof.releaseFixturePath -replace '/', '\') + $locusFixture = Join-Path $packageRoot ($proof.locusFixturePath -replace '/', '\') + foreach ($required in @($installerInput, $portable, $testAssembly, $releaseFixture, $locusFixture)) { + if (-not (Test-Path $required)) { + throw "Verified release package materialization is incomplete: $required" + } + } + + $dist = Join-Path $env:GITHUB_WORKSPACE 'ArIED61850Tester\dist' + New-Item -ItemType Directory -Force $dist | Out-Null + $releaseInput = Join-Path $dist "ARSAS-$env:RELEASE_VERSION-win-x64" + if (Test-Path $releaseInput) { Remove-Item $releaseInput -Recurse -Force } + Copy-Item $installerInput $releaseInput -Recurse -Force + + $releasePortable = Join-Path $dist "ARSAS-$env:RELEASE_VERSION-win-x64-portable.exe" + Copy-Item $portable $releasePortable -Force + $portableSha = (Get-FileHash $releasePortable -Algorithm SHA256).Hash.ToLowerInvariant() + if ($portableSha -ne $proof.portableSha256 -or + [long](Get-Item $releasePortable).Length -ne [long]$proof.portableSizeBytes) { + throw 'Promoted release portable bytes differ from the sealed package proof.' + } + + $bridge = Join-Path $releaseInput 'Tools\ArdIrec\ardirec_bridge.dll' + $bridgeSha = (Get-FileHash $bridge -Algorithm SHA256).Hash.ToLowerInvariant() + if ($bridgeSha -ne $proof.nativeBridgeSha256) { + throw 'Promoted release native bridge differs from the sealed package proof.' + } + + $evidenceDir = '.\ArIED61850Tester\TestResults\release-promoted-evidence' + New-Item -ItemType Directory -Force $evidenceDir | Out-Null + Copy-Item (Join-Path $packageRoot 'evidence\arsas-tests.trx') (Join-Path $evidenceDir 'arsas-tests.trx') -Force + Copy-Item $proofPath (Join-Path $evidenceDir 'ci-release-package-proof.json') -Force + + "RELEASE_INSTALLER_INPUT_DIR=$releaseInput" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PORTABLE_PATH=$releasePortable" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_TEST_ASSEMBLY=$testAssembly" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_COMTRADE_FIXTURE=$releaseFixture" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_LOCUS_FIXTURE=$locusFixture" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PACKAGE_AUTHORITY=sealed-build-arsas:$($proof.canonicalRunId)/$($proof.canonicalRunAttempt)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PACKAGE_RUN=$($proof.canonicalRunId)/$($proof.canonicalRunAttempt)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PACKAGE_ARTIFACT_SHA256=$($proof.artifactSha256)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_CANONICAL_PORTABLE_SHA256=$($proof.portableSha256)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + @( + '## CI-P3C exact-byte release promotion', + '', + "- source: $env:GITHUB_SHA", + "- engine: $env:ARIEC61850_COMMIT", + "- ArdIrec: $env:ARDIREC_COMMIT", + "- Build ARSAS run: $($proof.canonicalRunId)/$($proof.canonicalRunAttempt)", + "- canonical regression: $($proof.passed) passed / $($proof.failed) failed", + "- portable SHA-256: $($proof.portableSha256)", + "- sealed artifact SHA-256: $($proof.artifactSha256)", + '- release does not rebuild application bytes' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: Restore, build and test exact release source for manual fallback + if: github.event_name == 'workflow_dispatch' shell: powershell run: | dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln @@ -200,15 +428,8 @@ jobs: --results-directory .\ArIED61850Tester\TestResults if ($LASTEXITCODE -ne 0) { throw "Release application regression tests failed." } - - name: Upload release test evidence - if: always() - uses: actions/upload-artifact@v7 - with: - name: ARSAS-${{ steps.release.outputs.version }}-release-tests - path: ArIED61850Tester/TestResults/*.trx - if-no-files-found: error - - - name: Publish installer source folder with pinned native bridge + - name: Publish installer source folder for manual fallback + if: github.event_name == 'workflow_dispatch' shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -220,26 +441,33 @@ jobs: -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" ` -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" + "RELEASE_INSTALLER_INPUT_DIR=$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_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 + "RELEASE_COMTRADE_FIXTURE=$env:GITHUB_WORKSPACE\ArIED61850Tester\tests\fixtures\comtrade\p1-release-smoke.cfg" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_LOCUS_FIXTURE=$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PACKAGE_AUTHORITY=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PACKAGE_RUN=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_PACKAGE_ARTIFACT_SHA256=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + - name: Exercise managed native bridge before release packaging shell: powershell run: | - $publish = "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64" - $bridge = Join-Path $publish "Tools\ArdIrec\ardirec_bridge.dll" - $fixture = "$env:GITHUB_WORKSPACE\ArIED61850Tester\tests\fixtures\comtrade\p1-release-smoke.cfg" - $locusFixture = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" - if (-not (Test-Path $bridge -PathType Leaf)) { throw "Native bridge was not published: $bridge" } - if (-not (Test-Path $fixture -PathType Leaf)) { throw "ARSAS native release fixture was not found: $fixture" } - if (-not (Test-Path $locusFixture -PathType Leaf)) { throw "Pinned ArdIrec locus fixture was not found: $locusFixture" } + $bridge = Join-Path $env:RELEASE_INSTALLER_INPUT_DIR "Tools\ArdIrec\ardirec_bridge.dll" + foreach ($required in @($bridge, $env:RELEASE_TEST_ASSEMBLY, $env:RELEASE_COMTRADE_FIXTURE, $env:RELEASE_LOCUS_FIXTURE)) { + if (-not (Test-Path $required -PathType Leaf)) { + throw "Release native verification prerequisite is missing: $required" + } + } $env:ARSAS_ARDIREC_BRIDGE_PATH = $bridge - $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $fixture - $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $locusFixture - 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:RELEASE_COMTRADE_FIXTURE + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $env:RELEASE_LOCUS_FIXTURE + & dotnet vstest $env:RELEASE_TEST_ASSEMBLY ` + "--TestCaseFilter:FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" if ($LASTEXITCODE -ne 0) { throw "Release managed ArdIrec bridge/locus integration test failed." } - - name: Publish real portable single EXE with embedded native bridge + - name: Publish real portable single EXE for manual fallback + if: github.event_name == 'workflow_dispatch' shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -250,12 +478,50 @@ jobs: -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" ` -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" + "RELEASE_PORTABLE_PATH=$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64-portable.exe" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + $manualPortableSha = (Get-FileHash $env:RELEASE_PORTABLE_PATH -Algorithm SHA256).Hash.ToLowerInvariant() + "RELEASE_CANONICAL_PORTABLE_SHA256=$manualPortableSha" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Stage unified release regression evidence + if: always() + shell: powershell + run: | + $evidenceDir = '.\ArIED61850Tester\TestResults\release-promoted-evidence' + New-Item -ItemType Directory -Force $evidenceDir | Out-Null + if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch') { + $trx = @(Get-ChildItem '.\ArIED61850Tester\TestResults\*.trx' -File -ErrorAction SilentlyContinue) + if ($trx.Count -lt 1) { + throw 'Manual release regression TRX is missing.' + } + foreach ($file in $trx) { + Copy-Item $file.FullName (Join-Path $evidenceDir $file.Name) -Force + } + [ordered]@{ + schemaVersion = 1 + authority = 'manual-local-build' + sourceCommit = $env:GITHUB_SHA + engineCommit = $env:ARIEC61850_COMMIT + ardIrecCommit = $env:ARDIREC_COMMIT + } | ConvertTo-Json | Set-Content (Join-Path $evidenceDir 'ci-release-package-proof.json') -Encoding utf8 + } - - name: Smoke-test real portable single EXE + - name: Upload release regression evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: ARSAS-${{ steps.release.outputs.version }}-release-tests + path: ArIED61850Tester/TestResults/release-promoted-evidence/* + if-no-files-found: error + + - name: Smoke-test exact release portable single EXE shell: powershell run: | - $exe = ".\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64-portable.exe" - if (-not (Test-Path $exe -PathType Leaf)) { throw "Portable single EXE was not produced: $exe" } + $exe = $env:RELEASE_PORTABLE_PATH + if (-not (Test-Path $exe -PathType Leaf)) { throw "Release portable single EXE is missing: $exe" } + $actualSha = (Get-FileHash $exe -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualSha -ne $env:RELEASE_CANONICAL_PORTABLE_SHA256) { + throw 'Release portable bytes changed after package promotion.' + } $env:DOTNET_BUNDLE_EXTRACT_BASE_DIR = Join-Path $env:RUNNER_TEMP "ARSAS-release-bundle-cache" $diagnostic = Join-Path ([System.IO.Path]::GetTempPath()) "ARSAS-portable-smoke-error.txt" Remove-Item $diagnostic -Force -ErrorAction SilentlyContinue @@ -269,18 +535,84 @@ jobs: throw "Portable single EXE smoke test failed with exit code $($process.ExitCode)." } - - name: Install Inno Setup compiler + - name: Promote exact validated Windows installer for release + if: github.event_name != 'workflow_dispatch' + shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $validated = Join-Path $env:RUNNER_TEMP 'arsas-validated-installer' + $proofPath = Join-Path $env:RUNNER_TEMP 'arsas-validated-installer-proof.json' + python .\ArIED61850Tester\scripts\verify-ci-installer-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --source-sha $env:GITHUB_SHA ` + --version $env:RELEASE_VERSION ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --package-artifact-sha256 $env:RELEASE_PACKAGE_ARTIFACT_SHA256 ` + --wait-seconds 1800 ` + --output-dir $validated ` + --proof-output $proofPath + if ($LASTEXITCODE -ne 0) { + throw 'Production release requires the exact installed-smoke-tested installer for this main commit.' + } + + $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:RELEASE_VERSION -or + $proof.canonicalPackageArtifactSha256 -ne $env:RELEASE_PACKAGE_ARTIFACT_SHA256 -or + -not [bool]$proof.installedSmokePassed -or + [bool]$proof.releasePromotionAuthority) { + throw 'Validated installer authority does not match release identity.' + } + + $source = Join-Path $validated $proof.installerFile + $target = ".\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64-setup.exe" + Copy-Item $source $target -Force + $actual = (Get-FileHash $target -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $proof.installerSha256 -or + [long](Get-Item $target).Length -ne [long]$proof.installerSizeBytes) { + throw 'Promoted installer bytes differ from validated installer proof.' + } + + "RELEASE_INSTALLER_AUTHORITY=validated-installer:$($proof.installerRunId)/$($proof.installerRunAttempt)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_INSTALLER_SHA256=$($proof.installerSha256)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_INSTALLER_ARTIFACT_SHA256=$($proof.artifactSha256)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + @( + '## CI-P3D exact installer promotion', + '', + "- source: $env:GITHUB_SHA", + "- canonical package: $env:RELEASE_PACKAGE_RUN", + "- validated installer: $($proof.installerRunId)/$($proof.installerRunAttempt)", + "- installer SHA-256: $($proof.installerSha256)", + '- release-specific install/runtime smoke remains required' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: Install Inno Setup compiler for manual fallback + if: github.event_name == 'workflow_dispatch' shell: powershell run: choco install innosetup --no-progress --yes - - name: Build Windows installer + - name: Build Windows installer for manual fallback + if: github.event_name == 'workflow_dispatch' shell: powershell run: | .\ArIED61850Tester\scripts\build-windows-installer.ps1 ` -Version $env:RELEASE_VERSION ` -Runtime win-x64 ` - -PublishedDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64" ` - -OutputDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist" + -PublishedDirectory $env:RELEASE_INSTALLER_INPUT_DIR ` + -OutputDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist" ` + -TestAssemblyPath $env:RELEASE_TEST_ASSEMBLY ` + -FixtureCfg $env:RELEASE_COMTRADE_FIXTURE ` + -LocusFixtureCfg $env:RELEASE_LOCUS_FIXTURE + $installer = ".\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64-setup.exe" + $sha = (Get-FileHash $installer -Algorithm SHA256).Hash.ToLowerInvariant() + "RELEASE_INSTALLER_AUTHORITY=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_INSTALLER_SHA256=$sha" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_INSTALLER_ARTIFACT_SHA256=manual-local-build" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - name: Smoke-test silent installer and uninstaller shell: powershell @@ -330,6 +662,15 @@ jobs: } } + $env:ARSAS_ARDIREC_BRIDGE_PATH = Join-Path $installRoot "Tools\ArdIrec\ardirec_bridge.dll" + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $env:RELEASE_COMTRADE_FIXTURE + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $env:RELEASE_LOCUS_FIXTURE + & dotnet vstest $env:RELEASE_TEST_ASSEMBLY ` + "--TestCaseFilter:FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + if ($LASTEXITCODE -ne 0) { + throw "Installed release bridge/locus runtime validation failed." + } + $uninstaller = Join-Path $installRoot "unins000.exe" if (-not (Test-Path $uninstaller -PathType Leaf)) { throw "Installer did not create an uninstaller." } $uninstall = Start-Process -FilePath $uninstaller -ArgumentList @("/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART") -Wait -PassThru @@ -388,6 +729,13 @@ jobs: comtradeViewerCommit = $env:ARDIREC_COMMIT comtradeBridgeAbi = [int]$env:ARDIREC_BRIDGE_ABI comtradeBridgeRelativeLibrary = $env:ARDIREC_BRIDGE_RELATIVE_LIBRARY + canonicalPackageAuthority = $env:RELEASE_PACKAGE_AUTHORITY + canonicalPackageRun = $env:RELEASE_PACKAGE_RUN + canonicalPackageArtifactSha256 = $env:RELEASE_PACKAGE_ARTIFACT_SHA256 + canonicalPortableSha256 = $env:RELEASE_CANONICAL_PORTABLE_SHA256 + validatedInstallerAuthority = $env:RELEASE_INSTALLER_AUTHORITY + validatedInstallerArtifactSha256 = $env:RELEASE_INSTALLER_ARTIFACT_SHA256 + validatedInstallerSha256 = $env:RELEASE_INSTALLER_SHA256 workflow = "release-windows.yml" } $evidence | ConvertTo-Json -Depth 4 | Set-Content ` @@ -416,7 +764,14 @@ jobs: $provenance.engineCommit -ne $env:ARIEC61850_COMMIT -or $provenance.comtradeViewerCommit -ne $env:ARDIREC_COMMIT -or $provenance.comtradeBridgeAbi -ne 1 -or - $provenance.comtradeBridgeRelativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll') { + $provenance.comtradeBridgeRelativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll' -or + $provenance.canonicalPackageAuthority -ne $env:RELEASE_PACKAGE_AUTHORITY -or + $provenance.canonicalPackageRun -ne $env:RELEASE_PACKAGE_RUN -or + $provenance.canonicalPackageArtifactSha256 -ne $env:RELEASE_PACKAGE_ARTIFACT_SHA256 -or + $provenance.canonicalPortableSha256 -ne $env:RELEASE_CANONICAL_PORTABLE_SHA256 -or + $provenance.validatedInstallerAuthority -ne $env:RELEASE_INSTALLER_AUTHORITY -or + $provenance.validatedInstallerArtifactSha256 -ne $env:RELEASE_INSTALLER_ARTIFACT_SHA256 -or + $provenance.validatedInstallerSha256 -ne $env:RELEASE_INSTALLER_SHA256) { throw "Release provenance does not match the tested app, IEC engine and native COMTRADE bridge revisions." } diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 45117d35c..9b69feac8 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -22,7 +22,7 @@ on: workflow_dispatch: concurrency: - group: scl-interoperability-r7-${{ github.event.pull_request.number || github.ref }} + group: scl-interoperability-r7-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} cancel-in-progress: true jobs: @@ -81,6 +81,8 @@ jobs: throw 'Physical SCL-association/R10 provenance is not intact.' } + . .\ARSAS\scripts\test-p10-9f-core-routing-trial.ps1 + $coreRoutingTrialValid = Test-P109FCoreRoutingTrial -Lock $lock $trial = $lock.smartInteroperabilityCodeTrial if ($null -ne $trial -and $lock.commit -eq $trial.exactCommit) { if ([int]$lock.sourcePullRequest -ne 148 -or @@ -127,6 +129,28 @@ jobs: } Write-Host 'DataSet-first closure trial qualified for CI only; no physical promotion.' } + elseif ($null -ne $lock.p10IdentityTrial -and + $lock.commit -eq $lock.p10IdentityTrial.exactCommit) { + $p10 = $lock.p10IdentityTrial + $closure = $lock.datasetFirstDiscoveryTrial + if ($lock.ref -ne 'main' -or + [int]$lock.sourcePullRequest -ne 161 -or + [int]$p10.enginePullRequest -ne 161 -or + $p10.baseEngineCommit -ne $closure.exactCommit -or + $p10.exactCommit -ne '00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1' -or + $p10.status -ne 'code-trial-not-physical' -or + -not [bool]$p10.physicalQualificationRequired -or + -not [bool]$p10.fullNameListFallbackForbidden -or + -not [bool]$p10.cyclicMmsProcessPollingForbidden -or + $p10.identitySource -ne 'TrustedSclExactDomainMatch' -or + $closure.status -ne 'code-trial-not-physical') { + throw 'P10 identity trial is not pinned to exact qualified DataSet-first ancestry.' + } + Write-Host 'P10 trusted-SCL identity trial accepted for code CI ONLY; R7/R10 physical gate unchanged.' + } + elseif ($coreRoutingTrialValid) { + Write-Host 'P10.9F routing core accepted for CodeVerified staging CI ONLY; R7/R10 physical authority unchanged.' + } elseif ($lock.commit -eq $convergence.activeStack.engineModelAndScl.head) { if ([int]$lock.sourcePullRequest -notin @(134, 135) -or $lock.purpose -notmatch 'R7 physical SCL repair pin') { diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 7c1b4a685..95fe9fae7 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -34,8 +34,32 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +permissions: + contents: read + actions: read + +concurrency: + group: smart-discovery-field-capture-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: + manual-regression: + name: Manual exact-SHA full regression before Field Capture packaging + if: github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/_ci-p1-windows-build-test.yml + with: + source_sha: ${{ github.sha }} + shadow_mode: true + secrets: {} + build-smart-capture: + needs: [manual-regression] + if: >- + ${{ + always() && + (github.event_name != 'workflow_dispatch' || + needs.manual-regression.result == 'success') + }} name: Build smart-discovery portable field capture runs-on: windows-latest steps: @@ -70,6 +94,12 @@ jobs: throw "Unexpected engine commit (physical baseline): $baselineEngineCommit" } $integrationEngineCommit = $lock.commit + $ardirecLock = Get-Content .\ArIED61850Tester\engines\ARDIREC.lock.json -Raw | ConvertFrom-Json + if ($ardirecLock.commit -notmatch '^[0-9a-f]{40}$' -or + $ardirecLock.bridge.abi -ne 1 -or + $ardirecLock.bridge.mode -ne 'native-only') { + throw 'Invalid ArdIrec Field Capture pin.' + } $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim() if ($arsasCommit -notmatch '^[0-9a-f]{40}$') { throw "Invalid cloned ARSAS commit: $arsasCommit" @@ -77,6 +107,7 @@ jobs: "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "ARIEC61850_COMMIT=$integrationEngineCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "ARIEC61850_BASELINE_COMMIT=$baselineEngineCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARDIREC_COMMIT=$($ardirecLock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append "ARSAS_COMMIT=$arsasCommit" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append $version = (Get-Content .\ArIED61850Tester\VERSION -Raw).Trim() "ARSAS_VERSION=$version" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append @@ -242,23 +273,12 @@ jobs: throw 'Pinned P0-5b engine does not expose the required smart discovery, hierarchy-budget, and authoritative-Control invariants.' } - - name: Setup .NET 8 - uses: actions/setup-dotnet@v6 - with: - dotnet-version: 8.0.x - - - name: Restore - run: dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln - - - name: Build Release - run: dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore - - - name: Verify smart route and association lifecycle resets were installed + - name: Verify smart route and association lifecycle resets before packaging shell: powershell run: | $native = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.cs -Raw if ($native -notmatch 'return await DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { - throw 'Build-time smart discovery route was not installed.' + throw 'Field Capture smart discovery route is not installed.' } if ($native -notmatch '_lastDiscovery = null;\s*_liveModel = null;\s*ResetSmartDiscoveryAuthority\(\);\s*// __P0_5C_CONNECT_RESET__') { throw 'P0-5c ConnectAsync association-generation reset was not installed.' @@ -267,13 +287,110 @@ jobs: throw 'P0-5c DisposeAsync association-generation reset was not installed.' } if ($native -notmatch 'service\.OpenAsync\(_session, signal\.ObjectReference, _lastDiscovery\.Snapshot\.DomainVariables, cancellationToken\)') { - throw 'Build-time Control path does not consume the authoritative smart domain inventory.' + throw 'Field Capture Control path does not consume the authoritative smart domain inventory.' + } + + - name: Setup Python for sealed canonical package verification + if: github.event_name == 'pull_request' + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Run offline sealed-package safety fixtures + if: github.event_name == 'pull_request' + run: python .\ArIED61850Tester\scripts\test-ci-package-reuse.py + + - name: Promote exact sealed canonical portable + if: github.event_name == 'pull_request' + 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: | + $packageRoot = '.\ArIED61850Tester\TestResults\smart-capture-canonical-package' + $proofPath = '.\ArIED61850Tester\TestResults\ci-smart-capture-canonical-proof.json' + python .\ArIED61850Tester\scripts\verify-ci-package-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --head-branch $env:PR_HEAD_BRANCH ` + --head-sha $env:PR_HEAD_SHA ` + --source-sha $env:GITHUB_SHA ` + --engine-sha $env:ARIEC61850_COMMIT ` + --ardirec-sha $env:ARDIREC_COMMIT ` + --event-name pull_request ` + --wait-seconds 1200 ` + --allow-in-progress-artifact ` + --require-native-ctest-proof ` + --output-dir $packageRoot ` + --proof-output $proofPath + if ($LASTEXITCODE -ne 0) { + throw 'Field Capture requires exact sealed canonical Windows package evidence.' } - - name: Run ARSAS regression tests - run: dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-smart-capture-tests.trx" --results-directory .\ArIED61850Tester\TestResults + $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:ARSAS_VERSION -or + [int]$proof.failed -ne 0 -or + [int]$proof.notExecuted -ne 0 -or + [int]$proof.passed -lt 1 -or + [bool]$proof.releasePromotionAuthority) { + throw 'Field Capture sealed canonical package proof is incomplete.' + } + + $portable = Join-Path $packageRoot ($proof.portablePath -replace '/', '\') + $identity = Join-Path $packageRoot ($proof.portableIdentityPath -replace '/', '\') + foreach ($required in @($portable, $identity)) { + if (-not (Test-Path $required -PathType Leaf)) { + throw "Verified canonical portable payload is incomplete: $required" + } + } + $portableSha = (Get-FileHash $portable -Algorithm SHA256).Hash.ToLowerInvariant() + if ($portableSha -ne $proof.portableSha256 -or + [long](Get-Item $portable).Length -ne [long]$proof.portableSizeBytes) { + throw 'Verified canonical portable bytes differ after materialization.' + } + + $dist = '.\ArIED61850Tester\dist' + New-Item -ItemType Directory -Force $dist | Out-Null + Copy-Item $portable (Join-Path $dist (Split-Path $portable -Leaf)) -Force + Copy-Item $identity (Join-Path $dist (Split-Path $identity -Leaf)) -Force + + "SMART_CAPTURE_REGRESSION_AUTHORITY=sealed-build-arsas:$($proof.canonicalRunId)/$($proof.canonicalRunAttempt)" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "SMART_CAPTURE_PACKAGING_AUTHORITY=sealed-build-arsas:$($proof.canonicalRunId)/$($proof.canonicalRunAttempt)" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + @( + '## CI-P3B Field Capture exact-byte promotion', + '', + "- source: $env:GITHUB_SHA", + "- engine: $env:ARIEC61850_COMMIT", + "- ArdIrec: $env:ARDIREC_COMMIT", + "- Build ARSAS run: $($proof.canonicalRunId)/$($proof.canonicalRunAttempt)", + "- regression: $($proof.passed) passed, $($proof.failed) failed", + "- portable SHA-256: $($proof.portableSha256)", + '- Field Capture retains field checks and independent runtime smoke' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: Record manual reusable regression authority + if: github.event_name == 'workflow_dispatch' + shell: powershell + run: | + "SMART_CAPTURE_REGRESSION_AUTHORITY=manual-reusable-exact-sha" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "SMART_CAPTURE_PACKAGING_AUTHORITY=independent-manual-field-capture-publish" | + Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Setup .NET 8 for independent Field Capture publish + if: github.event_name == 'workflow_dispatch' + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 8.0.x - name: Publish portable single EXE x64 + if: github.event_name == 'workflow_dispatch' shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -302,6 +419,8 @@ jobs: "ARIEC61850 commit: $env:ARIEC61850_COMMIT", "ARIEC61850 physical baseline commit: $env:ARIEC61850_BASELINE_COMMIT", "Engine PR: 134", + "Regression authority: $env:SMART_CAPTURE_REGRESSION_AUTHORITY", + "Packaging authority: $env:SMART_CAPTURE_PACKAGING_AUTHORITY", "Mode: P0-5e golden capture acceptance over P0-5d physical wire proof, P0-5c association single-flight and P0-5b hierarchy request-budget convergence", "CI invariant: duplicate semantic request, duplicate GetNameList/GVA, second naming sweep, invoke-ID reuse and outstanding-window proof logic execute against deterministic fixtures", "Golden invariant: production hard budget is derived only from a fresh physical P0-5d PASS plus raw capture/proof SHA-256 provenance", @@ -315,6 +434,7 @@ jobs: name: ARSAS-smart-discovery-pr134-win-x64 path: | ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + ArIED61850Tester\dist\ARSAS-*-win-x64-portable-build-identity.json ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt ArIED61850Tester\scripts\verify-smart-discovery-pcap.ps1 ArIED61850Tester\scripts\new-smart-discovery-golden-lock.ps1 @@ -331,7 +451,7 @@ jobs: with: name: ARSAS-smart-discovery-pr134-test-evidence path: | - ArIED61850Tester\TestResults\*.trx + ArIED61850Tester\TestResults\ci-smart-capture-canonical-proof.json ArIED61850Tester\TestResults\P0-5D-*.json ArIED61850Tester\TestResults\P0-5E-*.json if-no-files-found: warn diff --git a/.github/workflows/smart-discovery-golden-budget-lock.yml b/.github/workflows/smart-discovery-golden-budget-lock.yml index 15a1aa4cb..083a05b26 100644 --- a/.github/workflows/smart-discovery-golden-budget-lock.yml +++ b/.github/workflows/smart-discovery-golden-budget-lock.yml @@ -34,6 +34,10 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: verify-golden-lock: name: Verify P0-5e evidence-derived hard budget diff --git a/.github/workflows/smart-discovery-golden-provenance.yml b/.github/workflows/smart-discovery-golden-provenance.yml index 01dd2bc0c..0b5d62cd0 100644 --- a/.github/workflows/smart-discovery-golden-provenance.yml +++ b/.github/workflows/smart-discovery-golden-provenance.yml @@ -34,6 +34,10 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: verify-provenance: name: Verify P0-5e capture build and target provenance diff --git a/.github/workflows/smart-discovery-mainline-readiness.yml b/.github/workflows/smart-discovery-mainline-readiness.yml index a10014654..f5735b4a4 100644 --- a/.github/workflows/smart-discovery-mainline-readiness.yml +++ b/.github/workflows/smart-discovery-mainline-readiness.yml @@ -34,6 +34,14 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +permissions: + contents: read + actions: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: mainline-readiness: name: Require R10 physical authority before mainline review @@ -48,6 +56,7 @@ jobs: shell: powershell env: PR_BASE_REF: ${{ github.base_ref }} + SOURCE_REF_NAME: ${{ github.ref_name }} run: | $target = Get-Content .\evidence\interoperability-reference-target.json -Raw | ConvertFrom-Json $lock = Get-Content .\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json @@ -98,36 +107,74 @@ jobs: throw 'Post-baseline SCL association acceptance is missing or inconsistent.' } + # P10.1: exact, bounded and ancestry-checked code-trial allowlist. + # This never grants field acceptance or mainline promotion. + . .\scripts\test-p10-9f-core-routing-trial.ps1 + $coreRoutingTrialValid = Test-P109FCoreRoutingTrial -Lock $lock $trial = $lock.smartInteroperabilityCodeTrial $serverAt = $lock.sclServerAtTrial - $originalTrial = $lock.commit -eq $trial.exactCommit -and - [int]$lock.sourcePullRequest -eq 148 - $serverAtTrial = $null -ne $serverAt -and - $lock.commit -eq $serverAt.exactCommit -and - $lock.commit -eq '352c81e6a798635c6addcee0683235ca87ad416d' -and - $serverAt.baseEngineCommit -eq $trial.exactCommit -and - [int]$lock.sourcePullRequest -eq 153 -and + $closure = $lock.datasetFirstDiscoveryTrial + $p10 = $lock.p10IdentityTrial + + $historicalTrialValid = $null -ne $trial -and + $trial.status -eq 'code-verified-not-physical' -and + [int]$trial.issue -eq 144 -and + [int]$trial.integrationPullRequest -eq 148 -and + [bool]$trial.physicalPromotionRequired -and + [bool]$trial.fullDiscoveryFallbackForbidden -and + [bool]$trial.vendorBranchingForbidden + $serverAtValid = $null -ne $serverAt -and + $serverAt.exactCommit -eq '352c81e6a798635c6addcee0683235ca87ad416d' -and [int]$serverAt.sourcePullRequest -eq 153 -and + $serverAt.baseEngineCommit -eq $trial.exactCommit -and $serverAt.status -eq 'code-verified-candidate-not-physical' -and [bool]$serverAt.physicalQualificationRequired -and [bool]$serverAt.noAutomaticMmsFailover - if ($null -ne $trial -and ($originalTrial -or $serverAtTrial)) { - if ($trial.status -ne 'code-verified-not-physical' -or - [int]$trial.issue -ne 144 -or - [int]$trial.integrationPullRequest -ne 148 -or - -not [bool]$trial.physicalPromotionRequired) { - throw 'Smart interoperability code-trial metadata is inconsistent.' - } + $closureValid = $null -ne $closure -and + $closure.status -eq 'code-trial-not-physical' -and + [int]$closure.issue -eq 495 -and + [int]$closure.enginePullRequest -eq 157 -and + [int]$closure.consumerPullRequest -eq 497 -and + $closure.exactCommit -eq '8b0c0f7e695b016834e40a6f068994dcc945cb1a' -and + $closure.baseEngineCommit -eq $serverAt.exactCommit -and + [bool]$closure.fullNameListFallbackForbidden -and + [bool]$closure.cyclicMmsProcessPollingForbidden -and + [bool]$closure.physicalQualificationRequired + $p10Valid = $null -ne $p10 -and + $p10.status -eq 'code-trial-not-physical' -and + [int]$p10.enginePullRequest -eq 161 -and + $p10.exactCommit -eq '00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1' -and + $p10.baseEngineCommit -eq $closure.exactCommit -and + $p10.identitySource -eq 'TrustedSclExactDomainMatch' -and + [bool]$p10.physicalQualificationRequired -and + [bool]$p10.fullNameListFallbackForbidden -and + [bool]$p10.cyclicMmsProcessPollingForbidden + + $originalTrial = $lock.commit -eq $trial.exactCommit -and + [int]$lock.sourcePullRequest -eq 148 + $serverAtTrial = $serverAtValid -and + $lock.commit -eq $serverAt.exactCommit -and + [int]$lock.sourcePullRequest -eq 153 + $datasetTrial = $serverAtValid -and $closureValid -and + $lock.commit -eq $closure.exactCommit -and + [int]$lock.sourcePullRequest -eq 157 + $p10Trial = $serverAtValid -and $closureValid -and $p10Valid -and + $lock.commit -eq $p10.exactCommit -and + [int]$lock.sourcePullRequest -eq 161 - if ($env:PR_BASE_REF -eq 'main') { + if ($originalTrial -or $serverAtTrial -or $datasetTrial -or $p10Trial -or $coreRoutingTrialValid) { + if (-not $historicalTrialValid -or + $lock.ref -ne 'main') { + throw 'Stacked trial identity or ancestor authority is inconsistent.' + } + if ($env:PR_BASE_REF -eq 'main' -or $env:SOURCE_REF_NAME -eq 'main') { throw 'Unqualified P6.1/P6.2 trial cannot pass mainline readiness before fresh physical qualification.' } - - Write-Host "Stacked consumer trial accepted for CI against $($lock.commit); historical physical authority remains $($association.engine.testedHead)." + Write-Host "Stacked consumer trial accepted for CI against $($lock.commit); no mainline physical promotion." } elseif ($lock.commit -ne $association.engine.testedHead -or [int]$lock.sourcePullRequest -ne 143) { - throw 'Runtime engine pin is neither the physically accepted authority nor an explicit stacked CodeVerified trial.' + throw 'Runtime engine pin is neither the physically accepted authority nor an exact ancestry-bound stacked CodeVerified trial.' } "ENGINE_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append @@ -141,7 +188,7 @@ jobs: git -C ..\ARIEC61850 checkout --quiet --detach $env:ENGINE_COMMIT $actual = (git -C ..\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() if ($actual -ne $env:ENGINE_COMMIT) { - throw "Accepted engine authority mismatch. Expected $env:ENGINE_COMMIT, got $actual." + throw "R10 engine authority mismatch. Expected $env:ENGINE_COMMIT, got $actual." } - name: Setup .NET 8 @@ -156,9 +203,62 @@ jobs: dotnet build ..\ARIEC61850\ARIEC61850.sln -c Release --no-restore dotnet test ..\ARIEC61850\tests\AR.Iec61850.Tests\AR.Iec61850.Tests.csproj -c Release --no-build --no-restore - - name: Build and test ARSAS candidate + - name: Set up Python for canonical regression evidence verification + if: github.event_name == 'pull_request' + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Require successful canonical Build ARSAS for same PR merge and engine + if: github.event_name == 'pull_request' + 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 }} + PR_MERGE_SHA: ${{ github.sha }} + run: | + python .\scripts\verify-ci-canonical-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --head-branch $env:PR_HEAD_BRANCH ` + --head-sha $env:PR_HEAD_SHA ` + --merge-sha $env:PR_MERGE_SHA ` + --engine-sha $env:ENGINE_COMMIT ` + --wait-seconds 960 ` + --allow-in-progress-artifact ` + --output (Join-Path $env:RUNNER_TEMP 'ci-r10-canonical-reuse.json') + if ($LASTEXITCODE -ne 0) { + throw 'R10 Mainline Readiness cannot reuse canonical ARSAS regression evidence.' + } + + - name: Report independent engine test plus exact ARSAS regression reuse + if: github.event_name == 'pull_request' shell: powershell run: | - dotnet restore .\ArIED61850Tester.sln - dotnet build .\ArIED61850Tester.sln -c Release --no-restore - dotnet test .\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore + $proof = Get-Content (Join-Path $env:RUNNER_TEMP 'ci-r10-canonical-reuse.json') -Raw | ConvertFrom-Json + if ([int]$proof.failed -ne 0 -or [int]$proof.notExecuted -ne 0 -or + [int]$proof.passed -lt 1 -or $proof.engineSha -ne $env:ENGINE_COMMIT) { + throw 'R10 accepted engine/regression evidence is incomplete.' + } + @( + '## R10 Mainline Readiness — independent engine + canonical ARSAS tests', + '', + "- R10 engine integration SHA: $env:ENGINE_COMMIT", + "- canonical Build ARSAS run: $($proof.canonicalRunId)", + "- exact ARSAS PR merge SHA: $($proof.mergeSha)", + "- full ARSAS regression: $($proof.passed) passed, $($proof.failed) failed", + "- canonical proof stage: $($proof.proofStage)", + "- canonical workflow completed: $($proof.workflowCompleted)", + "- packaging/smoke proven here: $($proof.packagingSmokeProven)", + '- Build ARSAS remains the independent packaging/smoke check', + '- Historical physical authority validated independently before this reuse' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + manual-regression: + name: Manual exact-SHA ARSAS regression for R10 readiness + if: github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/_ci-p1-windows-build-test.yml + with: + source_sha: ${{ github.sha }} + shadow_mode: true + secrets: {} diff --git a/.github/workflows/smart-discovery-merge-execution-guard.yml b/.github/workflows/smart-discovery-merge-execution-guard.yml index d02de3beb..70948c723 100644 --- a/.github/workflows/smart-discovery-merge-execution-guard.yml +++ b/.github/workflows/smart-discovery-merge-execution-guard.yml @@ -34,10 +34,19 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +permissions: + contents: read + actions: read + +concurrency: + group: smart-discovery-merge-execution-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: verify-merge-execution-contract: name: Verify P0-5h ordered merge contract - runs-on: windows-latest + runs-on: ubuntu-latest + timeout-minutes: 23 steps: - name: Checkout ARSAS branch uses: actions/checkout@v7 @@ -45,7 +54,7 @@ jobs: fetch-depth: 0 - name: Validate P0-5h source contract - shell: powershell + shell: pwsh run: | $target = '.\evidence\smart-discovery-mainline-merge-target.json' $writer = '.\scripts\new-smart-discovery-mainline-merge-manifest.ps1' @@ -59,7 +68,8 @@ jobs: foreach ($script in @($writer,$preflight,$postMerge)) { $tokens = $null $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($script,[ref]$tokens,[ref]$errors) | Out-Null + $scriptAbsolute = (Resolve-Path -LiteralPath $script).Path + [System.Management.Automation.Language.Parser]::ParseFile($scriptAbsolute,[ref]$tokens,[ref]$errors) | Out-Null if ($errors.Count -ne 0) { $messages = @($errors | ForEach-Object { $_.Message }) -join '; ' throw "PowerShell parse failure in ${script}: $messages" @@ -85,33 +95,64 @@ jobs: } } - - name: Checkout pinned ARIEC61850 dependency - shell: powershell - run: | - $lock = Get-Content .\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json - if ($lock.repository -ne 'masarray/ARIEC61850' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { - throw 'Invalid ARIEC61850 engine lock.' - } - - $enginePath = Join-Path (Split-Path $env:GITHUB_WORKSPACE -Parent) 'ARIEC61850' - git clone --quiet --filter=blob:none --no-checkout "https://github.com/$($lock.repository).git" $enginePath - git -C $enginePath fetch --quiet --depth 1 origin $lock.commit - git -C $enginePath checkout --quiet --detach $lock.commit + - name: Validate offline canonical evidence producer/consumer fixtures + run: python3 scripts/test-ci-canonical-reuse.py - $actual = (git -C $enginePath rev-parse HEAD).Trim().ToLowerInvariant() - if ($actual -ne ([string]$lock.commit).ToLowerInvariant()) { - throw "Pinned engine checkout mismatch: expected $($lock.commit), got $actual" - } - Write-Host "P0-5h regression dependency engine: $actual" - - - name: Setup .NET 8 - uses: actions/setup-dotnet@v6 + - id: pinned-engine + name: Resolve exact integration engine authority + uses: ./.github/actions/resolve-ariec-engine with: - dotnet-version: 8.0.x + lock-path: engines/ARIEC61850.lock.json + + - name: Require same-head canonical Build ARSAS regression proof + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_MERGE_SHA: ${{ github.sha }} + PINNED_ENGINE_SHA: ${{ steps.pinned-engine.outputs.commit }} + REPOSITORY: ${{ github.repository }} + run: | + python3 scripts/verify-ci-canonical-reuse.py \ + --repository "$REPOSITORY" \ + --head-branch "$PR_HEAD_BRANCH" \ + --head-sha "$PR_HEAD_SHA" \ + --merge-sha "$PR_MERGE_SHA" \ + --engine-sha "$PINNED_ENGINE_SHA" \ + --wait-seconds 960 \ + --allow-in-progress-artifact \ + --output "$RUNNER_TEMP/ci-canonical-reuse-proof.json" - - name: Run ARSAS regression tests - shell: powershell + - name: Summarize exact canonical-regression reuse + if: github.event_name == 'pull_request' run: | - dotnet restore .\ArIED61850Tester.sln - dotnet build .\ArIED61850Tester.sln -c Release --no-restore - dotnet test .\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore + python3 - <<'PY' + import json + import os + from pathlib import Path + + proof = json.loads( + (Path(os.environ["RUNNER_TEMP"]) / "ci-canonical-reuse-proof.json").read_text() + ) + with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as out: + out.write("## Canonical ARSAS regression reuse — fail-closed PASS\\n\\n") + out.write(f"- canonical Build ARSAS run: {proof['canonicalRunId']}, attempt {proof['canonicalRunAttempt']}\\n") + out.write(f"- exact PR merge SHA: {proof['mergeSha']}\\n") + out.write(f"- exact engine SHA: {proof['engineSha']}\\n") + out.write(f"- full regression: {proof['passed']} passed, {proof['failed']} failed\\n") + out.write(f"- proof stage: {proof['proofStage']}\\n") + out.write(f"- canonical workflow completed: {proof['workflowCompleted']}\\n") + out.write(f"- packaging/smoke proven by this guard: {proof['packagingSmokeProven']}\\n") + out.write("- Build ARSAS remains the independent packaging/smoke check\\n") + out.write("- Source P0-5h ordered-merge contract: passed independently\\n") + PY + + manual-regression: + name: Manual exact-SHA reusable full regression + if: github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/_ci-p1-windows-build-test.yml + with: + source_sha: ${{ github.sha }} + shadow_mode: true + secrets: {} diff --git a/.github/workflows/smart-discovery-post-merge-production.yml b/.github/workflows/smart-discovery-post-merge-production.yml index 9248ebf04..4c7b5e2f0 100644 --- a/.github/workflows/smart-discovery-post-merge-production.yml +++ b/.github/workflows/smart-discovery-post-merge-production.yml @@ -6,15 +6,33 @@ on: - main workflow_dispatch: +permissions: + contents: read + actions: read + +concurrency: + group: smart-discovery-post-merge-production-${{ github.run_id }} + cancel-in-progress: false + jobs: post-merge-production: name: Verify R10 convergence authority on main runs-on: windows-latest steps: - - name: Checkout ARSAS main + - name: Checkout exact ARSAS main revision uses: actions/checkout@v7 with: + ref: ${{ github.sha }} fetch-depth: 1 + persist-credentials: false + + - name: Verify exact post-merge source identity + shell: powershell + run: | + $actual = (git rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -cne $env:GITHUB_SHA.ToLowerInvariant()) { + throw "Post-merge source mismatch. Expected $env:GITHUB_SHA, got $actual." + } - name: Verify R10 physical convergence authority shell: powershell @@ -73,13 +91,67 @@ jobs: dotnet restore ..\ARIEC61850\ARIEC61850.sln dotnet build ..\ARIEC61850\ARIEC61850.sln -c Release --no-restore dotnet test ..\ARIEC61850\tests\AR.Iec61850.Tests\AR.Iec61850.Tests.csproj -c Release --no-build --no-restore + if ($LASTEXITCODE -ne 0) { throw 'Independent R10 engine regression failed.' } + "ENGINE_FULL_REGRESSION_VERIFIED=true" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Setup Python for exact-main canonical regression evidence + uses: actions/setup-python@v6 + with: + python-version: '3.11' - - name: Build and test ARSAS main + - name: Run offline canonical evidence safety fixtures + run: python .\scripts\test-ci-canonical-reuse.py + + - name: Reuse exact-main canonical ARSAS full regression shell: powershell + env: + GITHUB_TOKEN: ${{ github.token }} run: | - dotnet restore .\ArIED61850Tester.sln - dotnet build .\ArIED61850Tester.sln -c Release --no-restore - dotnet test .\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore + $proofPath = Join-Path $env:RUNNER_TEMP 'ci-post-merge-canonical-proof.json' + python .\scripts\verify-ci-canonical-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --head-branch main ` + --head-sha $env:GITHUB_SHA ` + --merge-sha $env:GITHUB_SHA ` + --engine-sha $env:ENGINE_COMMIT ` + --event-name push ` + --wait-seconds 960 ` + --allow-in-progress-artifact ` + --output $proofPath + if ($LASTEXITCODE -ne 0) { + throw 'Post-merge production verification requires exact-main canonical ARSAS regression evidence.' + } + + $proof = Get-Content $proofPath -Raw | ConvertFrom-Json + if ($proof.eventName -ne 'push' -or + $proof.branch -ne 'main' -or + $proof.mergeSha -ne $env:GITHUB_SHA -or + $proof.engineSha -ne $env:ENGINE_COMMIT -or + [int]$proof.failed -ne 0 -or + [int]$proof.notExecuted -ne 0 -or + [int]$proof.passed -lt 1) { + throw 'Exact-main canonical ARSAS regression proof is incomplete.' + } + + "ARSAS_CANONICAL_REGRESSION_VERIFIED=true" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARSAS_CANONICAL_RUN_ID=$($proof.canonicalRunId)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARSAS_CANONICAL_RUN_ATTEMPT=$($proof.canonicalRunAttempt)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARSAS_CANONICAL_TESTS_PASSED=$($proof.passed)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARSAS_CANONICAL_PROOF_STAGE=$($proof.proofStage)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARSAS_CANONICAL_PACKAGING_PROVEN=$($proof.packagingSmokeProven.ToString().ToLowerInvariant())" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + + @( + '## R10 post-merge production verification', + '', + "- exact main SHA: $env:GITHUB_SHA", + "- exact engine SHA: $env:ENGINE_COMMIT", + '- R10 engine regression: independently executed', + "- canonical Build ARSAS run: $($proof.canonicalRunId)/$($proof.canonicalRunAttempt)", + "- canonical ARSAS regression: $($proof.passed) passed, $($proof.failed) failed", + "- proof stage: $($proof.proofStage)", + "- packaging/smoke proven by this workflow: $($proof.packagingSmokeProven)", + '- Build ARSAS remains independent packaging/smoke authority' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - name: Write R10 post-merge evidence if: always() @@ -87,12 +159,25 @@ jobs: run: | New-Item -ItemType Directory -Force .\TestResults | Out-Null $target = Get-Content .\evidence\interoperability-reference-target.json -Raw | ConvertFrom-Json + $engineVerified = $env:ENGINE_FULL_REGRESSION_VERIFIED -eq 'true' + $arsasVerified = $env:ARSAS_CANONICAL_REGRESSION_VERIFIED -eq 'true' + $status = if ($engineVerified -and $arsasVerified) { 'passed' } else { 'incomplete-or-failed' } + [ordered]@{ schemaVersion = 1 + verificationStatus = $status sourceCommit = $env:GITHUB_SHA engineCommit = $env:ENGINE_COMMIT convergenceStatus = $target.status physicalRetestPassed = [bool]$target.promotion.physicalRetestPassed + engineFullRegressionVerified = $engineVerified + arsasCanonicalRegressionVerified = $arsasVerified + arsasCanonicalRunId = $env:ARSAS_CANONICAL_RUN_ID + arsasCanonicalRunAttempt = $env:ARSAS_CANONICAL_RUN_ATTEMPT + arsasCanonicalTestsPassed = $env:ARSAS_CANONICAL_TESTS_PASSED + arsasCanonicalProofStage = $env:ARSAS_CANONICAL_PROOF_STAGE + arsasCanonicalPackagingProven = ($env:ARSAS_CANONICAL_PACKAGING_PROVEN -eq 'true') + buildArsasRemainsPackagingAuthority = $true ed2ProjectionErrors = [int]$target.physicalEvidence.arsasR10.reuseEdition2.projectionErrors ed2CacheLoss = [int]$target.physicalEvidence.arsasR10.reuseEdition2.cacheLoss ed1ProjectionErrors = [int]$target.physicalEvidence.arsasR10.reuseEdition1.projectionErrors diff --git a/.github/workflows/smart-discovery-production-promotion.yml b/.github/workflows/smart-discovery-production-promotion.yml index 8a221d4c1..fc6defb4b 100644 --- a/.github/workflows/smart-discovery-production-promotion.yml +++ b/.github/workflows/smart-discovery-production-promotion.yml @@ -34,49 +34,58 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +permissions: + contents: read + actions: read + +concurrency: + group: smart-discovery-production-promotion-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: verify-production-route: name: Verify physical-proven Smart Discovery production parity runs-on: windows-latest - timeout-minutes: 30 + timeout-minutes: 25 steps: - name: Checkout exact ARSAS candidate uses: actions/checkout@v7 with: path: ArIED61850Tester + fetch-depth: 1 + persist-credentials: false - - name: Resolve immutable ARIEC61850 engine lock - shell: powershell + - name: Verify exact tested ARSAS candidate SHA + shell: pwsh + env: + EXPECTED_SOURCE_SHA: ${{ github.sha }} run: | - $lock = Get-Content '.\ArIED61850Tester\engines\ARIEC61850.lock.json' -Raw | ConvertFrom-Json - if ($lock.repository -ne 'masarray/ARIEC61850' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { - throw 'Invalid ARIEC61850 production lock.' + $actual = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -cne $env:EXPECTED_SOURCE_SHA) { + throw "Smart Discovery route guard checkout mismatch. Expected $env:EXPECTED_SOURCE_SHA, got $actual." } - "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - "ARIEC61850_COMMIT=$($lock.commit)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append - - name: Checkout exact ARIEC61850 engine - shell: powershell - run: | - git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 - git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT - git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT - $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() - if ($actual -ne $env:ARIEC61850_COMMIT.ToLowerInvariant()) { - throw "Engine pin mismatch: expected $env:ARIEC61850_COMMIT got $actual" - } + - name: Setup Python for bounded canonical evidence verification + uses: actions/setup-python@v6 + with: + python-version: '3.11' - - name: Setup .NET 8 - uses: actions/setup-dotnet@v6 + - name: Run offline canonical-proof fail-closed fixtures + run: python .\ArIED61850Tester\scripts\test-ci-canonical-reuse.py + + - id: engine + name: Resolve exact pinned ARIEC61850 integration authority + uses: ./ArIED61850Tester/.github/actions/resolve-ariec-engine with: - dotnet-version: 8.0.x + lock-path: ArIED61850Tester/engines/ARIEC61850.lock.json - - name: Verify tracked production route before build + - name: Verify tracked production route and preserve source hash shell: powershell run: | $native = '.\ArIED61850Tester\Services\NativeIec61850Client.cs' $before = (Get-FileHash $native -Algorithm SHA256).Hash + "SMART_ROUTE_SOURCE_SHA256=$before" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append & .\ArIED61850Tester\scripts\enable-smart-discovery-capture.ps1 -VerifyOnly if ($LASTEXITCODE -ne 0) { throw 'Smart Discovery route verification failed.' } $after = (Get-FileHash $native -Algorithm SHA256).Hash @@ -95,25 +104,80 @@ jobs: } } - - name: Restore build and test the exact production route - shell: powershell + - name: Require matching canonical Build ARSAS full regression evidence + if: github.event_name == 'pull_request' + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_MERGE_SHA: ${{ github.sha }} + PINNED_ENGINE_SHA: ${{ steps.engine.outputs.commit }} run: | - dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln - if ($LASTEXITCODE -ne 0) { throw 'ARSAS restore failed.' } - dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore - if ($LASTEXITCODE -ne 0) { throw 'ARSAS production-route build failed.' } - dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore - if ($LASTEXITCODE -ne 0) { throw 'ARSAS production-route regression tests failed.' } - - - name: Prove build did not select or synthesize another route + python .\ArIED61850Tester\scripts\verify-ci-canonical-reuse.py ` + --repository $env:GITHUB_REPOSITORY ` + --head-branch $env:PR_HEAD_BRANCH ` + --head-sha $env:PR_HEAD_SHA ` + --merge-sha $env:PR_MERGE_SHA ` + --engine-sha $env:PINNED_ENGINE_SHA ` + --wait-seconds 960 ` + --allow-in-progress-artifact ` + --output (Join-Path $env:RUNNER_TEMP 'ci-production-route-canonical-proof.json') + if ($LASTEXITCODE -ne 0) { + throw 'Production route guard requires matching exact-SHA canonical full-regression PASS.' + } + + - name: Summarize exact-source canonical regression parity + if: github.event_name == 'pull_request' + shell: pwsh + run: | + $proof = Get-Content (Join-Path $env:RUNNER_TEMP 'ci-production-route-canonical-proof.json') -Raw | ConvertFrom-Json + if ([int]$proof.failed -ne 0 -or [int]$proof.notExecuted -ne 0 -or + [int]$proof.passed -lt 1 -or + $proof.engineSha -ne '${{ steps.engine.outputs.commit }}') { + throw 'Production-route canonical full-regression proof is incomplete.' + } + @( + '## Smart Discovery production route — canonical full-regression reuse', + '', + "- exact PR merge SHA: $($proof.mergeSha)", + "- exact pinned engine SHA: $($proof.engineSha)", + "- canonical Build ARSAS run: $($proof.canonicalRunId)", + "- full regression: $($proof.passed) passed, $($proof.failed) failed", + "- canonical proof stage: $($proof.proofStage)", + "- canonical workflow completed: $($proof.workflowCompleted)", + "- packaging/smoke proven by this guard: $($proof.packagingSmokeProven)", + '- Build ARSAS remains the independent packaging/smoke check', + '- Production route source verified independently without any mutation', + '- No promotion or release action was performed' + ) | Out-File $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: Prove canonical route remains immutable after evidence verification shell: powershell run: | $native = '.\ArIED61850Tester\Services\NativeIec61850Client.cs' + $actualHash = (Get-FileHash $native -Algorithm SHA256).Hash + if ($actualHash -cne $env:SMART_ROUTE_SOURCE_SHA256) { + throw 'Route source changed during canonical evidence verification.' + } & .\ArIED61850Tester\scripts\enable-smart-discovery-capture.ps1 -VerifyOnly - if ($LASTEXITCODE -ne 0) { throw 'Post-build Smart Discovery route verification failed.' } + if ($LASTEXITCODE -ne 0) { throw 'Post-proof Smart Discovery route verification failed.' } + $finalHash = (Get-FileHash $native -Algorithm SHA256).Hash + if ($finalHash -cne $env:SMART_ROUTE_SOURCE_SHA256) { + throw 'Route verifier mutated production source.' + } $targets = Get-Content '.\ArIED61850Tester\Directory.Build.targets' -Raw if ($targets -match 'GITHUB_WORKFLOW' -or $targets -match 'SmartDiscoveryProductionPromoted') { throw 'Discovery runtime still depends on workflow name or promotion switch.' } - Write-Host 'Production, R7, installer and local builds now compile the same tracked Smart Discovery route.' + Write-Host 'Same tracked Smart Discovery route verified; exact-SHA canonical Build ARSAS supplies full regression proof.' + + manual-regression: + name: Manual exact-SHA full production-route regression + if: github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/_ci-p1-windows-build-test.yml + with: + source_sha: ${{ github.sha }} + shadow_mode: true + secrets: {} diff --git a/.github/workflows/smart-discovery-repeat-run-stability.yml b/.github/workflows/smart-discovery-repeat-run-stability.yml index 48ad5a0ce..3f38fcb41 100644 --- a/.github/workflows/smart-discovery-repeat-run-stability.yml +++ b/.github/workflows/smart-discovery-repeat-run-stability.yml @@ -34,6 +34,10 @@ on: - ".github/workflows/smart-discovery-*.yml" workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: verify-repeat-run-stability: name: Verify P0-5f repeat-run stability contract diff --git a/.github/workflows/validate-io-testing.yml b/.github/workflows/validate-io-testing.yml index 0bcd315ce..c63609462 100644 --- a/.github/workflows/validate-io-testing.yml +++ b/.github/workflows/validate-io-testing.yml @@ -15,52 +15,99 @@ on: - "tests/ARSAS.Tests/FatScl*" - "engines/ARIEC61850.lock.json" - ".github/workflows/validate-io-testing.yml" + - "scripts/verify-ci-canonical-reuse.py" + - "scripts/test-ci-canonical-reuse.py" workflow_dispatch: permissions: contents: read + actions: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true jobs: validate-io-testing: name: Build and test IO List Testing - runs-on: windows-latest + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'windows-latest' }} steps: - - name: Checkout ARSAS application + - name: Checkout exact ARSAS application revision uses: actions/checkout@v7 with: path: ARSAS + ref: ${{ github.sha }} + persist-credentials: false + fetch-depth: 1 - - name: Resolve immutable ARIEC61850 engine lock - shell: powershell + - id: engine + name: Resolve exact immutable ARIEC61850 engine identity + uses: ./ARSAS/.github/actions/resolve-ariec-engine + with: + lock-path: ARSAS/engines/ARIEC61850.lock.json + + - name: Run offline canonical evidence safety fixtures + if: github.event_name == 'pull_request' + run: python3 ARSAS/scripts/test-ci-canonical-reuse.py + + - name: Prove exact canonical IO domain tests passed + if: github.event_name == 'pull_request' + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_MERGE_SHA: ${{ github.sha }} + ENGINE_SHA: ${{ steps.engine.outputs.commit }} run: | - $lock = Get-Content ".\ARSAS\engines\ARIEC61850.lock.json" -Raw | ConvertFrom-Json - if ($lock.repository -notmatch '^[^/]+/[^/]+$' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { - throw "ARIEC61850 lock metadata is invalid." - } - "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "ARIEC61850_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + set -euo pipefail + mkdir -p ARSAS/TestResults + python3 ARSAS/scripts/verify-ci-canonical-reuse.py \ + --repository "$GITHUB_REPOSITORY" \ + --head-branch "$PR_HEAD_BRANCH" \ + --head-sha "$PR_HEAD_SHA" \ + --merge-sha "$PR_MERGE_SHA" \ + --engine-sha "$ENGINE_SHA" \ + --wait-seconds 960 \ + --allow-in-progress-artifact \ + --require-test-substring IoTest \ + --require-test-substring IoList \ + --require-test-substring IoFat \ + --require-test-substring FatDataSet \ + --require-test-substring FatScl \ + --output ARSAS/TestResults/io-canonical-domain-proof.json - - name: Checkout immutable ARIEC61850 engine revision + - name: Checkout immutable ARIEC61850 engine revision for manual diagnostics + if: github.event_name == 'workflow_dispatch' shell: powershell + env: + ENGINE_REPOSITORY: ${{ steps.engine.outputs.repository }} + ENGINE_COMMIT: ${{ steps.engine.outputs.commit }} run: | - git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 - git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT - git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT - $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() - if ($actual -ne $env:ARIEC61850_COMMIT) { - throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ENGINE_REPOSITORY.git" ARIEC61850 + if ($LASTEXITCODE -ne 0) { throw 'Engine clone failed.' } + git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ENGINE_COMMIT + if ($LASTEXITCODE -ne 0) { throw 'Pinned engine fetch failed.' } + git -C .\ARIEC61850 checkout --quiet --detach $env:ENGINE_COMMIT + if ($LASTEXITCODE -ne 0) { throw 'Pinned engine checkout failed.' } + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -cne $env:ENGINE_COMMIT) { + throw "ARIEC61850 pin mismatch. Expected $env:ENGINE_COMMIT, got $actual." } - - name: Setup .NET 8 + - name: Setup .NET 8 for manual diagnostics + if: github.event_name == 'workflow_dispatch' uses: actions/setup-dotnet@v6 with: dotnet-version: 8.0.x - name: Restore focused test project + if: github.event_name == 'workflow_dispatch' run: dotnet restore .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj - name: Build focused IO testing project + if: github.event_name == 'workflow_dispatch' shell: powershell run: | $log = ".\ARSAS\io-testing-build.log" @@ -79,21 +126,17 @@ jobs: } Get-Content $log -Tail 12 - - name: Upload IO testing compiler diagnostics - if: always() - uses: actions/upload-artifact@v7 - with: - name: ARSAS-io-testing-compiler-log - path: ARSAS/io-testing-build.log - if-no-files-found: error - - name: Run IO testing regression tests + if: github.event_name == 'workflow_dispatch' run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --filter "FullyQualifiedName~IoTest|FullyQualifiedName~IoList|FullyQualifiedName~IoFat|FullyQualifiedName~FatDataSet|FullyQualifiedName~FatScl" --logger "trx;LogFileName=io-testing-tests.trx" --results-directory .\ARSAS\TestResults - - name: Upload IO testing test results + - name: Upload IO validation evidence if: always() uses: actions/upload-artifact@v7 with: name: ARSAS-io-testing-test-results - path: ARSAS/TestResults/*.trx - if-no-files-found: ignore + path: | + ARSAS/TestResults/io-canonical-domain-proof.json + ARSAS/TestResults/*.trx + ARSAS/io-testing-build.log + if-no-files-found: warn diff --git a/.github/workflows/validate-sv-evidence.yml b/.github/workflows/validate-sv-evidence.yml index 7d5989b93..4e4311c44 100644 --- a/.github/workflows/validate-sv-evidence.yml +++ b/.github/workflows/validate-sv-evidence.yml @@ -10,52 +10,95 @@ on: - "tests/ARSAS.Tests/**" - "engines/ARIEC61850.lock.json" - ".github/workflows/validate-sv-evidence.yml" + - "scripts/verify-ci-canonical-reuse.py" + - "scripts/test-ci-canonical-reuse.py" workflow_dispatch: permissions: contents: read + actions: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true jobs: validate-evidence: name: Build and test deterministic SV evidence - runs-on: windows-latest + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'windows-latest' }} steps: - - name: Checkout ARSAS application + - name: Checkout exact ARSAS application revision uses: actions/checkout@v7 with: path: ARSAS + ref: ${{ github.sha }} + persist-credentials: false + fetch-depth: 1 - - name: Resolve immutable ARIEC61850 engine lock - shell: powershell + - id: engine + name: Resolve exact immutable ARIEC61850 engine identity + uses: ./ARSAS/.github/actions/resolve-ariec-engine + with: + lock-path: ARSAS/engines/ARIEC61850.lock.json + + - name: Run offline canonical evidence safety fixtures + if: github.event_name == 'pull_request' + run: python3 ARSAS/scripts/test-ci-canonical-reuse.py + + - name: Prove exact canonical SV evidence tests passed + if: github.event_name == 'pull_request' + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_MERGE_SHA: ${{ github.sha }} + ENGINE_SHA: ${{ steps.engine.outputs.commit }} run: | - $lock = Get-Content ".\ARSAS\engines\ARIEC61850.lock.json" -Raw | ConvertFrom-Json - if ($lock.repository -notmatch '^[^/]+/[^/]+$' -or $lock.commit -notmatch '^[0-9a-f]{40}$') { - throw "ARIEC61850 lock metadata is invalid." - } - "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "ARIEC61850_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + set -euo pipefail + mkdir -p ARSAS/TestResults + python3 ARSAS/scripts/verify-ci-canonical-reuse.py \ + --repository "$GITHUB_REPOSITORY" \ + --head-branch "$PR_HEAD_BRANCH" \ + --head-sha "$PR_HEAD_SHA" \ + --merge-sha "$PR_MERGE_SHA" \ + --engine-sha "$ENGINE_SHA" \ + --wait-seconds 960 \ + --allow-in-progress-artifact \ + --require-test-substring SmvSnapshotEvidenceExporterTests \ + --output ARSAS/TestResults/sv-canonical-domain-proof.json - - name: Checkout immutable ARIEC61850 engine revision + - name: Checkout immutable ARIEC61850 engine revision for manual diagnostics + if: github.event_name == 'workflow_dispatch' shell: powershell + env: + ENGINE_REPOSITORY: ${{ steps.engine.outputs.repository }} + ENGINE_COMMIT: ${{ steps.engine.outputs.commit }} run: | - git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 - git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT - git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT - $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() - if ($actual -ne $env:ARIEC61850_COMMIT) { - throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ENGINE_REPOSITORY.git" ARIEC61850 + if ($LASTEXITCODE -ne 0) { throw 'Engine clone failed.' } + git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ENGINE_COMMIT + if ($LASTEXITCODE -ne 0) { throw 'Pinned engine fetch failed.' } + git -C .\ARIEC61850 checkout --quiet --detach $env:ENGINE_COMMIT + if ($LASTEXITCODE -ne 0) { throw 'Pinned engine checkout failed.' } + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + if ($actual -cne $env:ENGINE_COMMIT) { + throw "ARIEC61850 pin mismatch. Expected $env:ENGINE_COMMIT, got $actual." } - - name: Setup .NET 8 + - name: Setup .NET 8 for manual diagnostics + if: github.event_name == 'workflow_dispatch' uses: actions/setup-dotnet@v6 with: dotnet-version: 8.0.x - name: Restore focused test project + if: github.event_name == 'workflow_dispatch' run: dotnet restore .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj - name: Build focused test project + if: github.event_name == 'workflow_dispatch' shell: powershell run: | $log = ".\ARSAS\sv-evidence-build.log" @@ -74,21 +117,17 @@ jobs: } Get-Content $log -Tail 12 - - name: Upload focused compiler diagnostics - if: always() - uses: actions/upload-artifact@v7 - with: - name: ARSAS-sv-evidence-compiler-log - path: ARSAS/sv-evidence-build.log - if-no-files-found: error - - name: Run SV evidence regression tests + if: github.event_name == 'workflow_dispatch' run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --filter "FullyQualifiedName~SmvSnapshotEvidenceExporterTests" --logger "trx;LogFileName=sv-evidence-tests.trx" --results-directory .\ARSAS\TestResults - - name: Upload focused evidence test results + - name: Upload SV validation evidence if: always() uses: actions/upload-artifact@v7 with: name: ARSAS-sv-evidence-test-results - path: ARSAS/TestResults/*.trx - if-no-files-found: ignore + path: | + ARSAS/TestResults/sv-canonical-domain-proof.json + ARSAS/TestResults/*.trx + ARSAS/sv-evidence-build.log + if-no-files-found: warn diff --git a/ControlCommandWindow.xaml.cs b/ControlCommandWindow.xaml.cs index 5e9b0517e..dfdbf3ea4 100644 --- a/ControlCommandWindow.xaml.cs +++ b/ControlCommandWindow.xaml.cs @@ -139,7 +139,7 @@ private async Task InspectAsync() CommandStage = capabilities.SupportsOperate ? "Ready" : "Control unavailable"; CommandStatus = capabilities.SupportsOperate ? $"{capabilities.ControlModelText}. {capabilities.SequenceText}. Select a command value and required check flags." - : "The IED reports status-only or an unknown ctlModel. ArIED will not guess an operating sequence."; + : capabilities.EngineControlServiceStatus; SetResultTone(capabilities.SupportsOperate ? "Ready" : "Error"); } } @@ -243,19 +243,13 @@ private void PopulateValueOptions(string cdc, string currentValue) case "INC": case "ISC": case "INC/ISC": - ValueOptions.Add("Raise"); - ValueOptions.Add("Lower"); + if (long.TryParse(currentValue, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var numericValue)) + ValueOptions.Add(numericValue.ToString(System.Globalization.CultureInfo.InvariantCulture)); break; case "BSC": - if (TryExtractNumber(currentValue, out var step)) - { - ValueOptions.Add(Math.Round(step - 1d).ToString(System.Globalization.CultureInfo.InvariantCulture)); - ValueOptions.Add(Math.Round(step + 1d).ToString(System.Globalization.CultureInfo.InvariantCulture)); - } - else - { - ValueOptions.Add("0"); - } + ValueOptions.Add("Raise"); + ValueOptions.Add("Lower"); break; case "APC": ValueOptions.Add(TryExtractNumber(currentValue, out var analogue) diff --git a/IpConnectWizardWindow.xaml.cs b/IpConnectWizardWindow.xaml.cs index 01728720b..da52f2cae 100644 --- a/IpConnectWizardWindow.xaml.cs +++ b/IpConnectWizardWindow.xaml.cs @@ -26,16 +26,33 @@ public string MmsPortText set => SetValue(MmsPortTextProperty, value); } + private readonly bool _rememberDiscoveryEndpoint; public int MmsPort { get; private set; } = 102; public ObservableCollection RecentRelayIps { get; } = new(); - public IpConnectWizardWindow(string initialIp = "", int initialPort = 102) + public IpConnectWizardWindow(string initialIp = "", int initialPort = 102, + bool preferLastDiscoveryEndpoint = false) { + _rememberDiscoveryEndpoint = preferLastDiscoveryEndpoint; InitializeComponent(); - RelayIpAddress = initialIp?.Trim() ?? string.Empty; - MmsPortText = (initialPort <= 0 ? 102 : initialPort).ToString(CultureInfo.InvariantCulture); foreach (var endpoint in UserPreferenceStore.LoadRecentEndpoints()) RecentRelayIps.Add(endpoint); + + // This dialog is reused from several workspace actions. The last + // explicitly confirmed address, not a selected card or hardcoded NIC, + // is the primary suggestion for the next IP Discovery action. + if (_rememberDiscoveryEndpoint && + UserPreferenceStore.TryLoadLastDiscoveryEndpoint(out var lastIp, out var lastPort)) + { + RelayIpAddress = lastIp; + MmsPortText = lastPort.ToString(CultureInfo.InvariantCulture); + } + else + { + RelayIpAddress = initialIp?.Trim() ?? string.Empty; + MmsPortText = (initialPort is > 0 and <= 65535 ? initialPort : 102) + .ToString(CultureInfo.InvariantCulture); + } Loaded += (_, _) => RelayIpBox.Focus(); } @@ -54,7 +71,9 @@ private void RelayIpBox_Loaded(object sender, RoutedEventArgs e) private void Connect_Click(object sender, RoutedEventArgs e) { - RelayIpAddress = (RelayIpAddress ?? string.Empty).Trim(); + // Editable ComboBox commits its binding on LostFocus. Enter can submit + // while the editor still owns focus: read the live text to avoid stale IPs. + RelayIpAddress = RelayIpBox.Text.Trim(); if (!System.Net.IPAddress.TryParse(RelayIpAddress, out _)) { WizardStatusText.Text = "Enter a valid IPv4 or IPv6 address."; @@ -69,6 +88,22 @@ private void Connect_Click(object sender, RoutedEventArgs e) } MmsPort = port; + + // Persist the operator's valid *submission*, not only a successful + // MMS association. Cancel/invalid inputs never overwrite the last IP. + if (_rememberDiscoveryEndpoint) + { + try + { + UserPreferenceStore.RecordLastDiscoveryEndpoint(RelayIpAddress, MmsPort); + } + catch (Exception ex) when (ex is System.IO.IOException or UnauthorizedAccessException) + { + // Preference storage must never prevent an intentional connection. + System.Diagnostics.Debug.WriteLine($"Discovery address could not be saved: {ex.Message}"); + } + } + DialogResult = true; Close(); } diff --git a/MainWindow.RcbExport.cs b/MainWindow.RcbExport.cs index fbe25ad7d..bed12b760 100644 --- a/MainWindow.RcbExport.cs +++ b/MainWindow.RcbExport.cs @@ -499,6 +499,8 @@ private async Task ExportLegacySasRcbAsync( } var filteredModel = SclReportControlFilter.FilterLiveModel(exportModel, row.Reference); + var verifiedIdentity = await TrustedSclIdentityAuthority.TryMatchAsync( + device,filteredModel,cancellationToken); var liveResult = await Task.Run(() => AuthoritativeLiveIedSclExporter.WriteFiles( filteredModel, outputPath, @@ -507,7 +509,8 @@ private async Task ExportLegacySasRcbAsync( Profile = "full-model", SchemaProfile = schema, IpAddress = device.IpAddress, - IedNameOverride = device.Name, + IedNameOverride = verifiedIdentity?.IedName ?? device.Name, + VerifiedIdentity = verifiedIdentity, IncludeLowConfidenceTypes = true, IncludeRuntimeStateComment = false }), cancellationToken); diff --git a/MainWindow.xaml b/MainWindow.xaml index a8e0a084a..8ee6d221f 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -572,7 +572,7 @@ - + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index c2629ba5a..8b5553833 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -42,7 +42,7 @@ public partial class MainWindow : Window, INotifyPropertyChanged Interval = TimeSpan.FromSeconds(8) }; private Iec61850MonitorDevice? _selectedDevice; - private string _newDeviceIp = "192.168.1.10"; + private string _newDeviceIp = string.Empty; private string _newDevicePort = "102"; private int _pollingIntervalMs = 1000; private string _lastStatusText = "Ready. Add an IEC 61850 IED or open a saved ARSAS project."; @@ -157,7 +157,7 @@ private async void AddRelay_Click(object sender, RoutedEventArgs e) { var initialIp = SelectedDevice?.IpAddress ?? NewDeviceIp; var initialPort = SelectedDevice?.Port ?? 102; - var wizard = new IpConnectWizardWindow(initialIp, initialPort) { Owner = this }; + var wizard = new IpConnectWizardWindow(initialIp, initialPort, preferLastDiscoveryEndpoint: true) { Owner = this }; if (wizard.ShowDialog() != true) return; @@ -1047,7 +1047,8 @@ await _runtime.ConnectUsingCachedModelAsync( { SclEndpointBindingStore.RecordVerified( device.SclSourceSha256, device.SclIedName, - device.SclAccessPointName, device.IpAddress, device.Port); + device.SclAccessPointName, device.IpAddress, device.Port, + device.SclSourcePath); if (device.SclEndpointOrigin != "SCD-declared") device.SclEndpointOrigin = "Verified exact SCD/IED/AP"; } @@ -1821,7 +1822,21 @@ private async void IedSaveScl_Click(object sender, RoutedEventArgs e) return; } - SaveTypedModelAsScl(device, model, sourceDescription, schema, dialog.FileName); + AR.Iec61850.Discovery.LiveIedIdentity? provenIdentity = null; + if (device.SclWorkspace == null) + { + provenIdentity = await TrustedSclIdentityAuthority.TryMatchAsync( + device,model,_applicationCancellation.Token); + if (provenIdentity is not null) + AddLog("INFO","SCL Export", + $"{device.Name}: verified trusted SCL identity {provenIdentity.IedName} " + + $"and {provenIdentity.LogicalDeviceAliases.Count} LD mappings; MMS domains unchanged."); + else if (device.IdentitySource.Contains("(Low)",StringComparison.OrdinalIgnoreCase)) + AddLog("WARN","SCL Export", + $"{device.Name}: IED/LD naming is provisional (MMS domains alone do not prove the boundary). " + + "Open and verify the original engineering SCL on this IED to generate an authoritative identity."); + } + SaveTypedModelAsScl(device, model, sourceDescription, schema, dialog.FileName, provenIdentity); } catch (Exception ex) when (ex is IOException or InvalidOperationException or ArgumentException) { @@ -1884,7 +1899,8 @@ private void SaveTypedModelAsScl( AR.Iec61850.Discovery.LiveIedModelDiscoveryDocument model, string sourceDescription, SclSchemaProfileDescriptor schema, - string outputPath) + string outputPath, + AR.Iec61850.Discovery.LiveIedIdentity? verifiedIdentity = null) { LiveIedSclExportResult result; AR.Iec61850.Discovery.LiveIedCanonicalModel? canonicalExportEvidence = null; @@ -1905,7 +1921,8 @@ private void SaveTypedModelAsScl( canonical, outputPath, schema.Profile, - profile: "full-model"); + profile: "full-model", + verifiedIdentity: verifiedIdentity); try { var semanticPatch = SclExportSemanticParityPatch.ApplyForLiveModel( @@ -1914,7 +1931,8 @@ private void SaveTypedModelAsScl( canonicalReloadWorkspace = CanonicalSclReloadValidator.Validate( _sclWorkspaceService, canonical, - result); + result, + verifiedIdentity?.IedName); if (semanticPatch.Changed) { diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 11e25bd1b..eeb30e767 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -54,6 +54,12 @@ public sealed class Iec61850MonitorDevice : ObservableObject [JsonIgnore] public StaticAcquisitionParitySnapshot StaticAcquisitionParity { get; set; } = new(); + // Association-scoped only. A fresh Discovery must re-verify the original IID + // SHA and full domain topology; no persisted IP or filename is identity proof. + [JsonIgnore] + public LiveIedIdentity? TrustedLiveIdentity { get; set; } + + // Snapshot publication is atomic across runtime/diagnostic threads, never a // mutable live ReportStreams dictionary. Association reset clears its scope. [JsonIgnore] @@ -670,6 +676,47 @@ public sealed class Iec61850MonitorPoint : ObservableObject public string PointKey => $"{DeviceId}|{NormalizeReference(IecReference)}"; public string IecTelegram => StripIedNamePrefix(IecReference, DeviceName); + + // Explorer-only label: preserve every original IEC/MMS reference and signal name. + // "CSWI Pos" is ambiguous across CSWI1..CSWI16; wire references are not. + public string ExplorerSignalName + { + get + { + var signal = SignalName ?? string.Empty; + var reference = IecReference ?? string.Empty; + var slash = reference.IndexOf('/'); + if (slash < 0) + return signal; + + var start = slash + 1; + var relativeEnd = reference.AsSpan(start).IndexOfAny('.', '$'); + if (relativeEnd <= 0) + return signal; + + var logicalNode = reference.Substring(start, relativeEnd); + var digitAt = 0; + while (digitAt < logicalNode.Length && char.IsLetter(logicalNode[digitAt])) + digitAt++; + if (digitAt == 0 || digitAt == logicalNode.Length) + return signal; + for (var index = digitAt; index < logicalNode.Length; index++) + { + if (!char.IsDigit(logicalNode[index])) + return signal; + } + + if (signal.StartsWith(logicalNode, StringComparison.OrdinalIgnoreCase) && + (signal.Length == logicalNode.Length || + !char.IsDigit(signal[logicalNode.Length]))) + return signal; + + var logicalNodeClass = logicalNode[..digitAt]; + return signal.StartsWith(logicalNodeClass + " ", StringComparison.OrdinalIgnoreCase) + ? logicalNode + signal[logicalNodeClass.Length..] + : logicalNode + " · " + signal; + } + } public string Value { get => _value; diff --git a/Models/SignalDefinition.cs b/Models/SignalDefinition.cs index c52cbed9e..b6c1aa4c2 100644 --- a/Models/SignalDefinition.cs +++ b/Models/SignalDefinition.cs @@ -21,6 +21,7 @@ public class SignalDefinition : ObservableObject private string _controlModelText = "Auto-detect"; private Iec61850ControlModelKind _controlModelKind = Iec61850ControlModelKind.Unknown; private bool _controlModelResolved; + private bool _controlSemanticVerified; private string _controlValueType = string.Empty; private string _controlCurrentValue = "-"; private Iec61850ProcessValueBadge? _controlProcessValue; @@ -120,7 +121,19 @@ public string ControlModelText } public Iec61850ControlModelKind ControlModelKind => _controlModelKind; public bool ControlModelResolved => _controlModelResolved; - public bool ControlSupportsOperate => _controlModelResolved && _controlModelKind is + public bool ControlSemanticVerified + { + get => _controlSemanticVerified; + set + { + if (!Set(ref _controlSemanticVerified, value)) return; + Raise(nameof(ControlSupportsOperate)); + Raise(nameof(ControlCanConfirm)); + Raise(nameof(IsReadOnlyControl)); + RaiseControlActionProperties(); + } + } + public bool ControlSupportsOperate => _controlModelResolved && _controlSemanticVerified && _controlModelKind is Iec61850ControlModelKind.DirectNormal or Iec61850ControlModelKind.SboNormal or Iec61850ControlModelKind.DirectEnhanced or @@ -449,7 +462,7 @@ public bool IsRaiseLowerControl { if (!CanExposeControlActions || IsPositionControl || IsRaiseOnlyControl || IsLowerOnlyControl) return false; var cdc = (ControlCdc ?? string.Empty).Trim().ToUpperInvariant(); - return cdc is "INC" or "ISC" or "INC/ISC"; + return cdc == "BSC"; } } public bool IsBooleanControl => CanExposeControlActions && !IsPositionControl && !IsRaiseOnlyControl && !IsLowerOnlyControl && !IsRaiseLowerControl && @@ -460,7 +473,7 @@ public bool IsSetPointControl { if (!CanExposeControlActions || IsPositionControl || IsRaiseOnlyControl || IsLowerOnlyControl || IsRaiseLowerControl || IsBooleanControl) return false; var cdc = (ControlCdc ?? string.Empty).Trim().ToUpperInvariant(); - return cdc is "APC" or "BAC" or "BSC"; + return cdc is "APC" or "BAC" or "INC" or "ISC"; } } public bool IsGenericControl => !CanExposeControlActions || @@ -477,8 +490,8 @@ public string ControlActionLabel if (IsLowerOnlyControl) return "Lower"; var cdc = (ControlCdc ?? string.Empty).Trim().ToUpperInvariant(); if (cdc == "DPC") return "Open / Close"; - if (cdc is "INC" or "ISC") return "Raise / Lower"; - if (cdc == "BSC") return "Tap / step position"; + if (cdc is "INC" or "ISC") return "Numeric control"; + if (cdc == "BSC") return "Raise / Lower tap"; if (cdc == "SPC") { var commandText = $"{Name} {ObjectReference}"; diff --git a/Services/CanonicalSclReloadValidator.cs b/Services/CanonicalSclReloadValidator.cs index d47e072a2..c3c5d26d8 100644 --- a/Services/CanonicalSclReloadValidator.cs +++ b/Services/CanonicalSclReloadValidator.cs @@ -9,12 +9,18 @@ public static class CanonicalSclReloadValidator public static SclIedWorkspace Validate( SclWorkspaceService workspaceService, LiveIedCanonicalModel canonical, - LiveIedSclExportResult result) + LiveIedSclExportResult result, + string? verifiedIedName = null) { ArgumentNullException.ThrowIfNull(workspaceService); ArgumentNullException.ThrowIfNull(canonical); ArgumentNullException.ThrowIfNull(result); + // Only the engineering IED identity changes; verified MMS/ACSE endpoint + // and accepted association selectors remain owned by the live snapshot. + var effectiveIedName = string.IsNullOrWhiteSpace(verifiedIedName) + ? canonical.IedName : verifiedIedName.Trim(); + if (!string.Equals(result.Profile, "full-model", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( @@ -31,19 +37,19 @@ public static SclIedWorkspace Validate( result.SclPath, new SclWorkspaceOpenOptions { - IedName = canonical.IedName, + IedName = effectiveIedName, AccessPointName = canonical.AccessPointName }); var workspace = reloaded.Ieds.SingleOrDefault() ?? throw new InvalidOperationException( - $"Generated SCL could not be reopened as exactly one ARSAS workspace for '{canonical.IedName}/{canonical.AccessPointName}'."); + $"Generated SCL could not be reopened as exactly one ARSAS workspace for '{effectiveIedName}/{canonical.AccessPointName}'."); - if (!string.Equals(workspace.IedName, canonical.IedName, StringComparison.Ordinal) || + if (!string.Equals(workspace.IedName, effectiveIedName, StringComparison.Ordinal) || !string.Equals(workspace.AccessPointName, canonical.AccessPointName, StringComparison.Ordinal)) { throw new InvalidOperationException( - $"Generated SCL reload identity drifted. Expected '{canonical.IedName}/{canonical.AccessPointName}', " + + $"Generated SCL reload identity drifted. Expected '{effectiveIedName}/{canonical.AccessPointName}', " + $"reloaded '{workspace.IedName}/{workspace.AccessPointName}'."); } @@ -80,7 +86,7 @@ public static SclIedWorkspace Validate( var preparation = SclAssistedConnectionPreparationBuilder.Build( File.ReadAllText(result.SclPath), - canonical.IedName, + effectiveIedName, canonical.AccessPointName, canonical.Communication.Host, canonical.Communication.Port); @@ -94,13 +100,13 @@ public static SclIedWorkspace Validate( } var plan = preparation.AssociationPlan; - if (!string.Equals(plan.IedName, canonical.IedName, StringComparison.Ordinal) || + if (!string.Equals(plan.IedName, effectiveIedName, StringComparison.Ordinal) || !string.Equals(plan.AccessPointName, canonical.AccessPointName, StringComparison.Ordinal) || !string.Equals(plan.Host, canonical.Communication.Host, StringComparison.OrdinalIgnoreCase) || plan.Port != canonical.Communication.Port) { throw new InvalidOperationException( - $"Generated SCL reconnect plan identity drifted. Expected '{canonical.IedName}/{canonical.AccessPointName}' " + + $"Generated SCL reconnect plan identity drifted. Expected '{effectiveIedName}/{canonical.AccessPointName}' " + $"at {canonical.Communication.Host}:{canonical.Communication.Port}, rebuilt " + $"'{plan.IedName}/{plan.AccessPointName}' at {plan.Host}:{plan.Port}."); } diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index ddf92a589..336332cdd 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -12,7 +12,7 @@ namespace ArIED61850Tester.Services; internal static class DiagnosticReportBuilder { private sealed record TcpProbeResult(string DeviceId, string Endpoint, string Result, string Detail); - private sealed record AdapterIpv4(string Name, NetworkInterfaceType Type, IPAddress Address, int PrefixLength); + private sealed record AdapterIpv4(string InterfaceId, string Name, NetworkInterfaceType Type, IPAddress Address, int PrefixLength); private sealed record RouteAnalysis(string Source, string MatchingAdapters, string AdapterMatrix, string Note); public static string BuildEmergency( @@ -291,6 +291,9 @@ internal static void AppendReportContinuity( $"streams={snapshot.StreamCount}, findings={snapshot.Findings}, " + $"BufOvfl={snapshot.Overflows}, untracked={snapshot.UntrackedStreamCount}, " + $"OptFldsDecoded={snapshot.OptFldsDecoded}, OptFldsUnknown={snapshot.OptFldsUnknown}"); + builder.AppendLine($" Frame triage : framesWithFindings={snapshot.AnomalousFrames}, " + + $"sequenceDiscontinuities={snapshot.SequenceDiscontinuities}; " + + "these are observational counts, not confirmed lost SOE events."); builder.AppendLine(" Qualification : ARIEC decoder-sourced wire OptFlds evidence; " + "OptFldsUnknown is not proof of absent wire fields. No alerts does not prove SOE/event continuity, " + "GI causality or reconnect/replay completeness."); @@ -311,6 +314,51 @@ static string Safe(string value) $"BufOvfl={stream.Overflow}, ConfRevChanges={stream.ConfRevChanges}, " + $"EntryIDPresent={stream.EntryIdPresent}, firstSqNum={stream.FirstSqNum?.ToString() ?? "-"}, " + $"lastSqNum={stream.LastSqNum?.ToString() ?? "-"}"); + if (stream.Findings > 0) + builder.AppendLine($" RCB triage : anomalousFrames={stream.AnomalousFrames}, " + + $"sqDiscontinuities={stream.SequenceDiscontinuities}, " + + $"sqChangedEntryID={stream.SequenceWithChangedEntryId}, " + + $"sqRepeatedEntryID={stream.SequenceWithRepeatedEntryId}, " + + $"sqEntryIDUnknown={stream.SequenceWithoutComparableEntryId}, " + + $"sqWithGIreason={stream.SequenceWithGiReason}, " + + $"sqWithBufOvfl={stream.SequenceWithOverflow}; " + + "EntryID difference/identical is not an event-loss or harmless-replay conclusion."); + if (stream.Buffered && stream.Findings > 0) + builder.AppendLine($" BRCB context : {ExplainBrcbContinuity(stream)}"); + if (stream.AnomalousFrames > 0) + builder.AppendLine($" RCB follow-up : receivedAfterLastFinding={stream.FramesAfterLastFinding}, " + + $"firstFrameUtc={stream.FirstFrameReceivedAtUtc?.ToString("O") ?? "-"}, " + + $"lastFindingUtc={stream.LastFindingReceivedAtUtc?.ToString("O") ?? "-"}, " + + $"lastFrameUtc={stream.LastFrameReceivedAtUtc?.ToString("O") ?? "-"}; " + + "later clean frames are observed report traffic, not proof of complete buffered SOE."); + // Chronology is a small immutable copy of decoder metadata only: + // first 32 frames plus 8 most recent frames; no raw EntryID or values. + if (stream.Buffered && stream.Chronology.Count > 0) + { + builder.AppendLine($" BRCB chronology: first={stream.ChronologyInitialCount}, " + + $"recent={stream.ChronologyRecentCount}, omittedMiddle={stream.ChronologyOmittedFrames}; " + + "receive-order metadata only, not TCP/wire-order or SOE completeness proof."); + foreach (var item in stream.Chronology) + builder.AppendLine($" frame#{item.FrameOrdinal}: " + + $"receivedUtc={item.ReceivedAtUtc?.ToString("O") ?? "unknown"}, " + + $"SqNum={item.SequenceNumber?.ToString() ?? "unknown"}, " + + $"GIreason={Verdict(item.GiReason)}, " + + $"BufOvfl={Verdict(item.BufferOverflow)}, " + + $"EntryIDrelation={item.EntryIdRelation}, findings={item.FindingCount}"); + } + if (stream.Buffered && stream.RecentAnomalies.Count > 0) + { + builder.AppendLine($" BRCB anomaly history: last={stream.RecentAnomalies.Count} " + + $"of {stream.AnomalousFrames} anomalous frames; bounded decoder receive-order evidence. " + + "Some entries may also appear in the first/recent chronology."); + foreach (var item in stream.RecentAnomalies) + builder.AppendLine($" anomalyFrame#{item.FrameOrdinal}: " + + $"receivedUtc={item.ReceivedAtUtc?.ToString("O") ?? "unknown"}, " + + $"SqNum={item.SequenceNumber?.ToString() ?? "unknown"}, " + + $"GIreason={Verdict(item.GiReason)}, " + + $"BufOvfl={Verdict(item.BufferOverflow)}, " + + $"EntryIDrelation={item.EntryIdRelation}, findings={item.FindingCount}"); + } builder.AppendLine($" OptFlds : decoded={stream.OptFldsDecoded}/{stream.Frames}, " + $"unknown={stream.OptFldsUnknown}, lastMask={Safe(stream.LastOptFldsHex)}, " + $"SqNum requested={stream.SqNumAdvertised}, omitted={stream.SqNumOmitted}, " + @@ -338,6 +386,41 @@ static string Safe(string value) builder.AppendLine($" stream list : TRUNCATED • shown={snapshot.Streams.Count}, total={snapshot.StreamCount}"); } + /// + /// IEC 61850-aware interpretation of already observed RCB metadata: + /// temporal context and GI reason are correlations, never proof of lost + /// buffered entries, a server reset, or that a sequence gap is harmless. + /// No extra MMS traffic, raw event retention or timer. + /// + internal static string ExplainBrcbContinuity(Iec61850ReportContinuityStreamSnapshot stream) + { + ArgumentNullException.ThrowIfNull(stream); + var withoutProvenGi = Math.Max(0, stream.SequenceDiscontinuities - stream.SequenceWithGiReason); + var context = $"sequenceDiscontinuities={stream.SequenceDiscontinuities}, " + + $"GI-tagged={stream.SequenceWithGiReason}, withoutConfirmedGI={withoutProvenGi}, " + + $"BufOvflAdvertised={stream.Overflow}"; + + if (stream.ActivationReturnedAtUtc is { } activation && + stream.LastAnomalyReceivedAtUtc is { } anomaly && + anomaly >= activation) + { + var delta = anomaly - activation; + var elapsed = delta.TotalMilliseconds; + if (double.IsFinite(elapsed)) + { + context += $", lastAnomalyAfterStartMs={elapsed:0}"; + if (delta >= TimeSpan.FromSeconds(5)) + context += "; late recurrence observed beyond 5s of RCB activation (NOT startup-only)"; + } + } + else + { + context += "; timing=UNKNOWN (insufficient RCB lifecycle evidence)"; + } + + return context + "; received-frame order only, not TCP wire order, event-loss or GI causality proof"; + } + private static string Verdict(bool? value) => value is true ? "YES" : value is false ? "NO" : "UNKNOWN"; @@ -411,8 +494,10 @@ private static RouteAnalysis AnalyzeRoute(string targetText) return new RouteAnalysis("unavailable", "unavailable", "unavailable", "Route analysis currently supports IPv4 endpoints."); var adapters = GetActiveIpv4Adapters(); - var matching = adapters + var matchingAddresses = adapters .Where(item => IsInSameSubnet(item.Address, target, item.PrefixLength)) + .ToArray(); + var matching = matchingAddresses .Select(item => $"{item.Name}={item.Address}/{item.PrefixLength}") .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -440,19 +525,12 @@ private static RouteAnalysis AnalyzeRoute(string targetText) source = $"unavailable ({ex.GetType().Name}: {ex.Message})"; } - string note; - if (matching.Length == 0) - { - note = $"No active IPv4 adapter is in the same subnet as {target}. Check the PC adapter address, prefix length, VLAN, and static route."; - } - else if (matching.Length == 1) - { - note = $"{target} is directly reachable only through {matching[0]}. Ensure the IED is physically connected to that network."; - } - else - { - note = $"Multiple adapters match {target}; Windows route priority can select the wrong interface. Disable unused adapters or configure interface metrics/static routes."; - } + // Do not confuse multiple IP aliases on ONE KM-TEST interface with + // competing network adapters. Exact local IP means same-host endpoint; + // neither condition warrants telling operators to disable interfaces. + var note = ExplainIpv4Route(target, + matchingAddresses.Select(item => (item.InterfaceId, item.Name)).ToArray(), + adapters.Any(item => item.Address.Equals(target))); return new RouteAnalysis( source, @@ -461,6 +539,39 @@ private static RouteAnalysis AnalyzeRoute(string targetText) note); } + /// + /// Pure, testable interface-aware route advice. The interface ID, not the + /// number of matching IPv4 addresses, defines routing ambiguity. This is + /// descriptive only: the actual OS routing table remains authoritative. + /// + internal static string ExplainIpv4Route( + IPAddress target, + IReadOnlyList<(string InterfaceId, string AdapterName)> matchedAddresses, + bool targetAssignedLocally) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(matchedAddresses); + var distinctInterfaces = matchedAddresses + .GroupBy(item => item.InterfaceId, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First().AdapterName) + .ToArray(); + if (targetAssignedLocally) + return $"Target {target} is assigned to this PC on " + + $"{(distinctInterfaces.Length == 0 ? "a local interface" : string.Join(", ", distinctInterfaces))}; " + + "this can be a same-host IED simulator. Do not infer a competing NIC from IPv4 aliases. " + + "Check the local MMS listener and actual OS route if connection fails."; + if (distinctInterfaces.Length == 0) + return $"No directly matching IPv4 subnet for {target}; a gateway or static route can still provide connectivity. " + + "Check the Windows routing table before drawing a connectivity conclusion."; + if (distinctInterfaces.Length == 1) + return $"One network interface ({distinctInterfaces[0]}) matches {target}, across " + + $"{matchedAddresses.Count} IPv4 address(es). IP aliases are not competing adapters; " + + "confirm actual OS routing and physical/virtual IED connection if needed."; + return $"{distinctInterfaces.Length} distinct network interfaces match {target} " + + $"({string.Join(", ", distinctInterfaces)}); Windows interface metrics/routing table may select " + + "a different path. Check the OS route before changing adapter configuration."; + } + private static AdapterIpv4[] GetActiveIpv4Adapters() { try @@ -471,6 +582,7 @@ private static AdapterIpv4[] GetActiveIpv4Adapters() .SelectMany(adapter => adapter.GetIPProperties().UnicastAddresses .Where(address => address.Address.AddressFamily == AddressFamily.InterNetwork) .Select(address => new AdapterIpv4( + adapter.Id, adapter.Name, adapter.NetworkInterfaceType, address.Address, diff --git a/Services/Iec61850BscStepControlPolicy.cs b/Services/Iec61850BscStepControlPolicy.cs new file mode 100644 index 000000000..52db8948c --- /dev/null +++ b/Services/Iec61850BscStepControlPolicy.cs @@ -0,0 +1,66 @@ +using System.Globalization; +using ArControl = AR.Iec61850.Control; + +namespace ArIED61850Tester.Services; + +/// +/// IEC 61850 BSC ctlVal is directional Tcmd (0 stop, 1 lower, 2 higher, +/// 3 reserved). Not a numeric tap-position setpoint or a DPC Open/Close. +/// No wire request is built without the exact allowed live MMS type. +/// +internal static class Iec61850BscStepControlPolicy +{ + internal static bool TryBind( + string mmsType, int? bitWidth, string command, + out ArControl.Iec61850ControlValue? value, + out string expected, out string error) + { + value = null; + expected = string.Empty; + error = string.Empty; + var supported = mmsType is "integer" or "unsigned" || + (mmsType == "bit-string" && bitWidth == 2); + if (!supported) + { + error = $"BSC Tcmd is not safely supported by live MMS ctlVal type '{mmsType}' (bits={bitWidth}); no control write was sent."; + return false; + } + + var direction = (command ?? string.Empty).Trim().ToLowerInvariant() switch + { + "raise" or "higher" or "up" => 2L, + "lower" or "down" => 1L, + _ => -1L + }; + if (direction < 0) + { + error = "BSC TapChg accepts only Raise or Lower. It does not accept tap-position setpoints, Open/Close, Stop, or reserved Tcmd values."; + return false; + } + + value = mmsType == "unsigned" + ? ArControl.Iec61850ControlValue.Unsigned((ulong)direction) + : ArControl.Iec61850ControlValue.Integer(direction); + expected = direction == 2 ? "Raise" : "Lower"; + return true; + } + + internal static bool MovementConfirmed(string requested, string? before, string? after) + { + if (!TryExactNumericFeedback(before, out var baseline) || + !TryExactNumericFeedback(after, out var observed)) + return false; + + return requested.Trim().ToLowerInvariant() switch + { + "raise" or "higher" or "up" => observed > baseline, + "lower" or "down" => observed < baseline, + _ => false + }; + } + + private static bool TryExactNumericFeedback(string? text, out decimal result) + => decimal.TryParse(text?.Trim(), + NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, + CultureInfo.InvariantCulture, out result); +} diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 1d1577c31..61fc6d56f 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -142,6 +142,8 @@ public async Task> ConnectAndDiscoverAsync( progress?.Report(new IedDiscoveryProgress(IedDiscoveryStage.PreparingSession, "Preparing an independent IEC 61850 session…", 2d, 1, 15)); await StopDeviceAsync(device.DeviceId).ConfigureAwait(false); + // Previous sessions cannot authorize another endpoint or association. + device.TrustedLiveIdentity = null; var session = new DeviceSession { @@ -195,6 +197,69 @@ public async Task> ConnectAndDiscoverAsync( progress?.Report(new IedDiscoveryProgress(IedDiscoveryStage.PreparingWorkspace, "Filtering and ordering the discovered signal workspace…", 98d, 14, 15)); var identity = session.Client.DetectedIdentity; + if (device.LiveDiscoveryModel is { } liveModel) + { + if (!string.IsNullOrWhiteSpace(device.SclSourcePath) && + !string.IsNullOrWhiteSpace(device.SclSourceSha256)) + { + try + { + var proof = await TrustedSclIdentityAuthority.TryMatchAsync( + device, liveModel, cancellationToken).ConfigureAwait(false); + if (proof is not null) + { + identity = TrustedSclIdentityAuthority.ToConsumerIdentity( + proof, liveModel.LogicalDevices.Select(ld => ld.MmsDomain)); + device.TrustedLiveIdentity = proof; + Log("INFO", device.Name, + $"Trusted SCL topology verified: IED={proof.IedName}; " + + $"LD={string.Join(",", proof.LogicalDeviceAliases.Values)}; " + + $"MMS domains preserved; source={proof.Source}."); + } + else + { + Log("WARN", device.Name, + "Opened SCL did not uniquely match this endpoint's full MMS domain set. " + + "Discovery identity remains provisional; no vendor-based rename."); + } + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) when (ex is IOException or InvalidDataException or System.Xml.XmlException + or ArgumentException or UnauthorizedAccessException) + { + Log("WARN", device.Name, + $"Trusted SCL identity verification was rejected: {ex.Message}. " + + "Discovery can continue, but identity is provisional until SCL is re-opened."); + } + } + else + { + // Discovery by IP only: reuse only a *previously successful* + // SCL-assisted endpoint whose unchanged IID bytes and full live + // MMS domain topology are freshly matched. Never attach the + // SCL workspace or change report/GOOSE acquisition authority. + var remembered = await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + device, liveModel, cancellationToken).ConfigureAwait(false); + if (remembered is not null) + { + identity = TrustedSclIdentityAuthority.ToConsumerIdentity( + remembered.Identity, liveModel.LogicalDevices.Select(ld => ld.MmsDomain)); + device.TrustedLiveIdentity = remembered.Identity; + Log("INFO", device.Name, + $"IP Discovery identity independently verified: " + + $"IED={remembered.Identity.IedName}; AP={remembered.AccessPointName}; " + + $"IID SHA-256={remembered.SourceSha256}; source=prior verified SCL-assisted association. " + + "Live MMS discovery and static DataSet authority remain unchanged."); + } + else + { + Log("INFO", device.Name, + "IP Discovery IEDName remains provisional: no unique, unchanged " + + "previously verified SCL identity matched this endpoint and full MMS domain set. " + + "Open the original engineering IID and connect once to establish a reusable identity proof."); + } + } + } if (!string.IsNullOrWhiteSpace(identity.IedName)) { var previousName = device.Name; diff --git a/Services/Iec61850ReportContinuityInspector.cs b/Services/Iec61850ReportContinuityInspector.cs index af561f6c8..4f7d44c14 100644 --- a/Services/Iec61850ReportContinuityInspector.cs +++ b/Services/Iec61850ReportContinuityInspector.cs @@ -16,6 +16,9 @@ internal sealed class Iec61850ReportContinuityState public bool AwaitingMoreSegments { get; set; } public ulong? ConfigurationRevision { get; set; } public string LastEntryIdHex { get; set; } = string.Empty; + // A decoded value without raw OptionFields provenance cannot certify that + // the prior wire report advertised an EntryID. Do not treat it as comparable. + public bool LastEntryIdProvenByOptFlds { get; set; } // All counts belong to this exact report stream within the current // association. No static state, no process/control-plane side effects. @@ -28,6 +31,30 @@ internal sealed class Iec61850ReportContinuityState public long MissingSequenceFrames { get; set; } public long SegmentedFrames { get; set; } public long AnomalyFindings { get; set; } + // Frame counts stay independent of findings: one frame may carry multiple anomalies. + public long AnomalousFrames { get; set; } + // Bounded per-association receive-order evidence. No raw event history or timers. + public long FramesAfterLastFinding { get; set; } + public DateTimeOffset? FirstFrameReceivedAtUtc { get; set; } + public DateTimeOffset? LastFrameReceivedAtUtc { get; set; } + public DateTimeOffset? LastFindingReceivedAtUtc { get; set; } + // Head + tail chronology: fixed-size per BRCB and allocated only on demand. + public Iec61850BrcbChronologyFrame[]? InitialChronology { get; set; } + public int InitialChronologyCount { get; set; } + public Iec61850BrcbChronologyFrame[]? RecentChronology { get; set; } + public int RecentChronologyCount { get; set; } + public int RecentChronologyNext { get; set; } + // Retain the last eight anomalous BRCB frames even if they fall in the + // omitted middle of a long healthy stream. O(1) memory per buffered RCB. + public Iec61850BrcbChronologyFrame[]? AnomalyChronology { get; set; } + public int AnomalyChronologyCount { get; set; } + public int AnomalyChronologyNext { get; set; } + public long SequenceDiscontinuities { get; set; } + public long SequenceWithChangedEntryId { get; set; } + public long SequenceWithRepeatedEntryId { get; set; } + public long SequenceWithoutComparableEntryId { get; set; } + public long SequenceWithGiReason { get; set; } + public long SequenceWithOverflow { get; set; } public long BufferOverflows { get; set; } public long ConfRevChanges { get; set; } public long EntryIdPresentFrames { get; set; } @@ -53,6 +80,21 @@ internal sealed class Iec61850ReportContinuityState public DateTimeOffset? LastAnomalyReceivedAtUtc { get; set; } } +/// +/// Decoder-metadata-only evidence: no raw EntryID, process values, or packet bodies. +/// FrameOrdinal is receive order, not a server event sequence or TCP packet index. +/// +public readonly record struct Iec61850BrcbChronologyFrame +{ + public long FrameOrdinal { get; init; } + public DateTimeOffset? ReceivedAtUtc { get; init; } + public ulong? SequenceNumber { get; init; } + public bool? GiReason { get; init; } + public bool? BufferOverflow { get; init; } + public string? EntryIdRelation { get; init; } + public int FindingCount { get; init; } +} + /// /// Immutable copyable diagnostic evidence. Counts describe decoded metadata, /// NOT completeness of SOE delivery or absence of lost events. @@ -68,6 +110,24 @@ public sealed record Iec61850ReportContinuityStreamSnapshot public long SequenceMissing { get; init; } public long Segmented { get; init; } public long Findings { get; init; } + public long AnomalousFrames { get; init; } + public long FramesAfterLastFinding { get; init; } + public DateTimeOffset? FirstFrameReceivedAtUtc { get; init; } + public DateTimeOffset? LastFrameReceivedAtUtc { get; init; } + public DateTimeOffset? LastFindingReceivedAtUtc { get; init; } + public IReadOnlyList Chronology { get; init; } = + Array.Empty(); + public int ChronologyInitialCount { get; init; } + public int ChronologyRecentCount { get; init; } + public long ChronologyOmittedFrames { get; init; } + public IReadOnlyList RecentAnomalies { get; init; } = + Array.Empty(); + public long SequenceDiscontinuities { get; init; } + public long SequenceWithChangedEntryId { get; init; } + public long SequenceWithRepeatedEntryId { get; init; } + public long SequenceWithoutComparableEntryId { get; init; } + public long SequenceWithGiReason { get; init; } + public long SequenceWithOverflow { get; init; } public long Overflow { get; init; } public long ConfRevChanges { get; init; } public long EntryIdPresent { get; init; } @@ -104,6 +164,8 @@ public sealed record Iec61850ReportContinuitySnapshot public long Frames { get; init; } public long Sequenced { get; init; } public long Findings { get; init; } + public long AnomalousFrames { get; init; } + public long SequenceDiscontinuities { get; init; } public long Overflows { get; init; } public long OptFldsDecoded { get; init; } public long OptFldsUnknown { get; init; } @@ -115,6 +177,9 @@ public sealed record Iec61850ReportContinuitySnapshot internal static class Iec61850ReportContinuityInspector { private const ulong MaxSequenceNumber = ushort.MaxValue; + private const int InitialChronologyLimit = 32; + private const int RecentChronologyLimit = 8; + private const int AnomalyChronologyLimit = 8; /// /// A gap/backward move is an OBSERVED discontinuity, not proof of a specific @@ -130,6 +195,11 @@ internal static IReadOnlyList Observe( ArgumentNullException.ThrowIfNull(frame); if (state.FramesSeen < long.MaxValue) state.FramesSeen++; + if (frame.ReceivedAt != default) + { + state.FirstFrameReceivedAtUtc ??= frame.ReceivedAt; + state.LastFrameReceivedAtUtc = frame.ReceivedAt; + } if (frame.SequenceNumber.HasValue) { if (state.SequencedFrames < long.MaxValue) state.SequencedFrames++; @@ -158,6 +228,7 @@ void Warn(string message) } var priorEntryIdPresent = !string.IsNullOrEmpty(state.LastEntryIdHex); + var priorEntryIdProven = state.LastEntryIdProvenByOptFlds; var entryIdPresent = !string.IsNullOrEmpty(frame.EntryIdHex); var entryIdChanged = priorEntryIdPresent && entryIdPresent && !string.Equals(state.LastEntryIdHex, frame.EntryIdHex, StringComparison.OrdinalIgnoreCase); @@ -176,6 +247,34 @@ string CausalContext() => $"startReturnUtc={state.ActivationReturnedAtUtc?.ToString("O") ?? "unknown"}, " + $"frameReceivedUtc={frame.ReceivedAt:O}; GI correlation is not proof of a harmless reset"; + // One causal classification per actually observed SqNum discontinuity. + // EntryID is an opaque octet string, never a numeric continuity proof. + void RecordSequenceCorrelation() + { + if (state.SequenceDiscontinuities < long.MaxValue) + state.SequenceDiscontinuities++; + if (buffered && priorEntryIdPresent && entryIdPresent && + state.LastEntryIdProvenByOptFlds && frame.OptFldsEntryId == true && + IsBoundedHex(frame.OptFldsRawHex)) + { + if (entryIdChanged) + { + if (state.SequenceWithChangedEntryId < long.MaxValue) + state.SequenceWithChangedEntryId++; + } + else if (state.SequenceWithRepeatedEntryId < long.MaxValue) + state.SequenceWithRepeatedEntryId++; + } + else if (state.SequenceWithoutComparableEntryId < long.MaxValue) + state.SequenceWithoutComparableEntryId++; + + if (frame.GeneralInterrogationReasonSeen == true && + state.SequenceWithGiReason < long.MaxValue) + state.SequenceWithGiReason++; + if (frame.BufferOverflow == true && state.SequenceWithOverflow < long.MaxValue) + state.SequenceWithOverflow++; + } + // Raw OptFlds is authoritative decoder evidence. Unknown stays UNKNOWN; // a missing value cannot be interpreted as an omitted wire field. var hasOptFlds = frame.OptFldsSequenceNumber.HasValue && @@ -278,6 +377,7 @@ string CausalContext() => if (discontinuity is not null) { CaptureAnomaly(state, frame); + RecordSequenceCorrelation(); Warn(discontinuity + EntryContext() + CausalContext()); } } @@ -308,6 +408,7 @@ string CausalContext() => if (discontinuity is not null) { CaptureAnomaly(state, frame); + RecordSequenceCorrelation(); Warn(discontinuity + EntryContext() + CausalContext()); } state.LastSequenceNumber = current; @@ -319,8 +420,54 @@ string CausalContext() => } if (entryIdPresent) + { state.LastEntryIdHex = frame.EntryIdHex; + state.LastEntryIdProvenByOptFlds = hasOptFlds && frame.OptFldsEntryId == true; + } + else + { + // Missing current EntryID invalidates comparisons with future frames; + // never compare across an opaque, unobserved resume cursor. + state.LastEntryIdHex = string.Empty; + state.LastEntryIdProvenByOptFlds = false; + } + + if (buffered) + { + // Observational classification only. Both OptFlds must prove the + // opaque cursor to label successive values as changed or repeated. + var comparableEntryId = priorEntryIdPresent && entryIdPresent && + priorEntryIdProven && hasOptFlds && frame.OptFldsEntryId == true; + var relation = !entryIdPresent ? "absent" : + !comparableEntryId ? "unverified" : entryIdChanged ? "changed" : "repeated"; + var evidence = new Iec61850BrcbChronologyFrame + { + FrameOrdinal = state.FramesSeen, + ReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt, + SequenceNumber = frame.SequenceNumber, + GiReason = frame.GeneralInterrogationReasonSeen, + BufferOverflow = frame.BufferOverflow, + EntryIdRelation = relation, + FindingCount = findings?.Count ?? 0 + }; + RecordChronology(state, evidence); + if (evidence.FindingCount > 0) + RecordAnomalyChronology(state, evidence); + } + if (findings is not null) + { + if (state.AnomalousFrames < long.MaxValue) + state.AnomalousFrames++; + state.FramesAfterLastFinding = 0; + // Missing decoder timestamps remain unknown; do not reuse older time. + state.LastFindingReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt; + } + else if (state.AnomalousFrames > 0 && state.FramesAfterLastFinding < long.MaxValue) + { + // Receive-order observation only; no claim of event delivery completeness. + state.FramesAfterLastFinding++; + } return findings is null ? Array.Empty() : findings; } @@ -348,6 +495,24 @@ internal static Iec61850ReportContinuitySnapshot Snapshot( SequenceMissing = state.MissingSequenceFrames, Segmented = state.SegmentedFrames, Findings = state.AnomalyFindings, + AnomalousFrames = state.AnomalousFrames, + FramesAfterLastFinding = state.FramesAfterLastFinding, + FirstFrameReceivedAtUtc = state.FirstFrameReceivedAtUtc, + LastFrameReceivedAtUtc = state.LastFrameReceivedAtUtc, + LastFindingReceivedAtUtc = state.LastFindingReceivedAtUtc, + Chronology = CopyChronology(state), + RecentAnomalies = CopyAnomalyChronology(state), + ChronologyInitialCount = state.InitialChronologyCount, + ChronologyRecentCount = state.RecentChronologyCount, + ChronologyOmittedFrames = state.Buffered + ? Math.Max(0, state.FramesSeen - state.InitialChronologyCount - state.RecentChronologyCount) + : 0, + SequenceDiscontinuities = state.SequenceDiscontinuities, + SequenceWithChangedEntryId = state.SequenceWithChangedEntryId, + SequenceWithRepeatedEntryId = state.SequenceWithRepeatedEntryId, + SequenceWithoutComparableEntryId = state.SequenceWithoutComparableEntryId, + SequenceWithGiReason = state.SequenceWithGiReason, + SequenceWithOverflow = state.SequenceWithOverflow, Overflow = state.BufferOverflows, ConfRevChanges = state.ConfRevChanges, EntryIdPresent = state.EntryIdPresentFrames, @@ -381,6 +546,8 @@ internal static Iec61850ReportContinuitySnapshot Snapshot( Frames = ordered.Sum(state => state.FramesSeen), Sequenced = ordered.Sum(state => state.SequencedFrames), Findings = ordered.Sum(state => state.AnomalyFindings), + AnomalousFrames = ordered.Sum(state => state.AnomalousFrames), + SequenceDiscontinuities = ordered.Sum(state => state.SequenceDiscontinuities), Overflows = ordered.Sum(state => state.BufferOverflows), OptFldsDecoded = ordered.Sum(state => state.OptFldsDecodedFrames), OptFldsUnknown = ordered.Sum(state => state.OptFldsUnknownFrames), @@ -388,6 +555,72 @@ internal static Iec61850ReportContinuitySnapshot Snapshot( }; } + private static void RecordChronology( + Iec61850ReportContinuityState state, Iec61850BrcbChronologyFrame item) + { + if (state.InitialChronologyCount < InitialChronologyLimit) + { + var initial = state.InitialChronology ??= new Iec61850BrcbChronologyFrame[InitialChronologyLimit]; + initial[state.InitialChronologyCount++] = item; + } + else + { + var recent = state.RecentChronology ??= new Iec61850BrcbChronologyFrame[RecentChronologyLimit]; + recent[state.RecentChronologyNext] = item; + state.RecentChronologyNext = (state.RecentChronologyNext + 1) % RecentChronologyLimit; + if (state.RecentChronologyCount < RecentChronologyLimit) + state.RecentChronologyCount++; + } + } + + private static void RecordAnomalyChronology( + Iec61850ReportContinuityState state, Iec61850BrcbChronologyFrame item) + { + var ring = state.AnomalyChronology ??= + new Iec61850BrcbChronologyFrame[AnomalyChronologyLimit]; + ring[state.AnomalyChronologyNext] = item; + state.AnomalyChronologyNext = + (state.AnomalyChronologyNext + 1) % AnomalyChronologyLimit; + if (state.AnomalyChronologyCount < AnomalyChronologyLimit) + state.AnomalyChronologyCount++; + } + + private static IReadOnlyList CopyAnomalyChronology( + Iec61850ReportContinuityState state) + { + var count = state.AnomalyChronologyCount; + if (count == 0) + return Array.Empty(); + var copy = new Iec61850BrcbChronologyFrame[count]; + for (var index = 0; index < count; index++) + { + var source = (state.AnomalyChronologyNext - count + index + + AnomalyChronologyLimit) % AnomalyChronologyLimit; + copy[index] = state.AnomalyChronology![source]; + } + return copy; + } + + private static IReadOnlyList CopyChronology( + Iec61850ReportContinuityState state) + { + var initialCount = state.InitialChronologyCount; + var recentCount = state.RecentChronologyCount; + if (initialCount + recentCount == 0) + return Array.Empty(); + + var copy = new Iec61850BrcbChronologyFrame[initialCount + recentCount]; + if (initialCount > 0) + Array.Copy(state.InitialChronology!, 0, copy, 0, initialCount); + for (var index = 0; index < recentCount; index++) + { + var source = (state.RecentChronologyNext - recentCount + index + RecentChronologyLimit) + % RecentChronologyLimit; + copy[initialCount + index] = state.RecentChronology![source]; + } + return copy; + } + private static string WriteVerdict(bool? value) => value is true ? "accepted/yes" : value is false ? "rejected/no" : "UNKNOWN"; diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index f8dd38337..8ab80c5c0 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1098,7 +1098,9 @@ public async Task InspectControlAsync( var control = await GetOrOpenControlSessionAsync(signal, cancellationToken).ConfigureAwait(false); var descriptor = control.Descriptor; - var effectiveCdc = ResolveControlSemanticCdc(descriptor, signal); + var semantic = ArControl.Iec61850ControlSemanticResolver.Resolve( + descriptor, signal.ControlCdc, signal.ControlStatusReference); + var effectiveCdc = semantic.Cdc; var status = await RunMmsOperationAsync( () => control.ReadStatusAsync(cancellationToken), cancellationToken).ConfigureAwait(false); @@ -1135,9 +1137,10 @@ public async Task InspectControlAsync( var model = MapControlModel(descriptor.ControlModel); var modelText = FriendlyControlModel(descriptor.ControlModel); - var valueType = MapControlValueType(descriptor); + var valueType = MapControlValueType(descriptor, effectiveCdc); signal.ControlCdc = effectiveCdc; + signal.ControlSemanticVerified = semantic.IsCommandSafe; signal.ControlValueType = valueType; signal.ControlCurrentValue = currentValue; signal.ControlStatusReference = statusReference; @@ -1157,16 +1160,16 @@ public async Task InspectControlAsync( CurrentValue = currentValue, CurrentState = currentState, EngineControlServiceAvailable = true, - IsOperationallyReady = descriptor.IsOperationallyReady, + IsOperationallyReady = descriptor.IsOperationallyReady && semantic.IsCommandSafe, SupportsCommandTermination = descriptor.SupportsCommandTermination, SupportsTimeActivatedOperate = descriptor.SupportsTimeActivatedOperate, SboTimeoutText = FormatControlTimeout(descriptor.SboTimeout), OperTimeoutText = FormatControlTimeout(descriptor.OperTimeout), SequenceText = FriendlyControlSequence(descriptor.ControlModel), DiscoveryEvidence = descriptor.DiscoveryEvidence, - EngineControlServiceStatus = descriptor.IsOperationallyReady - ? "ARIEC61850 Smart Control is ready. Exact live ctlVal typing and the required Direct/SBO sequence were validated." - : "ARIEC61850 found the control object, but the live descriptor is incomplete and command execution remains blocked." + EngineControlServiceStatus = semantic.IsCommandSafe + ? "ARIEC61850 Smart Control is ready. CDC, live ctlVal, status and Direct/SBO semantics agree." + : semantic.Reason }; } @@ -1198,8 +1201,15 @@ private async Task ExecuteControlCoreAsync( var totalStopwatch = Stopwatch.StartNew(); var control = await GetOrOpenControlSessionAsync(request.Signal, cancellationToken).ConfigureAwait(false); var descriptor = control.Descriptor; - var effectiveCdc = ResolveControlSemanticCdc(descriptor, request.Signal); + var semantic = ArControl.Iec61850ControlSemanticResolver.Resolve( + descriptor, request.Signal.ControlCdc, request.Signal.ControlStatusReference); + var effectiveCdc = semantic.Cdc; + request.Signal.ControlSemanticVerified = semantic.IsCommandSafe; var capabilities = BuildControlCapabilities(descriptor, request.Signal, "-", effectiveCdc); + if (!semantic.IsCommandSafe) + return ControlFailure("Validation", + "Unsafe or ambiguous IEC 61850 control semantics: " + semantic.Reason, + capabilities, request.ValueText); if (!descriptor.IsOperationallyReady) { return ControlFailure( @@ -1379,6 +1389,8 @@ private Iec61850ControlCapabilities BuildControlCapabilities( string currentValue, string? effectiveCdc = null) { + var semantic = ArControl.Iec61850ControlSemanticResolver.Resolve( + descriptor, signal.ControlCdc, signal.ControlStatusReference); var statusReference = !string.IsNullOrWhiteSpace(signal.ControlStatusReference) ? signal.ControlStatusReference : descriptor.StatusReference; @@ -1390,21 +1402,21 @@ private Iec61850ControlCapabilities BuildControlCapabilities( ControlModel = MapControlModel(descriptor.ControlModel), ControlModelText = FriendlyControlModel(descriptor.ControlModel), ControlCdc = string.IsNullOrWhiteSpace(effectiveCdc) ? ResolveControlSemanticCdc(descriptor, signal) : effectiveCdc, - ControlValueType = MapControlValueType(descriptor), + ControlValueType = MapControlValueType(descriptor, effectiveCdc), CtlValSignature = descriptor.CtlValSpecification.Signature, CurrentValue = currentValue, CurrentState = "Unknown", EngineControlServiceAvailable = true, - IsOperationallyReady = descriptor.IsOperationallyReady, + IsOperationallyReady = descriptor.IsOperationallyReady && semantic.IsCommandSafe, SupportsCommandTermination = descriptor.SupportsCommandTermination, SupportsTimeActivatedOperate = descriptor.SupportsTimeActivatedOperate, SboTimeoutText = FormatControlTimeout(descriptor.SboTimeout), OperTimeoutText = FormatControlTimeout(descriptor.OperTimeout), SequenceText = FriendlyControlSequence(descriptor.ControlModel), DiscoveryEvidence = descriptor.DiscoveryEvidence, - EngineControlServiceStatus = descriptor.IsOperationallyReady + EngineControlServiceStatus = semantic.IsCommandSafe ? "ARIEC61850 Smart Control is ready." - : "The native Smart Control descriptor is incomplete." + : semantic.Reason }; } @@ -1494,31 +1506,36 @@ private static bool TryBuildNativeControlValue( return false; } - var cdc = (descriptor.Cdc ?? string.Empty).Trim().ToUpperInvariant(); + var cdc = ResolveControlSemanticCdc(descriptor, signal).Trim().ToUpperInvariant(); var mmsType = (descriptor.CtlValSpecification.MmsType ?? string.Empty).Trim().ToLowerInvariant(); var normalized = expected.ToLowerInvariant(); var signalText = $"{signal.Name} {signal.ObjectReference}"; var ctlChildNames = descriptor.CtlValSpecification.Children .Select(child => new string((child.Name ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray())) .ToArray(); - var isBscStructure = mmsType == "structure" && - (ctlChildNames.Contains("posval") || ctlChildNames.Contains("transind")); var isApcStructure = mmsType == "structure" && (ctlChildNames.Contains("f") || ctlChildNames.Contains("i")); + // BSC ctlVal is directional Tcmd, never a tap-position setpoint + // and never a DPC Open/Close command. Validate live encoding first. + if (cdc == "BSC") + return Iec61850BscStepControlPolicy.TryBind( + mmsType, descriptor.CtlValSpecification.Size, + normalized, out value, out expected, out error); + // Some vendor IEDs expose CSWI/XCBR/XSWI.Pos with a Boolean ctlVal while // the process feedback remains the standard double-point Pos.stVal. Preserve // Open/Closed semantics for the user and only adapt the wire value here. - if (signal.IsPositionControl && mmsType == "boolean") + if (cdc == "DPC" && signal.IsPositionControl && mmsType == "boolean") { - if (normalized.Contains("open") || normalized is "off" or "false" or "0" or "01") + if (normalized is "open" or "open [01]" or "off" or "false" or "0" or "01") { value = ArControl.Iec61850ControlValue.Boolean(false); expected = "Open [01]"; expectedState = ArControl.Iec61850ControlStatusState.Open; return true; } - if (normalized.Contains("close") || normalized.Contains("closed") || normalized is "on" or "true" or "1" or "10") + if (normalized is "close" or "closed" or "closed [10]" or "on" or "true" or "1" or "10") { value = ArControl.Iec61850ControlValue.Boolean(true); expected = "Closed [10]"; @@ -1529,16 +1546,16 @@ private static bool TryBuildNativeControlValue( return false; } - if (cdc == "DPC" || (mmsType == "bit-string" && descriptor.CtlValSpecification.Size == 2)) + if (cdc == "DPC" && mmsType == "bit-string" && descriptor.CtlValSpecification.Size == 2) { - if (normalized.Contains("open") || normalized is "off" or "1" or "01") + if (normalized is "open" or "open [01]" or "off" or "1" or "01") { value = ArControl.Iec61850ControlValue.Open(); expected = "Open [01]"; expectedState = ArControl.Iec61850ControlStatusState.Open; return true; } - if (normalized.Contains("close") || normalized.Contains("closed") || normalized is "on" or "2" or "10") + if (normalized is "close" or "closed" or "closed [10]" or "on" or "2" or "10") { value = ArControl.Iec61850ControlValue.Close(); expected = "Closed [10]"; @@ -1549,19 +1566,19 @@ private static bool TryBuildNativeControlValue( return false; } - if (cdc == "SPC" || mmsType == "boolean") + if (cdc == "SPC" && mmsType == "boolean") { var isPulseRaise = signalText.Contains("TapOpR", StringComparison.OrdinalIgnoreCase) || signalText.Contains("Raise", StringComparison.OrdinalIgnoreCase); var isPulseLower = signalText.Contains("TapOpL", StringComparison.OrdinalIgnoreCase) || signalText.Contains("Lower", StringComparison.OrdinalIgnoreCase); - if (normalized is "raise" or "up" || isPulseRaise) + if ((normalized is "raise" or "up") && isPulseRaise) { value = ArControl.Iec61850ControlValue.On(); expected = "Raise"; return true; } - if (normalized is "lower" or "down" || isPulseLower) + if ((normalized is "lower" or "down") && isPulseLower) { value = ArControl.Iec61850ControlValue.On(); expected = "Lower"; @@ -1580,22 +1597,8 @@ private static bool TryBuildNativeControlValue( return false; } - if (cdc.Contains("INC", StringComparison.OrdinalIgnoreCase) || - cdc.Contains("ISC", StringComparison.OrdinalIgnoreCase) || - mmsType is "integer" or "unsigned" or "bcd") + if ((cdc is "INC" or "ISC") && (mmsType is "integer" or "unsigned" or "bcd")) { - if (normalized is "raise" or "up") - { - value = ArControl.Iec61850ControlValue.Raise(); - expected = "Raise"; - return true; - } - if (normalized is "lower" or "down") - { - value = ArControl.Iec61850ControlValue.Lower(); - expected = "Lower"; - return true; - } if (long.TryParse(normalized, NumberStyles.Integer, CultureInfo.InvariantCulture, out var integer)) { if (mmsType == "unsigned") @@ -1614,23 +1617,11 @@ private static bool TryBuildNativeControlValue( expected = integer.ToString(CultureInfo.InvariantCulture); return true; } - error = "Integer control requires Raise, Lower, or a whole-number target."; + error = "Integer control requires an explicitly selected whole-number target."; return false; } - if (cdc == "BSC" || isBscStructure) - { - if (long.TryParse(normalized, NumberStyles.Integer, CultureInfo.InvariantCulture, out var position)) - { - value = ArControl.Iec61850ControlValue.StepPosition(position); - expected = position.ToString(CultureInfo.InvariantCulture); - return true; - } - error = "BSC command requires a numeric target position."; - return false; - } - - if (cdc == "APC" || mmsType == "floating-point" || isApcStructure) + if (cdc == "APC" && (mmsType == "floating-point" || isApcStructure)) { var numeric = normalized.Replace(',', '.'); if (double.TryParse(numeric, NumberStyles.Float, CultureInfo.InvariantCulture, out var analogue)) @@ -1665,6 +1656,8 @@ private static bool TryParseBooleanControl(string value, out bool result) private static bool ControlFeedbackMatches(string cdc, string expected, string initial, string actual) { var normalizedCdc = (cdc ?? string.Empty).Trim().ToUpperInvariant(); + if (normalizedCdc == "BSC") + return Iec61850BscStepControlPolicy.MovementConfirmed(expected, initial, actual); if (normalizedCdc == "DPC") { return Iec61850ValueFormatter.TryNormalizeDbpos(expected, out var expectedCode) && @@ -1677,7 +1670,7 @@ private static bool ControlFeedbackMatches(string cdc, string expected, string i { if (TryExtractNumber(initial, out var before) && TryExtractNumber(actual, out var after)) return expectedText is "raise" or "up" ? after > before : after < before; - return !string.Equals(initial?.Trim(), actual?.Trim(), StringComparison.OrdinalIgnoreCase); + return false; // Arbitrary changed text is not directional evidence. } if (normalizedCdc == "SPC") @@ -1737,14 +1730,14 @@ private static string FriendlyControlSequence(ArControl.Iec61850ControlModel mod _ => "No safe command sequence available" }; - private static string MapControlValueType(ArControl.Iec61850ControlObjectDescriptor descriptor) + private static string MapControlValueType(ArControl.Iec61850ControlObjectDescriptor descriptor, string? semanticCdc = null) { - var cdc = (descriptor.Cdc ?? string.Empty).Trim().ToUpperInvariant(); + var cdc = (semanticCdc ?? descriptor.Cdc ?? string.Empty).Trim().ToUpperInvariant(); return cdc switch { "DPC" => "Dbpos", "SPC" => "Boolean", - "BSC" => "ValWithTrans", + "BSC" => "Int8", // ST valWTr.posVal is feedback, not an Oper target "APC" => descriptor.CtlValSpecification.MmsType, _ when cdc.Contains("INC", StringComparison.OrdinalIgnoreCase) || cdc.Contains("ISC", StringComparison.OrdinalIgnoreCase) => descriptor.CtlValSpecification.MmsType, @@ -1853,8 +1846,11 @@ private static string InferControlCdc( if (objectName.Contains("TAPOPR") || objectName.Contains("TAPOPL") || objectName.Contains("RAISE") || objectName.Contains("LOWER")) return "SPC"; - if (objectName.Contains("TAPCHG") || objectName.Contains("STEP")) - return "INC"; + // Exact live ValWithTrans status beats name-derived heuristics. + if (hasControlOperation && attributes.Any(attribute => + (attribute.AttributePath ?? string.Empty).EndsWith("valWTr.posVal", StringComparison.OrdinalIgnoreCase) || + (attribute.ObjectReference ?? string.Empty).EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase))) + return "BSC"; var ctlVal = attributes.FirstOrDefault(attribute => (attribute.AttributePath ?? string.Empty).EndsWith("ctlVal", StringComparison.OrdinalIgnoreCase) || @@ -2053,7 +2049,8 @@ private static string NormalizeControlObjectKey(string? reference) private static string ResolveControlSemanticCdc( ArControl.Iec61850ControlObjectDescriptor descriptor, SignalDefinition signal) - => signal.IsPositionControl ? "DPC" : descriptor.Cdc; + => ArControl.Iec61850ControlSemanticResolver.Resolve( + descriptor, signal.ControlCdc, signal.ControlStatusReference).Cdc; private static (string Value, ArControl.Iec61850ControlStatusState State) NormalizeControlFeedback( string cdc, diff --git a/Services/SclEndpointBindingStore.cs b/Services/SclEndpointBindingStore.cs index d8b87fa22..f5fbf4540 100644 --- a/Services/SclEndpointBindingStore.cs +++ b/Services/SclEndpointBindingStore.cs @@ -25,6 +25,8 @@ internal sealed class Binding public string SourceSha256 { get; set; } = string.Empty; public string IedName { get; set; } = string.Empty; public string AccessPointName { get; set; } = string.Empty; + // Original operator-opened, SHA-pinned SCL source; absent in legacy records. + public string SourcePath { get; set; } = string.Empty; public string IpAddress { get; set; } = string.Empty; public int Port { get; set; } = 102; public DateTime LastVerifiedUtc { get; set; } @@ -57,12 +59,12 @@ internal static bool TryGetAtPath( public static void RecordVerified( string sourceSha256, string iedName, string accessPointName, - string ipAddress, int port) - => RecordVerifiedAtPath(DefaultPath, sourceSha256, iedName, accessPointName, ipAddress, port); + string ipAddress, int port, string? sourcePath = null) + => RecordVerifiedAtPath(DefaultPath, sourceSha256, iedName, accessPointName, ipAddress, port, sourcePath); internal static void RecordVerifiedAtPath( string path, string sourceSha256, string iedName, string accessPointName, - string ipAddress, int port) + string ipAddress, int port, string? sourcePath = null) { if (!ValidIdentity(sourceSha256, iedName, accessPointName) || !ValidAddress(ipAddress, port)) throw new ArgumentException("An exact SCL source, IED, AP and valid MMS IP:port are required."); @@ -76,6 +78,7 @@ internal static void RecordVerifiedAtPath( SourceSha256 = sourceSha256.Trim(), IedName = iedName.Trim(), AccessPointName = accessPointName.Trim(), + SourcePath = string.IsNullOrWhiteSpace(sourcePath) ? string.Empty : Path.GetFullPath(sourcePath), IpAddress = ipAddress.Trim(), Port = port, LastVerifiedUtc = DateTime.UtcNow @@ -102,6 +105,30 @@ internal static void RecordVerifiedAtPath( } } + // A previously successful SCL-assisted MMS association is an operator-authorized + // identity candidate, not proof by itself. Every source is re-hashed and matched + // against the current full live MMS domain topology before name promotion. + internal static IReadOnlyList FindVerifiedSourcesForEndpoint( + string ipAddress, int port) + => FindVerifiedSourcesForEndpointAtPath(DefaultPath, ipAddress, port); + + internal static IReadOnlyList FindVerifiedSourcesForEndpointAtPath( + string path, string ipAddress, int port) + { + if (!ValidAddress(ipAddress, port)) return Array.Empty(); + lock (Gate) + { + return Load(path) + .Where(binding => + ValidIdentity(binding.SourceSha256, binding.IedName, binding.AccessPointName) && + ValidAddress(binding.IpAddress, binding.Port) && + string.Equals(binding.IpAddress, ipAddress, StringComparison.OrdinalIgnoreCase) && + binding.Port == port && + !string.IsNullOrWhiteSpace(binding.SourcePath)) + .ToArray(); + } + } + private static List Load(string path) { try diff --git a/Services/StaticAcquisitionParityTracker.cs b/Services/StaticAcquisitionParityTracker.cs index 76d0a9138..7d2bac3dd 100644 --- a/Services/StaticAcquisitionParityTracker.cs +++ b/Services/StaticAcquisitionParityTracker.cs @@ -120,8 +120,8 @@ internal static StaticAcquisitionIngressEvidence BuildEvidence( evidenceIssues.Add("no engine-authoritative static report plans"); if (model is null) evidenceIssues.Add("ingress model missing"); - else if (string.IsNullOrWhiteSpace(model.IedName) || - !string.Equals(model.IedName.Trim(), device.Name.Trim(), StringComparison.OrdinalIgnoreCase)) + else if (!string.Equals(model.IedName?.Trim(), device.Name.Trim(), StringComparison.OrdinalIgnoreCase) && + !HasVerifiedIngressIdentity(device, model, ingress)) evidenceIssues.Add($"ingress model IED identity '{model.IedName}' does not match selected IED '{device.Name}'"); // Comparing equal hashes is not meaningful if two independently malformed or @@ -136,8 +136,12 @@ internal static StaticAcquisitionIngressEvidence BuildEvidence( if (brcbBindings != planning.StaticBrcbSignalCount || urcbBindings != planning.StaticUrcbSignalCount) evidenceIssues.Add("static plan bindings do not match engine coverage counters"); - if (brcbBindings + urcbBindings + planning.UncoveredSignalCount != planning.RequestedPointCount) - evidenceIssues.Add("static plan coverage does not account for all requested runtime points"); + // Binding counters measure report membership incidences. One IEC point can + // legitimately appear in several configured DataSets / RCBs (GE F650: + // 166 binding incidences across 6 RCBs represent 67 unique runtime points). + // Do not compare incidence sum against distinct requested signal count. + // Check unique exact IEC reference + FC below, after collecting all plans. + var uniqueSelectedPoints = new HashSet(StringComparer.Ordinal); var semanticLines = new List { @@ -217,6 +221,7 @@ internal static StaticAcquisitionIngressEvidence BuildEvidence( .OrderBy(value => value, StringComparer.Ordinal) .ToArray(); + uniqueSelectedPoints.UnionWith(selected); if (selected.Length == 0) evidenceIssues.Add($"DataSet {dataSetReference} has no selected point bindings"); if (plan.Bindings.Any(point => @@ -231,6 +236,13 @@ internal static StaticAcquisitionIngressEvidence BuildEvidence( runtimeTargets.Add(BuildRuntimeTarget(plan)); } + // Fail closed on genuinely incomplete coverage, while preserving the + // existing per-family raw binding counter consistency guards above. + if ((long)uniqueSelectedPoints.Count + planning.UncoveredSignalCount != planning.RequestedPointCount) + evidenceIssues.Add( + $"unique static plan coverage does not account for requested runtime points " + + $"(unique={uniqueSelectedPoints.Count}, uncovered={planning.UncoveredSignalCount}, requested={planning.RequestedPointCount})"); + var canonicalText = string.Join("\n", semanticLines); var fingerprint = Convert.ToHexString( SHA256.HashData(Encoding.UTF8.GetBytes(canonicalText))) @@ -319,6 +331,32 @@ internal static StaticAcquisitionParitySnapshot Compare( }; } + // The live MMS document may retain its original IP-based name as immutable + // wire provenance. A fresh, association-scoped, full-domain SHA-verified SCL + // identity is sufficient to compare semantic evidence under the real IED name. + // Never allow an arbitrary manual card rename to satisfy this parity gate. + private static bool HasVerifiedIngressIdentity( + Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument model, + StaticAcquisitionIngressKind ingress) + { + if (ingress != StaticAcquisitionIngressKind.LiveDiscovery || + device.TrustedLiveIdentity is not { } proof || + proof.Source != "TrustedSclExactDomainMatch" || + proof.Confidence != LiveIedDiscoveryConfidenceLevel.High || + !string.Equals(proof.IedName, device.Name, StringComparison.OrdinalIgnoreCase)) + return false; + + var domains = model.LogicalDevices + .Select(ld => ld.MmsDomain) + .Where(domain => !string.IsNullOrWhiteSpace(domain)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return domains.Length > 0 && + domains.Length == proof.LogicalDeviceAliases.Count && + domains.All(domain => proof.LogicalDeviceAliases.ContainsKey(domain)); + } + private static string BuildRuntimeTarget(ReportControlPlan plan) => $"{NormalizeKind(plan.EngineAcquisitionKind)}|dataset={NormalizeReference(plan.DataSetReference)}|" + $"rcb={NormalizeReference(plan.ReportControlReference)}|bindings={plan.Bindings.Count}"; diff --git a/Services/TrustedSclIdentityAuthority.cs b/Services/TrustedSclIdentityAuthority.cs new file mode 100644 index 000000000..2e894f5c0 --- /dev/null +++ b/Services/TrustedSclIdentityAuthority.cs @@ -0,0 +1,138 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using System.Xml.Linq; + +namespace ArIED61850Tester.Services; + +/// +/// SCL identity is authoritative only if it is the same SHA-256 pinned source +/// already opened by the operator AND its complete IED/AP/LD topology matches +/// the observed MMS domains. This is deliberately outside the hot report path. +/// +public static class TrustedSclIdentityAuthority +{ + public static async Task TryMatchAsync( + Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument liveModel, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(liveModel); + if (string.IsNullOrWhiteSpace(device.SclSourcePath) || + string.IsNullOrWhiteSpace(device.SclSourceSha256)) + return null; + + var verified = await VerifiedSclSourceLoader.LoadAsync( + device.SclSourcePath,device.SclSourceSha256,cancellationToken).ConfigureAwait(false); + var source = XDocument.Parse(verified.Xml, LoadOptions.PreserveWhitespace); + return TrustedSclIedIdentityMatcher.TryMatch( + source, + liveModel.LogicalDevices.Select(ld => ld.MmsDomain), + device.IpAddress, + device.SclIedName); + } + + /// + /// IP-only discovery may reuse a prior user-opened IID only after its SCL-assisted + /// MMS association was successful, its original bytes still match the pinned + /// SHA-256, and its IED/AP/LD domain topology uniquely matches this live session. + /// No IP-only guess, vendor heuristic or SCL-assisted data acquisition is enabled. + /// + public static async Task TryMatchRememberedAsync( + Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument liveModel, + CancellationToken cancellationToken = default, + string? bindingStorePath = null) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(liveModel); + + var candidates = bindingStorePath is null + ? SclEndpointBindingStore.FindVerifiedSourcesForEndpoint(device.IpAddress, device.Port) + : SclEndpointBindingStore.FindVerifiedSourcesForEndpointAtPath( + bindingStorePath, device.IpAddress, device.Port); + // Never pick the "most recent" plausible model from an ambiguous or + // pathological endpoint history. Capped work protects discovery latency. + if (candidates.Count == 0 || candidates.Count > 16) + return null; + + var domains = liveModel.LogicalDevices.Select(ld => ld.MmsDomain).ToArray(); + RememberedTrustedSclMatch? unique = null; + var ns = (XNamespace)"http://www.iec.ch/61850/2003/SCL"; + + foreach (var candidate in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var source = await VerifiedSclSourceLoader.LoadAsync( + candidate.SourcePath, candidate.SourceSha256, cancellationToken) + .ConfigureAwait(false); + var document = XDocument.Parse(source.Xml, LoadOptions.PreserveWhitespace); + + // Engine matcher guards global IED/AP ambiguity. Additionally, only + // a single explicit AP for the remembered IED is eligible until an + // independently authenticated AP identifier is observable from MMS. + var matchingIeds = document.Root?.Elements(ns + "IED") + .Where(ied => string.Equals( + (string?)ied.Attribute("name"), candidate.IedName, + StringComparison.OrdinalIgnoreCase)).ToArray() + ?? Array.Empty(); + if (matchingIeds.Length != 1) + continue; + var accessPoints = matchingIeds[0].Elements(ns + "AccessPoint").ToArray(); + if (accessPoints.Length != 1 || !string.Equals( + (string?)accessPoints[0].Attribute("name"), + candidate.AccessPointName, StringComparison.OrdinalIgnoreCase)) + continue; + + var identity = TrustedSclIedIdentityMatcher.TryMatch( + document, domains, device.IpAddress, candidate.IedName); + if (identity is null || + identity.Source != "TrustedSclExactDomainMatch" || + identity.Confidence != LiveIedDiscoveryConfidenceLevel.High) + continue; + + if (unique is not null) + return null; // competing qualified IID sources: fail closed + unique = new RememberedTrustedSclMatch( + identity, source.Sha256, candidate.AccessPointName); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) when ( + ex is IOException or UnauthorizedAccessException or InvalidDataException or + System.Xml.XmlException or ArgumentException) + { + // Missing/edited/malformed remembered files cannot rename a live IED. + continue; + } + } + return unique; + } + + public static Iec61850DeviceIdentity ToConsumerIdentity( + LiveIedIdentity verified, IEnumerable domains) + { + ArgumentNullException.ThrowIfNull(verified); + if (verified.Source != "TrustedSclExactDomainMatch" || + verified.Confidence != LiveIedDiscoveryConfidenceLevel.High) + throw new InvalidDataException("SCL model identity is not verified."); + + return new Iec61850DeviceIdentity + { + IedName=verified.IedName, + Source=$"ARIEC61850 {verified.Source} ({verified.Confidence})", + LogicalDevices=domains.Distinct(StringComparer.OrdinalIgnoreCase).Select(domain => + new Iec61850LogicalDeviceIdentity + { + Domain=domain, + Instance=verified.LogicalDeviceAliases.TryGetValue(domain,out var alias) + ? alias : throw new InvalidDataException($"Verified LD mapping missing '{domain}'.") + }).ToArray() + }; + } +} + +/// Association-local proof, not a persisted or inferred IEC wire name. +public sealed record RememberedTrustedSclMatch( + LiveIedIdentity Identity, string SourceSha256, string AccessPointName); diff --git a/Services/UserPreferenceStore.cs b/Services/UserPreferenceStore.cs index 9be74665e..e874da83f 100644 --- a/Services/UserPreferenceStore.cs +++ b/Services/UserPreferenceStore.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Net; using System.Text.Json; namespace ArIED61850Tester.Services; @@ -26,14 +27,58 @@ public static class UserPreferenceStore "user-preferences.json"); + /// + /// Remembers the operator's last valid Discovery submission independently + /// of connection success and the per-IED signal selection history. + /// + public static bool TryLoadLastDiscoveryEndpoint(out string ipAddress, out int port) + => TryLoadLastDiscoveryEndpoint(PreferencesPath, out ipAddress, out port); + + // Tests use a temp path; no test writes to the real Windows profile. + internal static bool TryLoadLastDiscoveryEndpoint(string path, out string ipAddress, out int port) + { + ipAddress = string.Empty; + port = 102; + var prefs = ReadPreferencesFile(path); + if (prefs is null || !IPAddress.TryParse(prefs.LastDiscoveryIpAddress, out _) || + prefs.LastDiscoveryMmsPort is <= 0 or > 65535) + return false; + ipAddress = prefs.LastDiscoveryIpAddress.Trim(); + port = prefs.LastDiscoveryMmsPort; + return true; + } + + public static void RecordLastDiscoveryEndpoint(string ipAddress, int port) + => RecordLastDiscoveryEndpoint(PreferencesPath, ipAddress, port); + + internal static void RecordLastDiscoveryEndpoint(string path, string ipAddress, int port) + { + var ip = ipAddress?.Trim() ?? string.Empty; + if (!IPAddress.TryParse(ip, out _) || port is <= 0 or > 65535) + throw new ArgumentException("Discovery requires a valid IP address and MMS port."); + + // Keep legacy migration on the production path and preserve every + // existing saved signal profile and successful IED endpoint. + var prefs = string.Equals(path, PreferencesPath, StringComparison.Ordinal) + ? LoadPreferences() + : ReadPreferencesFile(path) ?? new UserPreferences(); + prefs.LastDiscoveryIpAddress = ip; + prefs.LastDiscoveryMmsPort = port; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, JsonSerializer.Serialize(prefs, Options)); + } + public static IReadOnlyList LoadRecentEndpoints(int maximum = 20) { maximum = Math.Clamp(maximum, 1, 100); var prefs = LoadPreferences(); - return prefs.SuccessfulRelays + return new[] { prefs.LastDiscoveryIpAddress } + .Where(ip => !string.IsNullOrWhiteSpace(ip) && IPAddress.TryParse(ip.Trim(), out _)) + .Select(ip => ip.Trim()) + .Concat(prefs.SuccessfulRelays .Where(item => !string.IsNullOrWhiteSpace(item.IpAddress)) .OrderByDescending(item => item.LastConnectedUtc) - .Select(item => item.IpAddress.Trim()) + .Select(item => item.IpAddress.Trim())) .Concat(prefs.SignalSelectionProfiles .Where(item => !string.IsNullOrWhiteSpace(item.IpAddress)) .OrderByDescending(item => item.LastSavedUtc) @@ -285,6 +330,8 @@ private sealed class UserPreferences { // Keep the original ArServer fields so successful endpoints migrate without // requiring the user to type the IP address again after upgrading. + public string LastDiscoveryIpAddress { get; set; } = string.Empty; + public int LastDiscoveryMmsPort { get; set; } = 102; public List RecentRelayIps { get; set; } = new(); public List SuccessfulRelays { get; set; } = new(); public List SignalSelectionProfiles { get; set; } = new(); diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index e646a6234..3ea4030cb 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -281,39 +281,52 @@ - + + - + + - - - - + + + + + - - + + VerticalAlignment="Center" FontSize="10.5"> - - + + FontSize="10.5" Foreground="{StaticResource Muted}" + ToolTip="Show every received GOOSE frame including retransmissions. Unchecked: show changes only; raw capture is preserved."/> diff --git a/docs/P10_GE_ED1_FIELD_ACCEPTANCE.md b/docs/P10_GE_ED1_FIELD_ACCEPTANCE.md new file mode 100644 index 000000000..28001f6e5 --- /dev/null +++ b/docs/P10_GE_ED1_FIELD_ACCEPTANCE.md @@ -0,0 +1,46 @@ +# P10.3/P10.4 — GE F650 Edition 1 physical-evidence procedure + +**Code authority:** ARSAS staging P10.2 commit \`2369c286da6d14dda2470a2854daf2d46d588dfe\` and engine lock \`00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1\`. No rebasing to old main and no substitution of engine PR #160. The artifact built by the exact staging [Build ARSAS #38105708427](https://github.com/masarray/arsas/actions/runs/38105708427) is the P10.3 regression-qualified **candidate**, not field-qualified release. + +## Trust and safety gates + +- Use **an independently supplied original** \`BCU_GE.iid\`, not an SCL generated by ARSAS and later renamed as engineering authority. Record its **actual file SHA-256** before opening, and preserve the source unchanged. The source and generated SCL must be separate files. +- Observe real MMS session **192.16.1.33:102** on the authorized substation network, from a qualified operator computer. Do not use direct device control operations or write data to create test traffic. +- In ARSAS, **Open SCL** original IID and select the actual \`BCUGE\` / AccessPoint. This establishes SHA-bound trusted identity. Then Discovery on the same endpoint, without clearing the source-association context. If live workflow creates a new card, ensure the trusted SCL remains bound to that card before deciding identity is high-confidence. +- Discovery must show IED \`BCUGE\`, \`LDevice.inst=F650\`, and observed *raw* MMS domain \`BCUGEF650\`. Without trusted exact SCL evidence, provisional \`IED_192_16_1_33\` is safer than heuristically guessing a device name. +- Select **Static DataSet** (not dynamic/polling), confirm 8 static DataSets, 124 ordered members, 67 selected runtime points and six concrete RCB streams with actual InformationReport traffic. GI and report-backed causality must be observed. **No cyclic MMS process-value reads** and no auto fallback. +- **Save SCL** in full-model Edition 1, then **Open SCL** the saved file and reconnect using the reloaded model; collect separate before/after diagnostics, SCL report files and PCAP (where permitted). The existing \`CanonicalSclReloadValidator\` checks internal reload and association structure; our offline check below independently compares to the original SHA-pinned IID. +- Test BRCB continuity in its **own** session evidence: timestamp, RCB, BufOvfl, SqNum/SubSqNum, EntryID, GI reason and replay after reconnect. Treat missing/ambiguous fields as incomplete, not zero faults. +- Evaluate design-only LN/DO/DA structures, observed GetNameList cap 6400 and \`incompleteChains\` separately. 8/8 DataSets is not proof of full model closure. GOOSE bindings/addresses also require separate verification. + +## Read-only offline SCL verification + +On the authorized local computer, use Python 3 already used by ARSAS CI (no additional libraries). In PowerShell: + +\`\`\`powershell +$iid = 'C:\field\BCU_GE.iid' +$export = 'C:\field\BCUGE-discovery.icd' +$sha = '<64-character SHA256 copied from original independent intake record>' +python .\scripts\verify-p10-ge-field-scl.py \` + --trusted-iid $iid \` + --trusted-sha256 $sha \` + --discovery-scl $export \` + --observed-mms-domain BCUGEF650 \` + --endpoint-ip 192.16.1.33 \` + --access-point S1 \` + --out 'C:\field\p10-ge-structural-evidence.json' +\`\`\` + +Replace \`S1\` with the **actual trusted** AccessPoint if different. Do not derive the expected trusted hash from the candidate file during the same verification run; compare with the independently recorded original intake hash. The observed-domain argument is an operator-supplied claim; save independent device logs/PCAP proving that claim. The tool hashes and checks both input files, is strictly offline, does not connect to a relay, and has no write path other than an explicitly requested JSON report. It fails closed on missing source SHA, ambiguous AP/IED, wrong IP, changed MMS wire-domain, missing LD/LN, or drift in ordered DataSet/FCDA members. It compares engineering-vs-export logical RCB and GOOSE definitions and reports them separately for review. It cannot infer unobserved MMS values, packet continuity, or physical certification. + +Possible structural outcomes: \`STRUCTURE_PASS_ONLY\`, \`FAIL\`, or \`REJECTED_INPUT\`. Even the passing case **always** has \`physicalQualification=NOT_VERIFIED\` and \`r10PromotionAllowed=false\`. It is **never** a substitute for actual IEC 61850 field evidence. + +The JSON records input SHA, structural discrepancies and explicitly lists all unverified physical gates. Do **not** modify \`evidence/interoperability-reference-target.json\` or change CI approval flags from this output. + +## Field package to attach for P10.4 review + +Preserve original IID plus original SHA256, exported ICD/IID, generated export report, full ARSAS Diagnostics from Discovery and Open SCL, packet capture (if allowed), and RCB continuity evidence with timestamps for both sessions. If PCAP contains sensitive operational data, share a redacted engineering-safe version but retain original hash/provenance in the field record. + +### Exit criteria + +Only mark P10.4 physically accepted after: verified field provenance; correct BCUGE/F650/BCUGEF650 mapping; 8/124 member match; report-only 67 runtime / 6 active RCB with real traffic; no cyclic fallback; Save→Open SCL endpoint/association round-trip; independently reviewed LN/DO/DA/name-list completeness; and independently reviewed BRCB replay/GI continuity. No claim that successful CI or this offline tool satisfies those conditions. diff --git a/docs/WINDOWS_RELEASES.md b/docs/WINDOWS_RELEASES.md index 9a3232cec..4019a607c 100644 --- a/docs/WINDOWS_RELEASES.md +++ b/docs/WINDOWS_RELEASES.md @@ -11,20 +11,21 @@ The current stable application version is **1.6.40**. Public download metadata r The canonical release request is `.release/windows.json`. A reviewed change to that file on `main` triggers `.github/workflows/release-windows.yml`. -The release workflow: +The production release workflow: 1. resolves the semantic version from the release request or tag and verifies it against `VERSION`, `Directory.Build.props`, and `ArIED61850Tester.csproj`; -2. resolves and checks out the immutable ARIEC61850 engine revision from `engines/ARIEC61850.lock.json`; -3. resolves and checks out the immutable ArdIrec revision from `engines/ARDIREC.lock.json`; -4. verifies source and licensing boundaries; -5. restores, builds, and runs the ARSAS regression suite against the exact release source; -6. exercises the managed native COMTRADE bridge, including cursor, phasor, harmonics, and distance-locus integration; -7. publishes the multi-file self-contained source used by the installer; -8. publishes and smoke-tests the self-contained portable single EXE; -9. compiles the Windows installer and performs silent install/uninstall smoke validation; -10. creates SHA-256 checksums, SPDX 2.3 SBOM, and provenance evidence; -11. creates GitHub artifact attestations for the public Windows binaries; -12. creates a new stable GitHub Release with the public assets; existing tags/assets are immutable and must not be overwritten. +2. resolves the immutable ARIEC61850 and ArdIrec lock SHAs without rebuilding those dependencies; +3. verifies source, licensing and physical-proven Smart Discovery route boundaries; +4. requires the exact successful Build ARSAS `push/main` sealed Windows package for the same source, engine and ArdIrec SHAs; +5. validates the canonical all-pass TRX, portable build identity and SHA-256/size of every sealed payload file before materialization; +6. promotes the exact tested installer-input folder and portable EXE bytes instead of rebuilding the application; +7. reruns the release-specific managed native COMTRADE bridge/locus verification and portable runtime smoke; +8. compiles the Windows installer from the verified installer input and performs silent install/uninstall plus installed native bridge validation; +9. creates SHA-256 checksums, SPDX 2.3 SBOM, and provenance evidence that records the canonical package run/artifact identity; +10. creates GitHub artifact attestations for the public Windows binaries; +11. creates a new stable GitHub Release with the public assets; existing tags/assets are immutable and must not be overwritten. + +A production tag whose exact commit has no matching canonical Build ARSAS package fails closed rather than silently rebuilding. `workflow_dispatch` retains an explicit local rebuild fallback for controlled engineering recovery. The workflow explicitly rejects legacy `ardirec.exe` and Qt runtime files from official packaging. ARSAS uses the pinned in-process `ardirec_bridge.dll` contract instead. @@ -40,7 +41,7 @@ The active release automation has intentionally separate responsibilities. Maint | Workflow | Responsibility | Public release mutation | | --- | --- | --- | -| `.github/workflows/release-windows.yml` | Canonical Windows build, test, portable/installer packaging, checksums, SBOM, provenance and new stable publication from the reviewed release request. | May create a new release only; existing published tag/assets are treated as immutable. | +| `.github/workflows/release-windows.yml` | Exact-byte promotion from the canonical Build ARSAS package and the matching installed-smoke-tested installer, release-specific runtime re-verification, checksums, SBOM, provenance and new stable publication from the reviewed release request. Manual dispatch retains local installer compilation fallback. | May create a new release only; existing published tag/assets are treated as immutable. | | `.github/workflows/installer-windows.yml` | Installer/portable packaging and smoke validation for engineering verification. | No stable GitHub Release publication authority. | | `.github/workflows/publish-verified-release.yml` | Alternative publication from already-tested workflow artifacts and an explicit verified publication request. | Create-only; refuses an existing tag rather than replacing it. | | `.github/workflows/release-supply-chain.yml` | Verify an existing stable release and add missing supply-chain evidence/attestation. | Additive only; refuses replacement of an existing published SBOM. | diff --git a/docs/audits/CI_P0_WORKFLOW_BASELINE.md b/docs/audits/CI_P0_WORKFLOW_BASELINE.md new file mode 100644 index 000000000..661e66d05 --- /dev/null +++ b/docs/audits/CI_P0_WORKFLOW_BASELINE.md @@ -0,0 +1,149 @@ +# CI-P0 — Workflow Stabilization Baseline + +Issue: #426 + +CI-P0 freezes the current GitHub Actions estate before any consolidation. It is deliberately non-semantic: no IEC 61850 runtime path, physical acceptance rule, release authority, or domain test contract is removed or weakened in this phase. + +## Why P0 exists + +The repository has accumulated many independently triggered workflows that repeat checkout, .NET setup, restore, build and test work. The failure mode is not only slower CI. Duplicated policy implementations drift, and an unrelated authority update can make several pipelines disagree about the same source of truth. + +The P0 goal is therefore: + +> make current CI topology observable, deterministic and corruption-resistant before optimizing it. + +## Main baseline + +Source: `71e8d7e864b22c2f2146e5f8576cef0126c5f6e7`. + +Observed before adding the CI-P0 integrity workflow: + +| Indicator | Count | +| --- | ---: | +| Tracked workflow files | 28 | +| `actions/checkout` uses | 31 | +| `actions/setup-dotnet` uses | 12 | +| `actions/setup-python` uses | 13 | +| `dotnet restore` invocations | 15 | +| `dotnet build` invocations | 15 | +| `dotnet test` invocations | 17 | +| `actions/upload-artifact` uses | 33 | +| Workflows containing a .NET build | 12 | +| Workflows containing a .NET test | 11 | +| Reusable `workflow_call` entry points | 0 | + +These are textual cost indicators, not runtime-duration measurements. They intentionally over-simplify complex workflows so growth and consolidation can be reviewed consistently. + +## CI-P0 guard + +The tracked budget lives in: + +`evidence/ci-workflow-budget.json` + +The deterministic inventory lives in: + +`scripts/audit-ci-workflows.py` + +Offline regression tests live in: + +`scripts/test-ci-workflows.py` + +The guard performs two independent checks: + +1. **Structural integrity** + - exactly one top-level workflow `name`; + - exactly one top-level `jobs` block; + - no duplicate job IDs; + - no duplicate step names inside the same job. + + Duplicate step names across different jobs remain legal. This catches the class of accidental copy/paste corruption that duplicated large sections of the R7 workflow without pretending to replace GitHub's YAML parser. + +2. **CI-sprawl budget** + - new workflows/builds/tests/checkouts cannot silently increase the frozen maxima; + - a deliberate increase must update the budget in the same reviewed change; + - reductions never require a budget increase. + +The P0 workflow itself is intentionally cheap: one checkout plus standard-library Python. It performs no .NET restore/build/test and uploads no artifact. + +## Ownership map + +### Core build / package + +- `.github/workflows/build.yml` — canonical PR/main Windows build, regression suite, portable EXE. +- `.github/workflows/installer-windows.yml` — installer-specific validation. +- `.github/workflows/release-windows.yml` — release-only Windows packaging and publication path. + +P0 does not merge these responsibilities yet. + +### IEC 61850 / SCL / physical authority + +- `interoperability-reference-guard.yml` +- `scl-interoperability-r7.yml` +- `smart-discovery-capture-build.yml` +- `smart-discovery-golden-budget-lock.yml` +- `smart-discovery-golden-provenance.yml` +- `smart-discovery-mainline-readiness.yml` +- `smart-discovery-merge-execution-guard.yml` +- `smart-discovery-post-merge-production.yml` +- `smart-discovery-production-promotion.yml` +- `smart-discovery-repeat-run-stability.yml` +- `rcb-export-guard.yml` + +These workflows currently encode multiple layers of historical, physical and promotion authority. P0 records them unchanged. P1/P2 may consolidate execution only after equivalent outcomes are proven. + +### Focused technical gates + +- `validate-io-testing.yml` +- `validate-sv-evidence.yml` +- `comtrade-viewer-integration.yml` +- `progressive-static-bench.yml` + +### Product site / adoption / measurement + +- `pages.yml` +- `adoption-proof.yml` +- `measurement-contract.yml` +- `search-growth.yml` +- `site-measurement.yml` +- `production-health.yml` + +### Release metadata / supply chain + +- `publish-verified-release.yml` +- `release-supply-chain.yml` +- `sync-release-documentation.yml` +- `sync-release-evidence.yml` + +## P0 invariants + +P0 must not: + +- retire an existing workflow; +- weaken a required physical or release gate; +- change engine/runtime source; +- reinterpret historical acceptance evidence; +- make one current engine pin silently replace a historical tested baseline; +- change product behavior merely to satisfy CI. + +P0 may: + +- add read-only inventory; +- add corruption detection; +- freeze duplicate-CI growth; +- document ownership and dependencies; +- restore a workflow to its known-good baseline when accidental text corruption is proven. + +## Handoff to CI-P1 + +CI-P1 should introduce the first reusable primitives and a single fast PR gate, but only after P0 is merged and stable. + +Recommended P1 order: + +1. reusable immutable engine resolver; +2. reusable .NET restore/build/test primitive; +3. one PR change classifier; +4. artifact reuse instead of repeated compilation; +5. repository-wide PR concurrency/cancellation; +6. shadow comparison before retiring any legacy workflow. + +The P0 budget becomes the before-state. P1 is successful when the expensive counts decrease while semantic guards remain equivalent. diff --git a/docs/audits/CI_P1_SHADOW_CONTRACT.md b/docs/audits/CI_P1_SHADOW_CONTRACT.md new file mode 100644 index 000000000..bb197aa37 --- /dev/null +++ b/docs/audits/CI_P1_SHADOW_CONTRACT.md @@ -0,0 +1,111 @@ +# CI-P1 — Reusable Exact-SHA Build/Test and Opt-in Shadow + +Issue: [#428](https://github.com/masarray/arsas/issues/428) +Predecessor: [CI-P0 workflow inventory](CI_P0_WORKFLOW_BASELINE.md). + +## Intent + +CI-P1 introduces a reusable engine resolver, a conservative changed-area +classifier, and an exact-SHA Windows compilation/test primitive. Existing +workflows remain the canonical authorities until a measured parity gate is +satisfied. **Shadow PASS is not a production, release or physical-device proof.** + +This phase intentionally introduces one extra .NET job *only on opt-in shadow +runs*. The P0 budget records that temporary overhead explicitly. + +## Contracts and single owners + +| Contract | New owner | Invariant | +| --- | --- | --- | +| Changed path classification | `scripts/classify-ci-changes.py` | Advisory, unknown/deleted changes cannot silently skip | +| Engine pin resolution | `scripts/resolve-ariec-lock.ps1` | Exact SHA, trusted repository, historical authority preserved | +| Composite resolver | `.github/actions/resolve-ariec-engine/action.yml` | Reuses resolver in the same job, no runner allocated | +| Build and test | `.github/workflows/_ci-p1-windows-build-test.yml` | One restore + build + full tests, no publish or release | +| Opt-in caller | `.github/workflows/ci-p1-shadow.yml` | Read-only PR merge SHA, conservative classifier, branch/label opt-in | +| Structural CI budget | `evidence/ci-workflow-budget.json` | Existing P0 baseline immutable; migration delta explicitly capped | + +The canonical existing `build.yml`, IEC/SCL guards, physical evidence, RCB, +FAT/IO and SV workflows are unchanged. + +## Reproduce classifier validation + +```bash +python3 scripts/test-classify-ci-changes.py +python3 scripts/classify-ci-changes.py --paths Services/NativeIec61850Client.SclAssisted.cs +``` + +On a PR, `ci-p1-shadow.yml` fetches the synthetic GitHub PR merge commit with +two parents and inspects its diff against the first parent. This tests the +candidate **as it would merge into main**, not an arbitrary developer branch +snapshot. If a merge parent cannot be resolved or the diff is empty, the +classifier deliberately selects broad verification. + +The classifier never disables a required legacy gate. It provides advisory +area flags so CI-P2 can make a reviewed targeted-job decision. + +## Reproduce immutable lock resolver validation + +```powershell +pwsh -NoProfile -File scripts/test-resolve-ariec-lock.ps1 +pwsh -NoProfile -File scripts/resolve-ariec-lock.ps1 -LockPath engines/ARIEC61850.lock.json +``` + +The resolver only reads the lock. It rejects untrusted repositories, symbolic +engine commit refs, invalid hashes, and inconsistent historical accepted-layer +references. The reusable job then checks out that exact commit and verifies +`git rev-parse HEAD` before compilation. Historical physical SHA and current +build SHA remain separate manifest fields. + +## Shadow opt-in + +The Windows shadow runs when: + +- the PR branch is `ci/p1-fast-gate-shadow` (CI-P1 self-validation); +- a PR has label `ci-p1-shadow`; or +- `workflow_dispatch` is explicitly requested on an available branch. + +Otherwise the lightweight classifier runs (subject to path filters), while +legacy CI remains unchanged. For a labeled PR, the shadow only compiles if the +classifier indicates a .NET-impacting change. + +The called Windows job does **not** create a public Release, publish an +installer, write evidence back to Git, or mutate GitHub settings. + +## Exact-sha parity gate + +Before a legacy job may be retired, compare: + +1. The synthetic PR merge source SHA used by canonical Build ARSAS and P1. +2. The `engines/ARIEC61850.lock.json` resolved commit and the checked-out engine SHA. +3. Full regression test outcomes (not just selected tests), including failure + diagnostics and TRX. +4. Coverage of source, release, field-authority and post-merge checks that are + **not** included in the reusable build/test primitive. +5. The packaged binary SHA when packaging is eventually moved; a shadow TRX + artifact alone must never serve as a release binary. + +Shadow metadata uploads `ci-p1-shadow-manifest.json` and full test TRX as +short-retention, non-promotion evidence. It deliberately contains neither +source CID files nor physical device identifiers. + +## Temporary migration cost + +The P0 immutable observed baseline is still **29 workflows**, **15 restore**, +**15 build** and **17 test** after the P0 guard. CI-P1 adds two workflow files: +one lightweight classifier and one reusable Windows shadow. The guarded maximum +during migration is **31 workflows**, **16 restore**, **16 build** and **18 test** +invocations in workflow source. + +These are static source counts, not simultaneous runtime invocations and not a +claim that CI is faster already. CI-P2 should reduce these counts through +verified replacement and artifact reuse, not by weakening guard semantics. + +## Safety gate for promotion of P1 + +P1 may merge to main when P0 structural/classifier tests, reusable Windows +build/test, and unchanged legacy checks are green on the same tested PR merge +head. The post-merge P0 guard must remain green. + +P1 does not approve reducing physical tests or disabling existing Build ARSAS. +A future CI-P2 PR must prove parity with accepted field evidence and tag/release +boundaries before retiring each specific legacy workflow. diff --git a/docs/audits/CI_P2A_CANONICAL_REGRESSION_REUSE.md b/docs/audits/CI_P2A_CANONICAL_REGRESSION_REUSE.md new file mode 100644 index 000000000..f082a9452 --- /dev/null +++ b/docs/audits/CI_P2A_CANONICAL_REGRESSION_REUSE.md @@ -0,0 +1,118 @@ +# CI-P2A — Canonical Regression Proof Reuse + +Issue: [#430](https://github.com/masarray/arsas/issues/430) +Predecessor: [CI-P1 reusable/shadow architecture](CI_P1_SHADOW_CONTRACT.md). + +## Scope and optimization + +The Smart Discovery Merge Execution Guard used to re-run the same .NET +restore/build/full ARSAS regression suite already executed unconditionally by +canonical `Build ARSAS` on **every PR**. + +P2A removes those duplicate PR compilation steps, **without** losing the +historical `Verify P0-5h ordered merge contract` check or its regression +requirement. + +The specialized check now has two independent, fail-closed stages: + +1. Validate the original ordered-merge evidence, scripts, PowerShell parser + output and mutation-scope constraints. +2. Consume and independently verify the successful canonical Build ARSAS test + artifact for the *same source and engine*. It may not pass on historical or + stale green results. + +This is a single-lane reduction, not permission to disable other Smart +Discovery, physical-retest, IEC 61850 or release checks. + +## Canonical evidence producer + +Canonical `.github/workflows/build.yml` still builds and tests normally. +After its successful full regression step, it runs +`scripts/write-ci-canonical-proof.py` and adds a tiny manifest to its **existing** +`ARSAS-test-evidence` artifact. No additional artifact upload job or +release/publication authority is created. + +`ci-canonical-authority.json` binds: + +- exact checked-out ARSAS SHA; +- exact checked-out ARIEC61850 SHA from the immutable lock; +- canonical workflow name, run ID and attempt; +- SHA-256 of the actual full-regression `arsas-tests.trx`; +- exact full test counters, including zero failed/not-executed; +- explicit `releasePromotionAuthority=false`. + +The manifest is a CI claim, **not a cryptographic signature**, and does not +replace real physical IED evidence. + +## Specialized consumer + +`scripts/verify-ci-canonical-reuse.py` has access only to a token with +`actions: read` and `contents: read`. It: + +1. locates the latest `Build ARSAS` pull-request workflow for the exact + PR head SHA and branch (not an older successful attempt); +2. requires the run to be `completed/success`, including its portable + package/smoke tests; +3. downloads only the canonical `ARSAS-test-evidence` artifact; +4. parses the ZIP **as data** without extracting or executing it; +5. validates artifact safety, manifest schema, exact synthetic PR merge SHA, + exact engine SHA, workflow run/attempt ID, TRX content SHA-256 and all + test counters; +6. rejects missing/incorrect artifacts, failed runs, conflicting test counts, + stale merge trees and timeouts. + +GitHub archive redirect handling removes bearer credentials on cross-origin +redirects to artifact blob storage. + +The bounded waiting policy is 16 minutes; timeout is **FAIL**, not skipped. +A canonical failure is reflected as specialized check failure, regardless +of which checks are configured as branch-protection requirements. + +## Manual execution + +For `workflow_dispatch`, the P0-5h source guard still runs. The reusable +`_ci-p1-windows-build-test.yml` job supplies a fresh exact-SHA full test, +rather than requiring a nonexistent PR Build run. It remains test-only and +cannot promote a release or mutate physical evidence. + +## Before and after + +The immutable P0 observed baseline is unchanged and retained in +`evidence/ci-workflow-budget.json`. P2A changes the guarded *workflow-source* +maximums: + +| Indicator | P1 maximum | P2A maximum | +| --- | ---: | ---: | +| Workflow files | 31 | 31 | +| setup-dotnet uses | 13 | 12 | +| .NET restore invocations | 16 | 15 | +| .NET build invocations | 16 | 15 | +| .NET test invocations | 18 | 17 | +| Workflows containing .NET build | 13 | 12 | +| Workflows containing .NET test | 12 | 11 | + +Those are static counts, **not** evidence that every PR is 1/16 faster. +The removed duplicated Windows compilation frees compute and reduces +independent failure surfaces; the specialized guard still waits for canonical +completion on an Ubuntu runner, so its verdict cannot precede canonical CI. + +## Validation and rollback + +Offline synthetic fixtures: + +```bash +python3 scripts/test-ci-canonical-reuse.py +python3 scripts/test-ci-workflows.py +``` + +Before merge, prove the P0 inventory guard, canonical Build ARSAS, new +specialized check, R10 readiness, Golden/Promotion and Field Capture workflows +all green on the same exact PR head and merge tree. + +If the run metadata, artifact format or permission model creates unacceptable +flakiness, revert only the specialized verifier/consumer lane to the previous +direct build/test commands; **never disable** the P0-5h source contract, and +never treat failed proof reuse as a green skip. + +Remaining CI-P2 work should target other duplicated lanes separately. +No other workflow retirement is authorized by this P2A change. diff --git a/docs/audits/CI_P2B_MAINLINE_READINESS_REUSE.md b/docs/audits/CI_P2B_MAINLINE_READINESS_REUSE.md new file mode 100644 index 000000000..3840c957e --- /dev/null +++ b/docs/audits/CI_P2B_MAINLINE_READINESS_REUSE.md @@ -0,0 +1,78 @@ +# CI-P2B — Mainline Readiness without duplicate application compilation + +Companion: [CI-P2A canonical exact-SHA reuse](CI_P2A_CANONICAL_REGRESSION_REUSE.md). +Issue: [#432](https://github.com/masarray/arsas/issues/432). + +## What changed + +`Smart Discovery Mainline Readiness` already performs an R10 physical acceptance +audit and independent **ARIEC61850 engine** restore/build/full tests. Previously +it also repeated the entire ARSAS application restore/build/full tests, which +canonical `Build ARSAS` runs unconditionally on the same PR merge tree. + +CI-P2B preserves **all** original R10 physical convergence predicates and +the independently checked-out exact engine head and engine test suite. + +Only the duplicate *application* restore/build/test portion is replaced with +the reviewed CI-P2A `scripts/verify-ci-canonical-reuse.py` consumer. + +That consumer can pass only if the latest matching canonical Build ARSAS +`pull_request` run: + +1. completed successfully, including packaging and portable smoke; +2. has the same PR head SHA and branch; +3. produced all-pass canonical TRX evidence for **this exact synthetic merge SHA**; +4. used exactly the same immutable engine commit that passed R10 physical + convergence checks; +5. published a manifest matching run ID, attempt, TRX SHA-256 and test counts. + +Failures, missing evidence, stale merges and API/timeouts still fail closed. +Branch-protection settings are not assumed. Both check names remain visible. + +For `workflow_dispatch`, the independent engine checks still run and an +additional reusable exact-SHA test-only ARSAS job provides manual coverage. + +## Costs and bounds + +The P0 immutable historical baseline is unchanged. CI-P2B reduces the +static workflow command maxima by **one more** duplicate .NET +restore/build/test, in addition to CI-P2A: + +| Metric | CI-P1 | CI-P2A | CI-P2B | +| --- | ---: | ---: | ---: | +| `dotnet restore` occurrences | 16 | 15 | 14 | +| `dotnet build` occurrences | 16 | 15 | 14 | +| `dotnet test` occurrences | 18 | 17 | 16 | +| `actions/setup-dotnet` uses | 13 | 12 | 12 | +| `actions/setup-python` uses | 13 | 13 | 14 | + +The +1 Python action ensures the same audited verifier runs reproducibly +on the Windows R10 engine test runner. Unlike the removed application build, +it does not trigger another compilation. Actual wall-time still depends +on canonical Build ARSAS, engine tests, runner load, and restore caches. + +## Physical authority boundary + +This milestone does **not** modify: + +- SCL, Discovery, MMS, RCB or report runtime behavior; +- accepted R10 Ed1/Ed2 physical evidence; +- exact engine lock / historical physical tested head; +- ARIEC61850 engine tests or their post-merge authority; +- P141 SCL-assisted interoperability draft work; +- Golden Budget, Provenance, Repeat-run, Production or Field Capture guards; +- any release/installer workflow. + +## Acceptance + +Required: P0 integrity and budget, canonical Build ARSAS, Merge Execution +Guard, Mainline Readiness (engine full tests **plus** canonical ARSAS proof), +Field Capture smoke, Golden/Promotion guards — all on same exact PR head. + +Regression rollback is narrow: restore the old application full-test step in +Mainline Readiness, leave independently retained engine/R10 validation and +canonical Build ARSAS untouched. + +CI-P2C can later consolidate Field Capture packaging only with a separately +verified immutable binary artifact and all physical capture-specific checks. +No such retirement is authorized by P2B. diff --git a/docs/audits/CI_P2C_PRODUCTION_ROUTE_REUSE.md b/docs/audits/CI_P2C_PRODUCTION_ROUTE_REUSE.md new file mode 100644 index 000000000..0983e78c1 --- /dev/null +++ b/docs/audits/CI_P2C_PRODUCTION_ROUTE_REUSE.md @@ -0,0 +1,44 @@ +# CI-P2C — Canonical regression reuse in production-route guard + +Issue #436. Existing accepted milestones: P2A #431 and P2B #433. + +## Scope + +On pull requests, Smart Discovery Production Promotion Guard independently verifies +the physical-proven Smart Discovery route before and after checking exact-SHA +canonical Build ARSAS full-regression evidence. This is a CI execution +optimization, not production release permission or a new field acceptance. + +## Independent checks retained + +- Checkout exact triggering synthetic PR merge SHA and assert git HEAD. +- Validate immutable engine lock without substituting historical physical SHA. +- Run enable-smart-discovery-capture.ps1 -VerifyOnly before proof consumption. +- Verify the route and critical reset/dispose source markers. +- Preserve NativeIec61850Client.cs SHA256 before/after evidence verification. +- Run -VerifyOnly again and reject workflow/promotion dependent targets. + +## Exact canonical proof required + +The unchanged scripts/verify-ci-canonical-reuse.py rejects: +- wrong PR branch/head SHA or synthetic merge SHA; +- wrong engine lock SHA, stale or failed newest canonical run; +- ambiguous/missing artifacts, malformed or unsafe archives; +- altered TRX bytes, bad test counters or nonzero failures/skips; +- invalid API provenance or unbounded evidence. + +The tool uses read-only Actions API and a bounded waiting policy. +Manual workflow_dispatch retains reusable full regression separately. +Packaging, field capture, R10 independent engine testing, release and +production promotion remain owned by existing workflows. + +## Inventory and rollback + +Static source cost from P2B to P2C: dotnet restore/build/test 14/14/16 +to 13/13/15; setup-dotnet 12 to 11; setup-python 14 to 15. +Historical P0 observations are unchanged. Lowered limits are recorded in +evidence/ci-workflow-budget.json. + +If exact cross-run matching is unreliable, revert the workflow migration +and restore direct dotnet regression execution; never drop route assertions +or rewrite historical IEC 61850 physical evidence. diff --git a/docs/audits/CI_P2D_ARTIFACT_READY_PROOF.md b/docs/audits/CI_P2D_ARTIFACT_READY_PROOF.md new file mode 100644 index 000000000..ad9901419 --- /dev/null +++ b/docs/audits/CI_P2D_ARTIFACT_READY_PROOF.md @@ -0,0 +1,55 @@ +# CI-P2D — Artifact-ready canonical regression proof + +Issue #438. + +## Problem + +P2A/P2B/P2C removed duplicate ARSAS compilation, but their guard runners still +waited until the entire canonical Build ARSAS workflow finished. In reference +run 37567436447, the exact regression artifact was uploaded at 03:37:44Z while +portable publish/smoke kept the canonical workflow alive until 03:39:45Z. +That left about 121 seconds of avoidable polling per consumer. + +## P2D behavior + +The canonical verifier keeps completed-workflow proof as its default. P2D adds +an explicit --allow-in-progress-artifact mode. In that mode, the newest matching +Build ARSAS run may satisfy the full-regression consumer while still in progress +only after the canonical manifest and TRX artifact exist and validate. + +The verifier still requires exact PR head/branch, exact synthetic PR merge SHA, +exact pinned engine SHA, exact workflow run ID/attempt, safe bounded archive, +matching TRX SHA-256 and all-pass counters with zero failed/notExecuted tests. + +A Build ARSAS run that is already completed with a non-success conclusion is +always rejected even when a prior test artifact exists. A missing artifact +continues bounded polling rather than becoming a pass. + +## Authority boundary + +Artifact-ready mode proves full regression only. If Build ARSAS is still +running, the proof explicitly reports workflowCompleted=false and +packagingSmokeProven=false. Portable publish/smoke remains the independent +Build ARSAS responsibility and is not promoted by Merge Execution, Mainline +Readiness or Production Route Guard. + +## Consumers + +- Smart Discovery Merge Execution Guard +- Smart Discovery Mainline Readiness +- Smart Discovery Production Promotion Guard + +Each consumer opts in explicitly. No other caller changes from strict default +behavior. + +## Measurement + +The 121-second post-regression packaging interval is observational evidence, +not a flaky timing acceptance test. With three consumers, the reference run +represents roughly six runner-minutes that can be avoided per comparable PR. + +## Rollback + +Remove --allow-in-progress-artifact from the three consumers. This immediately +restores completed-workflow waiting while preserving the same canonical evidence +format, exact-SHA checks, physical authority and release policy. diff --git a/docs/audits/CI_P2E_POST_MERGE_MAIN_REUSE.md b/docs/audits/CI_P2E_POST_MERGE_MAIN_REUSE.md new file mode 100644 index 000000000..9da2fb151 --- /dev/null +++ b/docs/audits/CI_P2E_POST_MERGE_MAIN_REUSE.md @@ -0,0 +1,57 @@ +# CI-P2E — Exact-main canonical ARSAS regression reuse + +Issue #440. + +## Goal + +Remove the duplicate ARSAS application restore/build/full-test from +Smart Discovery Post-Merge Production Verification while preserving: + +- exact R10 physical convergence assertions; +- exact merged ARIEC61850 checkout; +- independent engine restore/build/full-test; +- Build ARSAS portable publish/smoke authority; +- post-merge diagnostic attestation. + +## Canonical main proof + +The shared verifier now accepts an explicit workflow event identity. Its default +remains pull_request for existing P2A-P2D consumers. P2E calls it with: + +- event-name=push; +- branch=main; +- head SHA equal to GITHUB_SHA; +- source SHA equal to that same GITHUB_SHA; +- exact immutable engine lock SHA; +- artifact-ready mode enabled. + +The artifact must still contain the exact canonical manifest and TRX, exact +run ID/attempt, matching SHA-256 digest and an entirely green test suite. +A completed failed Build ARSAS run is never reusable. + +## Attestation + +R10-post-merge-production.json remains schemaVersion 1 for compatibility and +adds explicit fields showing whether independent engine regression and canonical +ARSAS regression were actually verified. Because the artifact is uploaded with +if: always(), verificationStatus becomes incomplete-or-failed if either proof +did not complete. A diagnostic artifact therefore cannot imply acceptance. + +## Static CI cost + +Compared with P2D, workflow-source dotnet restore/build/test counts fall from +13/13/15 to 12/12/14. setup-dotnet is unchanged because the post-merge job still +runs the engine suite. setup-python increases from 15 to 16 for the pinned +canonical evidence verifier. + +## Authority boundary + +Canonical regression reuse proves ARSAS application tests only. It does not +replace the R10 engine suite, physical field evidence, portable packaging, +installer validation or release promotion. + +## Rollback + +Restore the direct ARSAS main restore/build/test step in the post-merge workflow. +No evidence format, engine test or physical authority must be weakened to roll +back this optimization. diff --git a/docs/audits/CI_P2F_FIELD_CAPTURE_REGRESSION_REUSE.md b/docs/audits/CI_P2F_FIELD_CAPTURE_REGRESSION_REUSE.md new file mode 100644 index 000000000..f472e6118 --- /dev/null +++ b/docs/audits/CI_P2F_FIELD_CAPTURE_REGRESSION_REUSE.md @@ -0,0 +1,62 @@ +# CI-P2F — Field Capture canonical regression reuse + +Issue #442. + +## Decision + +Field Capture may reuse canonical ARSAS full-regression evidence, but it may not +reuse the canonical portable executable. + +On PR #441 both lanes used the same ARSAS head and integration engine SHA, yet +the executables were not byte-identical: + +- canonical: 78,397,596 bytes, SHA-256 49ff985f9ab969483836810dc15895da5ea0b9b046cfb57fdb96b8897269e774; +- Field Capture: 78,397,601 bytes, SHA-256 ef80317a8665b7fa900473650864f8d7fb7f9250229880c11b5f4a558aaab40e. + +Therefore this milestone optimizes regression execution only. + +## Pull request path + +The Field Capture workflow still: + +- validates the physical baseline commit 4467124775d8d9d76f3db194f9fbfd97144767a8; +- resolves and checks out the current integration engine; +- validates P0-5c association-scoped Smart Discovery source contracts; +- executes deterministic P0-5d wire-proof PASS/FAIL fixtures; +- validates route/lifecycle markers before packaging; +- runs publish-windows-portable.ps1 independently; +- smoke-tests the resulting Field Capture executable; +- uploads the Field Capture package and field-specific evidence. + +The duplicate explicit solution restore/build/dotnet-test sequence is removed. +Full ARSAS regression is instead required from the exact PR synthetic merge SHA, +exact integration engine SHA, canonical Build ARSAS artifact and all-pass TRX. + +publish-windows-portable.ps1 still performs its own restore and dotnet publish; +therefore the Field Capture binary remains independently built in its own lane. + +## Manual workflow_dispatch + +Manual packaging cannot depend on a PR artifact. It runs the reusable CI-P1 +exact-SHA full-regression job first. Field Capture packaging proceeds only when +that manual regression job succeeds. + +## Evidence artifact + +The existing ARSAS-smart-discovery-pr134-test-evidence artifact name remains. +For PR runs its regression component is now +ci-smart-capture-canonical-proof.json rather than a duplicated local TRX. +P0-5D/P0-5E evidence remains field-lane evidence. + +## Static cost + +Compared with P2E, workflow-source restore/build/test tokens fall from +12/12/14 to 11/11/13. setup-python rises from 16 to 17 for exact canonical +proof verification. Workflow files containing an explicit dotnet build/test +fall from 11/10 to 10/9. + +## Rollback + +Restore the explicit Field Capture restore/build/test sequence. Never substitute +the canonical EXE for the Field Capture EXE unless a later reproducible-build +milestone proves byte identity on matching source and engine SHAs. diff --git a/docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md b/docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md new file mode 100644 index 000000000..8a6e6c45e --- /dev/null +++ b/docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md @@ -0,0 +1,48 @@ +# CI-P3A — Reproducible Windows portable build identity + +Issue #447. + +## Purpose + +P2F proved that matching ARSAS and ARIEC61850 revisions could still produce +different portable single-file executables in independent workflows. Therefore +binary artifact reuse remains prohibited until reproducibility is proven. + +## Root-cause hypothesis and controlled change + +The Windows package embeds a pinned ArdIrec native bridge. That bridge is built +independently with MSVC/CMake in each workflow. A PE linker timestamp or other +non-reproducible native metadata can change the embedded bridge and, because the +single-file payload is compressed, propagate into a large executable diff. + +P3A does not treat that hypothesis as accepted evidence. It makes the native +shared-library linker request reproducible output with /Brepro and makes the +.NET publish determinism intent explicit with Deterministic=true and +ContinuousIntegrationBuild=true. Existing ArdIrec native regression tests remain. + +## Build identity + +Each single-file publish writes a JSON identity next to the executable containing: + +- ARSAS source commit when the source is a Git checkout; +- ARIEC61850 engine commit when the project is a Git checkout; +- pinned ArdIrec lock commit; +- ArdIrec bridge SHA-256 and byte size; +- portable executable SHA-256 and byte size; +- version/runtime and deterministic-build flags. + +Both Build ARSAS and Smart Discovery Field Capture upload that identity. + +## Acceptance boundary + +P3A itself does not authorize installer/release artifact reuse. After CI, the two +independent builders must report identical source, engine and ArdIrec inputs and +identical bridge plus portable SHA-256. Only that measured result can unlock P3B. + +Installer/release/physical authority and runtime behavior are unchanged. + +## Efficiency + +No new heavy workflow is introduced. Existing canonical and Field Capture lanes +serve as the independent builders, so reproducibility evidence is obtained from +work already required by the repository. diff --git a/docs/audits/CI_P3B_SEALED_CANONICAL_PORTABLE_PROMOTION.md b/docs/audits/CI_P3B_SEALED_CANONICAL_PORTABLE_PROMOTION.md new file mode 100644 index 000000000..46a471af3 --- /dev/null +++ b/docs/audits/CI_P3B_SEALED_CANONICAL_PORTABLE_PROMOTION.md @@ -0,0 +1,82 @@ +# CI-P3B — Sealed canonical portable promotion into Field Capture + +Issue #449. + +## Basis + +P3A proved that two independent Windows builders using the same ARSAS source, +ARIEC61850 engine and ArdIrec revision produced byte-identical native bridge and +portable executable outputs. + +Accepted parity: + +- synthetic merge: `3794b29fa71695ec4ea099469ae2e55054d3d3f8`; +- engine: `648124097621046f5f127ceb1cf853fea54db730`; +- ArdIrec: `7dbc7149db668126a493ce338264363a6ec5ec00`; +- bridge SHA-256: `fa8efcaf26e8ebb0a29808a2e52ad5c9f466ec2df146945d29c5d662987c41ce`; +- portable SHA-256: `9fa7fac00c96acb24c98e64ff91ca9ee1709d9f1b89bf55030284ee3da4fc369`; +- portable size: 78,789,758 bytes. + +## Architecture + +P3B uses the existing `ARSAS-windows-package-input` as the sole canonical +promotion substrate. Field Capture does not use a second ad-hoc artifact +download implementation. + +Build ARSAS seals: + +- the tested portable executable; +- its portable build-identity JSON; +- installer input; +- canonical all-pass TRX and manifest; +- native bridge bytes; +- prebuilt verification runtime and fixtures; +- SHA-256 and size for every promoted file. + +The sealed package verifier independently checks exact source, engine, ArdIrec, +workflow run/attempt/event, full-regression counters, every file digest, the +portable build identity and Windows-safe archive paths before materialization. + +## Pull-request Field Capture path + +Field Capture still owns and executes: + +- exact candidate checkout; +- physical baseline engine evidence; +- P0-5c Smart Discovery source invariants; +- deterministic P0-5d wire-proof PASS/FAIL fixtures; +- integration-engine inspection; +- Smart Discovery route/lifecycle verification; +- portable runtime smoke; +- field artifact publication. + +The duplicate PR native bridge + self-contained .NET publish is removed. +After sealed package verification, the exact canonical portable executable and +its exact build identity are copied into the Field Capture dist directory, +smoke-tested again and uploaded under the existing +`ARSAS-smart-discovery-pr134-win-x64` contract. + +## Manual workflow_dispatch + +Manual Field Capture remains independent. It runs the reusable exact-SHA full +regression and then performs its own portable publish. This prevents a manual +field build from depending on a historical PR package. + +## Readiness and fail-closed behavior + +P3B inherits the shared sealed-package verifier's bounded artifact readiness +policy. A transient GitHub archive HTTP 404 may be retried only within the +existing deadline. Persistent 404 times out fail-closed; non-404 request +failures remain hard failures. Completed failed Build ARSAS runs are never +reused. + +## Authority boundary + +P3B changes only the normal PR Field Capture packaging source. It does not +change installer authority, release promotion, physical field evidence, or IEC +61850 runtime behavior. + +## Rollback + +Restore the PR Field Capture independent publish path. Do not add a separate +portable downloader or weaken the shared sealed-package identity/hash checks. diff --git a/docs/audits/CI_P3C_RELEASE_EXACT_PACKAGE_PROMOTION.md b/docs/audits/CI_P3C_RELEASE_EXACT_PACKAGE_PROMOTION.md new file mode 100644 index 000000000..74b277598 --- /dev/null +++ b/docs/audits/CI_P3C_RELEASE_EXACT_PACKAGE_PROMOTION.md @@ -0,0 +1,83 @@ +# CI-P3C — Exact sealed package promotion through Windows release + +Issue #455. + +## Objective + +Complete the Windows build-once/package-many chain. Build ARSAS is the canonical +application regression and package-byte authority; the production release workflow +must package and publish those exact bytes rather than rebuilding equivalent source. + +## Production path + +For a reviewed `.release/windows.json` push on `main` and for `v*.*.*` tag +pushes, `release-windows.yml`: + +1. checks out and verifies the exact release source SHA; +2. resolves release version, ARIEC61850 SHA and ArdIrec SHA from tracked locks; +3. queries the latest matching Build ARSAS **push/main** run for that exact SHA; +4. verifies the sealed `ARSAS-windows-package-input` before materialization; +5. requires exact source/engine/ArdIrec/run identity, all-pass canonical TRX, + portable build identity and every payload file SHA-256; +6. copies the verified installer-input directory and portable EXE into release + staging without rebuilding application bytes; +7. reruns the release-specific native bridge/locus verification; +8. reruns portable smoke; +9. compiles the Inno installer from the exact verified installer input; +10. performs silent install/uninstall and installed native bridge/locus tests; +11. generates checksums, SPDX SBOM, provenance and artifact attestations; +12. applies the existing immutable create-only stable publication contract. + +A tag that points at a commit without an exact successful Build ARSAS push/main +package fails closed. It does not fall back to a production rebuild. + +## Release-specific fixture + +P3C does not replace the historical release-native fixture with the installer +fixture. Build ARSAS now seals both `p1-release-smoke.cfg` and its DAT companion +inside the shared package payload. The package manifest exposes +`releaseFixturePath`; the verifier confirms that path is part of the +hash-complete payload before release can use it. + +## Manual fallback + +`workflow_dispatch` keeps the historical local restore/build/test/folder publish/ +portable publish path. This is an engineering recovery mechanism, not the normal +production promotion path. Existing `publish_release`, prerelease and immutable +published-tag behavior remain unchanged. + +## Authority boundaries + +- **Build ARSAS:** canonical application regression, native bridge build, + installer-input bytes, portable bytes and sealed package manifest. +- **Release workflow:** release version policy, native release re-verification, + Inno compilation, installed-package smoke, checksums, SBOM, provenance, + attestations and public release creation. +- **Physical Smart Discovery evidence:** unchanged. + +The sealed package itself still states `releasePromotionAuthority=false`. +Release authority is created only after the release-owned checks above succeed. + +## Provenance + +Release provenance remains schemaVersion 2 and adds: + +- `canonicalPackageAuthority`; +- `canonicalPackageRun`; +- `canonicalPackageArtifactSha256`; +- `canonicalPortableSha256`. + +Manual fallback records its authority as `manual-local-build`. + +## CI accounting + +Static restore/build/test counts do not fall because the manual fallback remains +tracked in YAML. Runtime work on production push/tag releases does fall: the +application restore/build/full regression, installer-input publish and portable +publish are no longer repeated. + +## Rollback + +Restore the non-manual release rebuild/publish steps and stop consuming the sealed +package. Do not weaken package identity, hashes, canonical TRX, build identity or +stable release immutability as a workaround. diff --git a/docs/audits/CI_P3D_VALIDATED_INSTALLER_PROMOTION.md b/docs/audits/CI_P3D_VALIDATED_INSTALLER_PROMOTION.md new file mode 100644 index 000000000..ffe25aa08 --- /dev/null +++ b/docs/audits/CI_P3D_VALIDATED_INSTALLER_PROMOTION.md @@ -0,0 +1,82 @@ +# CI-P3D — Exact validated installer promotion + +Issue #457. + +## Objective + +Complete the Windows build-once/package-many chain at the installer layer. +The normal production release path must publish the exact installer bytes that +were already compiled, silently installed, runtime-tested and uninstalled by the +installer validation workflow for the same source and canonical package. + +## Installer authority + +After installer compilation and installed runtime smoke pass, +`.github/workflows/installer-windows.yml` writes +`ci-installer-authority.json` into the existing versioned installer artifact. + +The manifest binds: + +- release version; +- exact ARSAS source SHA; +- exact ARIEC61850 SHA; +- exact ArdIrec SHA; +- canonical Build ARSAS package run; +- canonical package artifact SHA-256; +- installer workflow run and attempt; +- installer filename, SHA-256 and size; +- `installedSmokePassed=true`; +- `releasePromotionAuthority=false`. + +The installer validator remains validation authority only. It cannot publish a +stable release by itself. + +## Production release + +For reviewed main release requests and tag releases, +`release-windows.yml`: + +1. verifies and materializes the exact sealed Build ARSAS package as in P3C; +2. waits boundedly for the matching successful installer validation + `push/main` run for the same source SHA; +3. validates the installer manifest and exact installer bytes; +4. requires the installer to reference the same canonical package artifact + already accepted by the release; +5. promotes those exact installer bytes without installing Inno Setup or + recompiling the installer; +6. still performs release-specific silent install/uninstall and installed + native bridge/locus tests with the release fixture; +7. generates release checksums, SPDX SBOM, provenance and attestations; +8. preserves the existing create-only/immutable stable release contract. + +A tag without an exact validated main installer fails closed. + +## Manual fallback + +`workflow_dispatch` retains local Inno Setup installation and installer +compilation. It records `manual-local-build` installer authority. + +## Provenance + +Release provenance adds: + +- `validatedInstallerAuthority`; +- `validatedInstallerArtifactSha256`; +- `validatedInstallerSha256`. + +These fields supplement the P3C canonical package provenance. They do not +replace source, engine, ArdIrec or portable identity. + +## Authority boundaries + +- **Build ARSAS:** tested application and canonical package bytes. +- **Installer validation:** installer compilation plus installed smoke authority. +- **Release workflow:** release-specific installed runtime re-verification, + checksums, SBOM, provenance, attestations and public release creation. +- **Physical IEC 61850 evidence:** unchanged. + +## Rollback + +Restore production Inno Setup installation and installer compilation in the +release workflow. Do not weaken source/package/installer identity checks to make +promotion pass. diff --git a/docs/audits/CI_P3F_INSTALLER_PACKAGE_READY_GATE.md b/docs/audits/CI_P3F_INSTALLER_PACKAGE_READY_GATE.md new file mode 100644 index 000000000..f77c04aea --- /dev/null +++ b/docs/audits/CI_P3F_INSTALLER_PACKAGE_READY_GATE.md @@ -0,0 +1,63 @@ +# CI-P3F — Defer Windows installer allocation until canonical package readiness + +Issue #460. + +## Problem + +On main commit 0814793c7473dbae1c1c64da56df12cbeecf5df7, the installer Windows runner +started at 22:18:39Z while the canonical Build ARSAS package was not uploaded until +22:24:11Z. The installer package-reuse step occupied the Windows runner from +22:19:01Z to 22:24:27Z: 326 seconds of mostly waiting before installer-specific work. + +Installer-specific compile/install/runtime validation after package readiness took +about 152 seconds. + +## Design + +For pull_request and push events, installer-windows.yml now starts a small +ubuntu-latest package-ready job first. It only checks GitHub Actions metadata: + +- exact Build ARSAS workflow name; +- exact event, head SHA and head branch; +- latest matching run ID/attempt; +- latest completed failure is rejected; +- exactly one non-expired ARSAS-windows-package-input artifact must exist. + +The gate does not download the artifact archive and never validates or trusts package +bytes. Its sole purpose is scheduling: the Windows installer job is not allocated until +the canonical artifact metadata exists. + +## Authority boundary + +The Windows installer job still runs verify-ci-package-reuse.py. That verifier remains +the package authority and still validates: + +- exact source, ARIEC61850 and ArdIrec SHAs; +- Build ARSAS run ID/attempt/event; +- canonical TRX digest and all-pass counters; +- every sealed payload file size and SHA-256; +- portable build identity and native bridge identity; +- package authority flags. + +Installer compilation, silent install/uninstall, installed native runtime tests, +checksums and validated-installer sealing are unchanged. + +Metadata presence is therefore not equivalent to package validity. + +## Manual workflow_dispatch + +Manual dispatch bypasses package-ready because it retains the existing local-build +fallback. The Windows job is allowed to start when either the metadata gate succeeds +or the event is workflow_dispatch. + +## Static topology + +No workflow file, checkout action, setup-python action, dotnet restore/build/test or +artifact-upload count is added. The change targets runtime allocation efficiency, not +textual CI primitive counts. + +## Rollback + +Remove package-ready and the installer needs/if dependency to return to direct Windows +polling. Do not remove or weaken verify-ci-package-reuse.py or installer/runtime smoke +as part of rollback. diff --git a/docs/audits/CI_P3G_RELEASE_READY_GATE.md b/docs/audits/CI_P3G_RELEASE_READY_GATE.md new file mode 100644 index 000000000..0420cfbc8 --- /dev/null +++ b/docs/audits/CI_P3G_RELEASE_READY_GATE.md @@ -0,0 +1,92 @@ +# CI-P3G — Release-ready metadata gate + +Issue #462. + +## Goal + +Keep expensive Windows release capacity out of dependency polling. + +Before P3G, the production release workflow allocated `windows-latest` +immediately and could then spend up to 1200 seconds waiting for the exact +canonical Build ARSAS package and another 1800 seconds waiting for the exact +validated installer. + +P3F already moved canonical-package waiting out of the installer Windows job. +P3G applies the same orchestration principle to the release lane. + +## Production release path + +For non-manual release events, a lightweight `ubuntu-latest` `release-ready` +job: + +1. checks out the exact release source SHA; +2. resolves and validates release version, ARIEC61850 SHA and ArdIrec SHA; +3. requires the latest exact Build ARSAS `push/main` run for the same source; +4. requires exactly one non-expired `ARSAS-windows-package-input` artifact; +5. requires the latest exact installer `push/main` run for the same source to + be completed successfully; +6. requires exactly one non-expired versioned installer artifact; +7. emits only run/artifact identifiers and scheduling diagnostics. + +The metadata gate never downloads artifact bytes and has +`releasePromotionAuthority=false`. + +Only after the gate succeeds is the `windows-release` job allocated. + +## Authority remains on Windows + +The Windows release job still independently: + +- validates/materializes the sealed Build ARSAS package; +- revalidates source, engine, ArdIrec, run/attempt, TRX and every payload hash; +- validates/materializes the installed-smoke-tested installer; +- rechecks portable and installer SHA-256 values; +- runs portable smoke; +- runs release-specific silent install/uninstall and installed native runtime + verification; +- creates checksums and SPDX SBOM; +- writes provenance; +- produces attestations; +- enforces stable-release publication policy. + +Metadata readiness cannot substitute for any of those checks. + +## Tag behavior + +A tag release still requires package and installer evidence from an exact +`push/main` run of the same commit. A tag on a commit that never completed the +main packaging/installer path fails closed instead of rebuilding silently. + +## Manual recovery + +`workflow_dispatch` bypasses `release-ready` and retains the historical local +restore/build/test/publish/Inno fallback. This preserves an explicit engineering +recovery path without weakening production exact-byte promotion. + +## Artifact archive readiness hardening + +Because the release Windows runner now starts close to installer artifact +publication, `verify-ci-installer-reuse.py` treats only archive-download HTTP +404 as transient within its existing bounded deadline. + +- transient 404: retry; +- persistent 404: timeout and fail closed; +- any other HTTP failure: immediate hard failure. + +The package verifier already follows the same observed GitHub artifact-readiness +rule. + +## Static CI cost + +P3G adds one checkout and one Python setup action on `ubuntu-latest`. It adds +no restore, build or test invocation. + +This is an intentional scheduling trade: cheap metadata work is added to avoid +holding a Windows release runner while upstream immutable artifacts are still +being produced. + +## Rollback + +Remove the `release-ready` job and the `windows-release` dependency/condition. +Do not remove or weaken the package verifier, installer verifier, release smoke, +SBOM, provenance, attestations or publication policy. diff --git a/docs/audits/CI_P3H_SUPERSEDED_PR_CANCELLATION.md b/docs/audits/CI_P3H_SUPERSEDED_PR_CANCELLATION.md new file mode 100644 index 000000000..269f02c8a --- /dev/null +++ b/docs/audits/CI_P3H_SUPERSEDED_PR_CANCELLATION.md @@ -0,0 +1,83 @@ +# CI-P3H — Superseded PR cancellation + +Issue #464. + +## Goal + +Stop obsolete Windows CI for stale pull-request revisions without weakening +main, tag, manual, installer, package, or release authority. + +## Scheduling contract + +Ten Windows-heavy pull-request workflows use: + +- group: workflow name + pull-request number only for `pull_request`; +- fallback group identity: unique `github.run_id` for every non-PR event; +- `cancel-in-progress: true`. + +This means a synchronize event for the same PR cancels the older revision, +while push/main, tags, release production paths, and workflow_dispatch runs +cannot cancel one another because their group identity is unique per run. + +## Why this matters + +P2/P3 consumers bind to exact source, engine, run and artifact identity. Keeping +obsolete PR Windows runs alive wastes capacity and can later produce irrelevant +red statuses after a newer canonical Build ARSAS run becomes authoritative. +Cancellation makes the newest PR head the sole active revision while preserving +fail-closed exact-SHA verification. + +## Affected workflows + +- Build ARSAS; +- Windows installer validation; +- Smart Discovery Mainline Readiness; +- RCB export guard; +- SV evidence validation; +- IO testing validation; +- COMTRADE integration; +- Smart Discovery Golden Provenance; +- Smart Discovery Golden Budget; +- Smart Discovery Repeat-Run Stability. + +Existing workflows that already had an intentional concurrency contract remain +unchanged. + +## Authority boundary + +P3H changes scheduling only. It does not change: + +- canonical Build ARSAS regression/package evidence; +- sealed package or portable hashes; +- physical R10 authority; +- installer compile/install/runtime smoke; +- validated-installer authority; +- release checksums, SBOM, provenance, attestations, or publication; +- manual recovery paths. + +## Regression guard + +`scripts/test-ci-workflows.py` asserts the exact PR-only concurrency expressions +for all ten workflows and confirms the production release workflow does not gain +the PR cancellation policy. + +## Corrected concurrency shape + +An initial implementation used an event-scoped boolean for `cancel-in-progress`. +Real synchronize validation showed the newer runs waiting behind older runs instead +of cancelling them. P3H therefore uses the safer concurrency invariant: only PR +runs share a group; all other events use `github.run_id`; cancellation is always +true inside a group. This preserves the intended PR-only effect without relying +on a separate boolean expression. + +## Validation protocol + +The PR acceptance intentionally performs one synchronize event after the first +Windows-heavy run set starts. The previous head must become stale and the newest +head must become the sole active PR authority for P3H-managed workflows. + +## Rollback + +Remove the P3H concurrency blocks. `cancel-in-progress: true` is safe here only +because every non-PR event falls back to its unique `github.run_id`; never change +the group expression so main, tags, release, or manual recovery runs can collide. diff --git a/docs/audits/CI_P3I_MAIN_MANUAL_AUTHORITY.md b/docs/audits/CI_P3I_MAIN_MANUAL_AUTHORITY.md new file mode 100644 index 000000000..731d06574 --- /dev/null +++ b/docs/audits/CI_P3I_MAIN_MANUAL_AUTHORITY.md @@ -0,0 +1,25 @@ +# CI-P3I — Preserve main and manual verification evidence + +CI-P3H (PR #465) cancels superseded Windows-heavy pull request runs without cancelling `main` or manual runs. This follow-up closes the equivalent gap in inherited workflows which grouped by `github.ref` or used a shared `pull_request.number || github.ref` fallback. + +## Why + +- `smart-discovery-post-merge-production.yml` runs an independent R10 engine regression and consumes **exact main SHA** Build ARSAS evidence. A second push to `main` must not cancel the previous SHA's physical/provenance verification. +- `ci-p0-workflow-integrity.yml` audits workflow safety and budget. A new main push must not erase the previous commit's guard result. +- Five manual-capable interoperability and proof workflows previously shared a concurrency group by ref; two dispatches on the same branch could cancel one another. +- On `pull_request`, stale revisions of the **same PR** should still cancel to save resources. + +## Policy + +| Event | Concurrency identity | Cancellation | +| --- | --- | --- | +| Pull request | stable workflow-specific prefix + PR number | stale same-PR runs cancelled | +| Main push | stable workflow-specific prefix + unique `github.run_id` | no cross-run cancellation | +| Workflow dispatch | stable workflow-specific prefix + unique `github.run_id` | no cross-run cancellation | +| R10 post-merge push/dispatch | `smart-discovery-post-merge-production-${{ github.run_id }}` | explicitly disabled | + +The top-level group is isolated per workflow. These changes affect only scheduling, not job conditions, R10 evidence, exact engine/source SHA matching, reusable regression, installer validation, portable hashes or release publication. Website deployment concurrency is intentionally outside scope. + +## Validation and rollback + +`scripts/test-ci-workflows.py` now locks each affected workflow's group and cancellation behavior. CI-P0 inventory/budget and the full PR workflow suite must pass before merge. For production acceptance, confirm a manual dispatch does not cancel a running post-merge verification. Roll back by restoring the pre-P3I concurrency blocks, not by changing evidence or package verifiers. diff --git a/docs/audits/CI_P3I_SPECIALIZED_TEST_PROMOTION.md b/docs/audits/CI_P3I_SPECIALIZED_TEST_PROMOTION.md new file mode 100644 index 000000000..b37b4bc94 --- /dev/null +++ b/docs/audits/CI_P3I_SPECIALIZED_TEST_PROMOTION.md @@ -0,0 +1,71 @@ +# CI-P3I — Specialized canonical domain-test promotion + +Issue #467. + +## Decision + +SV Evidence and IO Testing remain distinct review checks, but pull requests no +longer compile and test the same ARSAS application a second time on Windows. +They consume the exact canonical Build ARSAS regression artifact instead. + +## Exact authority + +The shared canonical verifier continues to require: + +- exact PR head and branch; +- exact synthetic merge/source SHA; +- exact ARIEC61850 integration SHA; +- latest matching Build ARSAS run ID and attempt; +- safe, bounded, non-expired ARSAS-test-evidence artifact; +- exact canonical manifest and TRX SHA-256; +- globally all-pass TRX counters. + +P3I adds an optional required-test-family proof. Every requested substring must +match at least one UnitTestResult with outcome Passed. A missing family or any +matching non-Passed result fails closed. + +SV requires SmvSnapshotEvidenceExporterTests. + +IO requires IoTest, IoList, IoFat, FatDataSet and FatScl. + +The production main TRX at the P3I start point contained 6 SV evidence tests +and, respectively, 110 / 12 / 169 / 2 / 12 matching IO-family results. + +## Runner routing + +For pull_request, both existing workflow/job check names are preserved but the +job runs on ubuntu-latest and only performs exact identity + canonical TRX +verification. No .NET restore/build/test is executed in those PR lanes. + +For workflow_dispatch, the same workflows continue to run on windows-latest +and retain the focused restore/build/filter-test diagnostic path. + +## Artifact contract + +The existing ARSAS-sv-evidence-test-results and +ARSAS-io-testing-test-results artifact names remain stable. Their PR payload is +an inspectable canonical domain-proof JSON rather than a second TRX generated +by duplicate execution. Manual diagnostics publish the focused TRX and compiler +log together in the same domain evidence artifact. The redundant standalone +compiler-log artifact names are retired; repository search found no consumers. + +## Static topology + +No workflow file, checkout action, setup-python action, setup-dotnet action, +or static dotnet restore/build/test count is added. Manual fallback commands +stay in YAML intentionally. Consolidating each domain's proof/TRX/compiler log +into one evidence artifact reduces static upload-artifact occurrences from +35 to 33, and P0 freezes that lower ceiling. PR execution no longer allocates +a Windows runner. + +## Authority boundary + +This milestone changes only specialized PR verification execution. It does not +change canonical Build ARSAS, Windows package authority, installer authority, +release promotion, R10/P141 physical evidence, or IEC 61850 runtime behavior. + +## Rollback + +Restore pull_request execution to the focused Windows restore/build/test path. +Never remove exact canonical identity or required-domain-test checks merely to +make the specialized workflow green. diff --git a/docs/audits/CI_P3J_B_NATIVE_ARCHIVE_VERIFICATION.md b/docs/audits/CI_P3J_B_NATIVE_ARCHIVE_VERIFICATION.md new file mode 100644 index 000000000..79fa2b528 --- /dev/null +++ b/docs/audits/CI_P3J_B_NATIVE_ARCHIVE_VERIFICATION.md @@ -0,0 +1,34 @@ +# CI-P3J-B — Fail-closed verification of sealed native CTest evidence + +Issue: #470. Depends on merged CI-P3J-A PR #472 (canonical CTest JUnit + source/engine/ArdIrec/DLL identity proof). + +## Scope + +Verify the two evidence files staged inside the already sealed \`ARSAS-windows-package-input\` artifact: + +- \`verification/native/ctest.xml\` — executed Windows x64 Release native CTest inventory. +- \`verification/native/ci-native-ctest-authority.json\` — SHA-256 of JUnit and native DLL, source/engine/ArdIrec SHA, run ID/attempt/event, exact test names and no-skips/no-failures verdict. + +After existing archive-level path safety, file count, and per-file SHA-256 checks, the **independent consumer** recomputes the native proof and rejects contradictions. Both files must be present together. New \`--require-native-ctest-proof\` forces their presence for current PR installer and Field Capture package consumers. A missing pair remains readable in default mode solely for historical/manual compatibility. A present but invalid pair always fails, including default-mode release consumption. + +## Safety boundary + +- No app/engine/runtime changes, no removal of the independent Windows COMTRADE CMake/CTest gate. +- No change to the canonical native compiler flags or test set. +- No new workflow, artifact upload, expensive build or runner required; the verifier inspects bytes it already downloads and checks. +- Native evidence has no standalone release promotion authority. The existing exact canonical Build ARSAS artifact and successful run checks remain required. +- Release workflow's regular canonical path verifies native proof **when present**, without adding a new explicit required-presence policy to historical/manual release paths. +- Installer and Field Capture demand the new proof on PR/push canonical paths; manual fallback remains an independent native build. +- A verified CTest proof is **not yet proof that independent COMTRADE and canonical native builds produce identical bytes**. No deduplication authorized. + +## Fail-closed offline fixtures + +- Successful source/engine/ArdIrec/run/bridge/test-inventory agreement. +- Missing native pair rejected in strict mode; partial pair rejected in all modes. +- Rehashed/rewritten proof with wrong source/engine/ArdIrec/run/bridge/authority values rejected. +- Rehashed JUnit containing native failures rejected. +- Byte-level tampering already rejected by the package manifest digests. + +## Next: CI-P3J-C shadow parity + +Capture independently produced COMTRADE native CTest test inventory and native bridge digest on **exact same PR head**, compare to canonical (without conflating source head SHA with GitHub's synthetic merge SHA), measure runner occupancy, and retain independent Windows tests unless proven equivalent. A read-only lightweight proof transport is preferable to re-downloading the 160+ MB sealed Windows package for each comparison. diff --git a/docs/audits/CI_P3J_C_NATIVE_SHADOW_PARITY.md b/docs/audits/CI_P3J_C_NATIVE_SHADOW_PARITY.md new file mode 100644 index 000000000..3e4a0d2e7 --- /dev/null +++ b/docs/audits/CI_P3J_C_NATIVE_SHADOW_PARITY.md @@ -0,0 +1,22 @@ +# CI-P3J-C — Shadow independent ArdIrec native test/byte parity + +Issue #470. Follows merged PR #472 (native CTest evidence producer) and PR #474 (archive verifier, current installer/Field Capture required proof). + +## Intent +Do not assume running the same CMake command is equivalent to running the same native tests and producing the same DLL. Compare *both* the CTest case inventory and SHA-256 of the independently built native bridge against canonical proof **for one exact source/engine/ArdIrec identity**. A mismatch stays visible; the independent Windows COMTRADE lane is still a required gate. + +## Data flow +1. Canonical Build ARSAS still tests the exact native bridge and seals `verification/native/ctest.xml` plus `ci-native-ctest-authority.json` in the full package. For PRs only, it also uploads a **tiny two-file copy** as `ARSAS-native-ctest-shadow` after CTest, without waiting for package/installer completion. +2. COMTRADE's **existing independent Windows job** now runs CTest with optional JUnit output; CMake flags and exact locked native source stay unchanged. Its existing native-only source routing assertions execute before comparison. +3. The read-only shadow comparer discovers the latest Build ARSAS run for the exact PR head/branch, requires the exact PR synthetic merge SHA and ARIEC61850/ArdIrec SHA in its proof, validates the GitHub artifact ZIP metadata digest and a bounded two-file ZIP, independently checks the JUnit/proof contents, then compares test names and independently built DLL size/hash. +4. A canonical run still in progress is marked **provisional**, not completed-success. No binary reuse, independent-native retirement, installer, release, or field-physical authority follows from provisional or advisory parity. +5. Comparison runs on the **existing COMTRADE Windows job** without new jobs. Its outcome is `continue-on-error` only for the **new advisory comparison step**. Any core native CTest failure and existing native-only routing failure are still hard CI failures. +6. Diagnostics are recorded in the workflow job's step summary. A digest difference or unavailable canonical proof must never yield a "pass" parity claim. + +## CI budget and rollback +One additional tiny artifact upload is deliberately budgeted (`actionsUploadArtifact: 33 -> 34`), with no new workflow, Windows job, restore/build/test, checkout, or runtime dependency. It avoids a second transfer of the existing ~165 MB canonical Windows package on every COMTRADE comparison. The artifact is scoped to PRs with 14-day retention. + +Rollback removes the small canonical native-shadow upload and advisory COMTRADE comparison only. Preserve the independently passing native CMake/CTest, canonical package/installer identity, physical R10 checks and source pinning. + +## Promotion gate after observations +Do not delete the independent COMTRADE Windows build just because an isolated shadow comparison returns equivalent hashes. Inspect several **completed-success** exact-source independent comparisons and runtime/runner efficiency data, reject flaky/unavailable or mismatched cases, and get explicit review before replacing its regression authority. diff --git a/docs/audits/CI_P3J_D_UBUNTU_SHADOW_OBSERVER.md b/docs/audits/CI_P3J_D_UBUNTU_SHADOW_OBSERVER.md new file mode 100644 index 000000000..2c3b41f73 --- /dev/null +++ b/docs/audits/CI_P3J_D_UBUNTU_SHADOW_OBSERVER.md @@ -0,0 +1,33 @@ +# CI-P3J-D — Free Windows runner after independent COMTRADE CTest + +Issue: #470. Prerequisite: merged PR #475 proved a real same-PR native JUnit inventory and SHA-256 bridge parity while COMTRADE's independent Windows build and source guards stayed mandatory. + +## Observed cost (not modeled savings) + +GitHub Actions **run 37755975584** (October 8, 2026) on exact PR #475: +- Windows job started 09:21:44 UTC and completed 09:25:41 UTC (**237 seconds**). +- Native CMake configure + compile + CTest: 09:21:58–09:23:05 (**67 seconds**). +- The advisory wait/compare step: 09:23:05–09:25:37 (**152 seconds**), before the canonical build fully completed. +- Canonical **Build ARSAS** run 37755975609 ultimately passed; independent/native CTest inventory was **9/9 equal** and bridge SHA-256 was **fa8efcaf26e8ebb0a29808a2e52ad5c9f466ec2df146945d29c5d662987c41ce**. +- The 152-second wait monopolized a Windows runner only to poll and compare small read-only evidence. Optimization targets this wait **not the required independent native tests**. + +## Changes + +1. On PR, the required Windows COMTRADE job performs the same pinned CMake build, independent JUnit-emitting CTest, and native-only routing guard. It stages **the actual independently tested** `ctest.xml` plus `ardirec_bridge.dll`, then uploads one small artifact `ARSAS-independent-native-ctest-shadow`. On `workflow_dispatch`, it still independently compiles/tests and enforces routing; no shadow upload. +2. A new **Ubuntu-only observer job** depends on the required Windows job and runs only on PR events. It checks out the exact candidate, downloads those two independent native inputs from **its own workflow run**, verifies they are present, reads the locked engine and ArdIrec SHAs, and calls the unchanged read-only GitHub canonical shadow comparer (bounded 600 seconds). +3. Its only comparison step is marked `continue-on-error: true`; a mismatch or unavailable canonical proof is never treated as established parity and is called out in the job summary. Missing independent inputs are a **hard observer job failure**. The required Windows job never becomes advisory. +4. No new native compilation, no release/installer authority, no runtime/engine code, no changes to physical R10, and no permission to retire native CTest. + +## CI budget and multi-thread safety + +Explicit tracked topology delta: **one lightweight Ubuntu job**, one checkout, one small artifact upload (`actionsCheckout 36→37`, `actionsUploadArtifact 34→35`). The code does not add Windows jobs, workflow files, CMake calls, or .NET restore/build/test. Observer uses the same PR-specific concurrency group and is cancelled on superseding PR revisions; push/main and manual workflows retain unique-run groups. The GitHub API comparer requires exact PR head/branch, synthetic source merge SHA, engine/ArdIrec SHA, canonical run ID/attempt and tested native hashes. It cannot consume cross-PR or older branch evidence as matching current SHA. + +This is expected to release the Windows runner sooner, but **actual after-change timings must be measured**. Total elapsed PR wall-clock may remain dominated by canonical Build ARSAS; reducing Windows occupancy does not imply a faster overall result. + +## Rollback + +Reinstate the advisory comparison in the same Windows job, remove the Ubuntu observer and independent native shadow input upload, restore topology budget counts. Independent native Windows CMake/CTest and source guards must remain unchanged in either direction. + +## Later promotion gate + +Multiple completed-success native proof parity observations and explicit COMTRADE managed native test-family evidence are prerequisites before eliminating independent CMake/CTest on PR. The present milestone **does not** authorize that change. \ No newline at end of file diff --git a/docs/audits/CI_P3J_E_NATIVE_SHADOW_FINALITY.md b/docs/audits/CI_P3J_E_NATIVE_SHADOW_FINALITY.md new file mode 100644 index 000000000..2900cdee1 --- /dev/null +++ b/docs/audits/CI_P3J_E_NATIVE_SHADOW_FINALITY.md @@ -0,0 +1,27 @@ +# CI-P3J-E — Completed-canonical native parity finality gate + +Issue #470. Builds on **merged** #475 (real SHA-256 + native 9/9 CTest parity) and **merged** #477 (required COMTRADE native build/CTest and native-only route guard complete on Windows; advisory-only comparison runs on Ubuntu). + +## Problem proven in real GitHub Actions + +PR #477 run 37758793077 independently built/tested its COMTRADE DLL on Windows in 87 seconds (previous run 37755975584 held the Windows runner for 237 seconds). The Ubuntu observer subsequently compared the exact source/engine/ArdIrec and all nine CTest names plus the binary SHA-256 successfully, but the canonical Build ARSAS run 37758793021 was still **in_progress**. The verifier explicitly marked "provisional: true", "fullCanonicalBuildPassed: false", "deduplicationAuthorized: false". + +Although the canonical build ultimately passed, a provisional observation is not evidence that its complete package/integration tests passed. Nor does a green advisory workflow job imply that this was final parity. + +## Minimal fail-closed change + +- Reuse the existing \`scripts/compare-ci-native-shadow.py\` and read-only \`actions:read\` permission. Add opt-in \`--require-completed-canonical\` mode on the existing Ubuntu observer only. +- Keep the wait bounded to 600 seconds. Require **the latest exact PR-head, branch, synthetic merge-source SHA and locked engine/ArdIrec** Build ARSAS run to be **completed/success** before fetching and verifying its two-file tiny JUnit/proof artifact. +- Any failed/cancelled completed canonical run errors immediately; in-progress/queued runs cannot produce a final verdict; timeout/missing artifact and malformed/foreign proof fail closed. The advisory step continues to flag an inability to establish parity as a **visible warning**, not a promotion/pass claim. Native DLL mismatch still yields no parity. +- Preserve default/provisional verifier behavior for explicit earlier diagnostic consumers; the added finality contract is opt-in and tested, so no unrelated callers change meaning. +- New offline fixtures prove completed success, in-progress timeout, completed failure, missing artifact, and transition from in-progress to completed with no pre-completion artifact lookup. + +## Thread-safe scope and cost + +No new workflow or runner, no new Windows build, no new evidence transport, no changed engine pin, COMTRADE runtime code, release authority, R10 field baseline, or P7 branch. The **mandatory Windows CMake/CTest and native-only route guard** still run independently before any observer. No topology-budget limit changes. + +Wait time increases only on the existing lightweight Ubuntu observer when canonical build has not yet completed. A matching, completed canonical and independently passed native DLL/CTest can now be recorded as **final observation of parity for that one exact revision**. This does not itself authorize retiring the independent Windows native lane or reusing bytes in a release. + +## Rollback + +Remove the opt-in flag from the Ubuntu job to restore provisional early advisory comparison, or revert this PR entirely; do not change the mandatory native tests or sealed package verifiers. Review multiple independent completed-success parity observations and named managed COMTRADE domain tests before any future deduplication consideration. diff --git a/docs/audits/CI_P3J_NATIVE_CTEST_EVIDENCE.md b/docs/audits/CI_P3J_NATIVE_CTEST_EVIDENCE.md new file mode 100644 index 000000000..a457705e7 --- /dev/null +++ b/docs/audits/CI_P3J_NATIVE_CTEST_EVIDENCE.md @@ -0,0 +1,30 @@ +# CI-P3J-A — Canonical native ArdIrec CTest evidence + +Issue: #470. This is an **evidence instrumentation milestone**, not authorization to delete the independent COMTRADE native test workflow. + +## Baseline and discovery + +Both `Build ARSAS` and `Validate COMTRADE viewer integration` call the same locked `scripts/build-ardirec-bridge.ps1`, which configures Windows x64 Release ArdIrec with native-only / Qt desktop disabled, builds the bridge, then runs CTest. The canonical build also performs managed packaged-bridge/locus integration tests and seals a file-by-file hashed Windows package. Previously, the manifest's `nativeBridgeIntegrationPassed` boolean was not independent machine-readable proof of the individual native CTest cases. + +## CI-P3J-A change + +- Add an **optional** `-CTestJunitPath` parameter to the native build script. With the option set, CTest emits JUnit using the existing x64/Release build. Without the option, existing COMTRADE/manual paths keep their original invocation and test behavior. +- Require native test cases to be present, individually passing, non-skipped, nonduplicated and consistent with suite counters. Reject malformed/unbounded/DTD/entity XML. Do not infer native success from an exit code alone. +- Seal `ci-native-ctest-authority.json` against exact ARSAS, ARIEC61850 and ArdIrec SHA; canonical workflow run/attempt/event; CTest JUnit SHA-256; native bridge SHA-256/size and discovered case names. +- Stage `verification/native/ctest.xml` and `verification/native/ci-native-ctest-authority.json` in the existing immutable Windows package input. Its existing archive file list, sizes and SHA-256 seal binds their exact bytes without adding a workflow/artifact. +- Reverify staged native evidence before canonical package sealing. Add Python offline positive and fail-closed fixtures in the CI-P0 gate. + +## Non-goals and authority limits + +No changes to the ARSAS UI/runtime, ARIEC61850 source/lock, native CMake flags, CTest selection, field tests, COMTRADE independent Windows lane, installer/release promotion rules or existing package verifier schema. + +The new proof has `independentComtradeLaneReplaced=false` and `releasePromotionAuthority=false`. Do **not** treat these files as an independent signed attestation; their authenticity follows the exact successfully verified canonical package run/artifact and its file digests. + +## Follow-up: CI-P3J-B (separately gated) + +1. Confirm actual canonical JUnit test inventory and CTest names from Windows CI. +2. Add a read-only verifier that rejects missing CTest proof, wrong source/engine/ArdIrec/run ID, modified bridge, absent managed COMTRADE/bridge/locus domain tests and failed latest canonical runs. +3. Compare the independent COMTRADE lane against the canonical lane on the same SHA, including native CTest coverage, compiler flags and binary identity. +4. **Only after real parity is green**, consider running pull-request COMTRADE static routing checks on Ubuntu while retaining Windows CMake/CTest for manual diagnosis. Otherwise retain independent Windows CI permanently. + +Rollback: revert the optional CTest JUnit output and canonical verification staging. The native build script default CTest path and all existing release/physical authority remain intact. 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/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 221ce6ead..449ad8e69 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "8b0c0f7e695b016834e40a6f068994dcc945cb1a", - "sourcePullRequest": 157, - "purpose": "EXPERIMENTAL DISCOVERY DATASET TYPE CLOSURE, code qualification pending: exact engine PR #157, extends immutable P6.2 baseline without touching canonical command/RCB or cyclic polling invariants. Physical F650 parity required before promotion.", + "commit": "a43b9b4864a9565c02f544d4135fe157c2fac577", + "sourcePullRequest": 168, + "purpose": "P10.9K CodeVerified-only: vendor-neutral control CDC fail-closed semantics with live ctlVal/status evidence plus association-scoped NameList single-flight reuse; ARIEC PR #168 CI #38231327892. Retains P10.9J exact OID/BSC interoperability, P10.9I FIFO protection, physical R7/R10 baseline and no-automatic-dynamic reporting. Physical ZIV control still NOT accepted.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, @@ -83,5 +83,83 @@ "fullNameListFallbackForbidden": true, "cyclicMmsProcessPollingForbidden": true, "physicalQualificationRequired": true + }, + "zivSclBscInteroperabilityTrial": { + "status": "code-verified-not-physical", + "issue": 538, + "enginePullRequest": 167, + "baseEngineCommit": "9bd01908ebd6fdf50a74f0a421b4e79613b878d4", + "exactCommit": "67c6635b7e16139c0661e559a2e6946527c1442a", + "engineCiRun": 38219429896, + "sclApTitlePackedPrefixOnly": true, + "bscLiveSemanticGateRequired": true, + "physicalQualificationRequired": true, + "mainlinePromotionForbidden": true, + "fullDiscoveryFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true + }, + "canonicalReportIngressIntegrityTrial": { + "status": "code-verified-not-physical", + "issue": 536, + "enginePullRequest": 166, + "baseEngineCommit": "d700b0df741b466afb8abe6cee1d70b1619d7b4d", + "exactCommit": "9bd01908ebd6fdf50a74f0a421b4e79613b878d4", + "engineCiRun": 38214165576, + "physicalQualificationRequired": true, + "mainlinePromotionForbidden": true, + "fullDiscoveryFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true + }, + "reportSubscriberIntegrityTrial": { + "status": "code-verified-not-physical", + "issue": 532, + "enginePullRequest": 165, + "baseEngineCommit": "fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7", + "exactCommit": "d700b0df741b466afb8abe6cee1d70b1619d7b4d", + "engineCiRun": 38180732078, + "physicalQualificationRequired": true, + "mainlinePromotionForbidden": true, + "fullDiscoveryFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true + }, + "coreReportRoutingTrial": { + "status": "code-verified-not-physical", + "issue": 531, + "simulatorPullRequest": 163, + "corePullRequest": 164, + "baseEngineCommit": "00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1", + "simulatorFifoCommit": "ef0fe44f3b40e47dc18b1aac4476b635e67d531e", + "exactCommit": "fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7", + "simulatorCiRun": 38141582981, + "coreCiRun": 38142115574, + "physicalQualificationRequired": true, + "mainlinePromotionForbidden": true, + "fullDiscoveryFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true + }, + "p10IdentityTrial": { + "status": "code-trial-not-physical", + "enginePullRequest": 161, + "baseEngineCommit": "8b0c0f7e695b016834e40a6f068994dcc945cb1a", + "exactCommit": "00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1", + "physicalQualificationRequired": true, + "fullNameListFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true, + "identitySource": "TrustedSclExactDomainMatch" + }, + "genericControlInteroperabilityTrial": { + "status": "code-verified-not-physical", + "issue": 540, + "enginePullRequest": 168, + "baseEngineCommit": "67c6635b7e16139c0661e559a2e6946527c1442a", + "exactCommit": "a43b9b4864a9565c02f544d4135fe157c2fac577", + "engineCiRun": 38231327892, + "typedSemanticGateRequired": true, + "associationScopedInventoryRequired": true, + "vendorDispatchForbidden": true, + "physicalQualificationRequired": true, + "mainlinePromotionForbidden": true, + "fullDiscoveryFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true } } diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json new file mode 100644 index 000000000..eb1522cd3 --- /dev/null +++ b/evidence/ci-workflow-budget.json @@ -0,0 +1,737 @@ +{ + "schemaVersion": 1, + "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", + "baselineName": "CI-P3I: canonical domain-test proof replaces duplicate specialized PR compilation", + "policy": "P0 historical workflow-sprawl baseline remains immutable. P3I preserves static topology and manual focused diagnostics, but SV Evidence and IO Testing PR runs consume exact canonical Build ARSAS TRX proof for their required test families on ubuntu-latest instead of compiling/testing the same application again on windows-latest.", + "preP0Observed": { + "workflowFiles": 28, + "actionsCheckout": 31, + "actionsSetupDotnet": 12, + "actionsSetupPython": 13, + "dotnetRestore": 15, + "dotnetBuild": 15, + "dotnetTest": 17, + "actionsUploadArtifact": 33, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11, + "workflowCall": 0 + }, + "limits": { + "workflowFiles": 31, + "actionsCheckout": 37, + "actionsSetupDotnet": 11, + "actionsSetupPython": 19, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 35, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "interpretation": [ + "The limits freeze CI sprawl; they are not performance targets and do not prove semantic necessity.", + "CI-P1 is expected to reduce restore/build/test counts and introduce reusable workflow_call contracts. Any temporary migration increase must be explicit in the same reviewed change.", + "Release, physical evidence and IEC 61850 acceptance semantics remain owned by their existing contracts during CI-P0." + ], + "ciP1MigrationShadow": { + "issueNumber": 428, + "purpose": "Two additive workflows: one advisory change classifier and one reusable exact-SHA Windows compile/test job for opt-in shadow parity. No legacy workflow retired or replaced.", + "originalP0Limits": { + "workflowFiles": 29, + "actionsCheckout": 32, + "actionsSetupDotnet": 12, + "actionsSetupPython": 13, + "dotnetRestore": 15, + "dotnetBuild": 15, + "dotnetTest": 17, + "actionsUploadArtifact": 33, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11 + }, + "temporaryIncrement": { + "workflowFiles": 2, + "actionsCheckout": 3, + "actionsSetupDotnet": 1, + "actionsSetupPython": 0, + "dotnetRestore": 1, + "dotnetBuild": 1, + "dotnetTest": 1, + "actionsUploadArtifact": 1, + "workflowsWithDotnetBuild": 1, + "workflowsWithDotnetTest": 1 + }, + "exitGate": "Only retire a legacy workflow or lower limits after shadow parity on matching source/engine SHAs, equivalent test verdicts, and explicit review of physical/release gate ownership. No release/promotion claim based on shadow results." + }, + "ciP2Consolidation": { + "issueNumber": 430, + "lane": "merge-execution-guard", + "approach": "Reuse completed canonical Build ARSAS exact-merge-SHA full-regression TRX artifact; no duplicate .NET compile for PR events. Keep original source-contract check and status name, read-only API, manual fallback via reusable full build/test.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 13, + "actionsSetupPython": 13, + "dotnetRestore": 16, + "dotnetBuild": 16, + "dotnetTest": 18, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 13, + "workflowsWithDotnetTest": 12 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 12, + "actionsSetupPython": 13, + "dotnetRestore": 15, + "dotnetBuild": 15, + "dotnetTest": 17, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11 + }, + "canonicalEvidenceArtifact": "ARSAS-test-evidence", + "candidateVerification": { + "source": "exact PR synthetic merge SHA", + "engine": "exact immutable engine lock SHA", + "canonicalRun": "successful latest matching PR head/branch run", + "tests": "Workflow-produced, not cryptographically signed, exact-SHA manifest plus independently verified all-pass TRX SHA-256 and counters.", + "noProductionPromotion": true + }, + "rollback": "Restore original smart-discovery-merge-execution-guard.yml build/test steps if canonical evidence or cross-run matching causes flakiness; never skip P0-5h source-contract checks." + }, + "ciP2MainlineReadiness": { + "issueNumber": 432, + "lane": "smart-discovery-mainline-readiness", + "approach": "After preserving R10 physical authority and running the engine's independent full restore/build/test, reuse immutable successful canonical Build ARSAS exact PR merge/engine SHA artifact instead of a second application restore/build/test. No cross-run proof bypass.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 12, + "actionsSetupPython": 13, + "dotnetRestore": 15, + "dotnetBuild": 15, + "dotnetTest": 17, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 12, + "actionsSetupPython": 14, + "dotnetRestore": 14, + "dotnetBuild": 14, + "dotnetTest": 16, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11 + }, + "existingR10EngineFullRegressionRetained": true, + "existingPhysicalAuthorityChecksRetained": true, + "exactCanonicalProofRequiredOnPullRequest": true, + "manualExecutionRunsReusableApplicationFullTests": true, + "actionsPermissionScope": "read-only contents/actions", + "rollback": "Restore direct application restore/build/test step if verifier fails legitimate parity; retain current R10 evidence and engine full tests without modification." + }, + "ciP2ProductionRoute": { + "issueNumber": 436, + "lane": "smart-discovery-production-promotion", + "approach": "For PRs independently verify source route and its unchanged SHA256 before/after consuming exact-PR-merge + exact-engine-commit full-regression canonical Build ARSAS proof. Preserve manual full regression via reusable Windows workflow.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 12, + "actionsSetupPython": 14, + "dotnetRestore": 14, + "dotnetBuild": 14, + "dotnetTest": 16, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 12, + "workflowsWithDotnetTest": 11 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 15, + "dotnetRestore": 13, + "dotnetBuild": 13, + "dotnetTest": 15, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 11, + "workflowsWithDotnetTest": 10 + }, + "evidence": "Latest successful canonical Build ARSAS matching exact PR head/branch, merge SHA, immutable engine SHA, passing all-test TRX digest/counters", + "sourceRouteChecksIndependent": true, + "sourceSha256BeforeAfter": true, + "noPhysicalAuthorityRewrite": true, + "noPromotionActions": true, + "rollback": "Revert this workflow change to restore direct production-route .NET build/test if canonical proof or sequencing fails. Never omit source route checks." + }, + "ciP2ArtifactReady": { + "issueNumber": 438, + "lane": "canonical-regression-consumers", + "observedReferenceRun": { + "buildRunId": 37567436447, + "regressionEvidenceUploadedAt": "2026-10-07T03:37:44Z", + "canonicalWorkflowCompletedAt": "2026-10-07T03:39:45Z", + "observedPostRegressionPackagingWindowSeconds": 121, + "consumers": 3, + "note": "Observed timing is diagnostic evidence only, not a hard CI timing assertion." + }, + "verifierMode": { + "default": "completed-workflow-only", + "optInFlag": "--allow-in-progress-artifact", + "acceptsStatuses": [ + "in_progress", + "completed-success" + ], + "completedFailureRejected": true, + "exactPrHeadAndBranchRequired": true, + "exactSyntheticMergeShaRequired": true, + "exactEngineShaRequired": true, + "exactRunIdAttemptRequired": true, + "trxDigestAndAllPassCountersRequired": true, + "missingArtifactWaitsBoundedly": true + }, + "authorityBoundary": { + "proves": "full ARSAS regression artifact", + "doesNotProve": [ + "portable publish", + "portable smoke", + "release promotion" + ], + "canonicalBuildRemainsIndependent": true + }, + "staticLimitsChanged": false, + "rollback": "Remove the opt-in flag from P2A/P2B/P2C consumers to restore completed-workflow waiting without changing evidence formats or physical/release authority." + }, + "ciP2PostMergeMainReuse": { + "issueNumber": 440, + "lane": "smart-discovery-post-merge-production", + "approach": "On push/main, preserve R10 physical assertions and independent engine restore/build/test, then consume exact same-main-SHA canonical Build ARSAS push artifact using event=push, branch=main, exact engine SHA and artifact-ready proof.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 15, + "dotnetRestore": 13, + "dotnetBuild": 13, + "dotnetTest": 15, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 11, + "workflowsWithDotnetTest": 10 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 16, + "dotnetRestore": 12, + "dotnetBuild": 12, + "dotnetTest": 14, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 11, + "workflowsWithDotnetTest": 10 + }, + "independentEngineFullRegressionRetained": true, + "exactMainSourceShaRequired": true, + "exactEngineShaRequired": true, + "canonicalEvent": "push", + "canonicalBranch": "main", + "canonicalBuildRemainsPackagingAuthority": true, + "attestationSchemaVersion": 1, + "diagnosticAttestationMarksIncompleteFailures": true, + "noRuntimeOrPhysicalEvidenceRewrite": true, + "rollback": "Restore direct ARSAS main restore/build/test in post-merge workflow; keep independent engine test and physical convergence assertions unchanged." + }, + "ciP2FieldCaptureRegressionReuse": { + "issueNumber": 442, + "lane": "smart-discovery-capture-build", + "approach": "Preserve exact Field Capture source checks, P0-5d fixtures, integration engine checkout, independent publish and smoke; replace duplicate application restore/build/full-test with exact canonical Build ARSAS regression evidence. Manual dispatch retains full regression through reusable CI-P1 before packaging.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 16, + "dotnetRestore": 12, + "dotnetBuild": 12, + "dotnetTest": 14, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 11, + "workflowsWithDotnetTest": 10 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 17, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 13, + "actionsUploadArtifact": 34, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "binaryReuseForbidden": true, + "binaryParityEvidence": { + "sourceHead": "83286557f3a485f2e589ed4742f2d8f9a3d9b8ef", + "engineCommit": "648124097621046f5f127ceb1cf853fea54db730", + "canonicalExeBytes": 78397596, + "canonicalExeSha256": "49ff985f9ab969483836810dc15895da5ea0b9b046cfb57fdb96b8897269e774", + "fieldCaptureExeBytes": 78397601, + "fieldCaptureExeSha256": "ef80317a8665b7fa900473650864f8d7fb7f9250229880c11b5f4a558aaab40e", + "byteIdentical": false + }, + "physicalBaselineCommitPreserved": "4467124775d8d9d76f3db194f9fbfd97144767a8", + "integrationEngineStillCheckedOutForPackaging": true, + "independentPublishAndSmokeRetained": true, + "canonicalBuildRemainsRegressionAuthorityOnPullRequest": true, + "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", + "approach": "Request reproducible MSVC bridge linking, explicit deterministic .NET publish, and emit source/engine/ArdIrec/bridge/portable hash identity from both existing independent builders. No binary promotion until cross-run parity is proven.", + "staticLimitsChanged": false, + "nativeLinkerFlag": "/Brepro", + "managedDeterminism": [ + "Deterministic=true", + "ContinuousIntegrationBuild=true" + ], + "crossRunBuilders": [ + "Build ARSAS", + "Smart Discovery Field Capture Build" + ], + "binaryPromotionAllowed": true, + "acceptance": "Matching source, ARIEC61850 and ArdIrec inputs must yield identical bridge and portable SHA-256 across independent workflow runs.", + "rollback": "Remove P3A deterministic flags and identity artifact additions only if toolchain incompatibility is proven; retain P2F binary-reuse prohibition.", + "acceptedParity": { + "syntheticMergeSha": "3794b29fa71695ec4ea099469ae2e55054d3d3f8", + "engineCommit": "648124097621046f5f127ceb1cf853fea54db730", + "ardIrecCommit": "7dbc7149db668126a493ce338264363a6ec5ec00", + "bridgeSha256": "fa8efcaf26e8ebb0a29808a2e52ad5c9f466ec2df146945d29c5d662987c41ce", + "portableSha256": "9fa7fac00c96acb24c98e64ff91ca9ee1709d9f1b89bf55030284ee3da4fc369", + "portableSizeBytes": 78789758, + "independentBuilders": 2 + } + }, + "ciP3CanonicalPortablePromotion": { + "issueNumber": 449, + "lane": "smart-discovery-capture-build", + "approach": "On pull_request consume the exact hash-complete ARSAS-windows-package-input already produced by Build ARSAS. The shared sealed-package verifier validates exact PR head/branch, synthetic merge/source SHA, ARIEC61850 SHA, ArdIrec SHA, workflow run/attempt, all-pass TRX counters, portable/native hashes and the sealed portable build-identity before materialization. Field Capture retains P0-5c/P0-5d/physical checks, then smoke-tests and republishes those exact canonical portable bytes under the stable field artifact contract. workflow_dispatch retains independent publish.", + "staticLimitsChanged": false, + "canonicalPromotionSubstrate": "ARSAS-windows-package-input", + "separatePortableDownloadMechanism": false, + "sealedPortableIdentityRequired": true, + "transientArchive404UsesSharedBoundedRetry": true, + "prIndependentDotnetPublishRemoved": true, + "fieldSourceAndWireChecksRetained": true, + "fieldPortableSmokeRetained": true, + "manualIndependentPublishRetained": true, + "artifactNameStable": "ARSAS-smart-discovery-pr134-win-x64", + "noInstallerOrReleaseAuthorityChange": true, + "rollback": "Restore PR Field Capture independent publish path if sealed package promotion becomes unavailable; never bypass exact package identity, digest, build-identity or runtime-smoke validation." + }, + "ciP3ReleaseExactPackagePromotion": { + "issueNumber": 455, + "lane": "release-windows", + "approach": "For push/main release requests and tags, resolve the reviewed release identity then require the exact successful Build ARSAS push/main sealed package for the same source, ARIEC61850 and ArdIrec SHAs. Materialize only after full hash/TRX/build-identity verification; promote exact portable and installer-input bytes into release-owned installer/smoke/SBOM/provenance/publication stages.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 18, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 35, + "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 + }, + "staticLimitsChanged": false, + "staticBudgetNote": "workflow_dispatch intentionally retains restore/build/test/publish source commands in release-windows.yml, so static topology counts do not fall even though production push/tag execution removes those rebuilds.", + "productionEvents": [ + "push-main-release-request", + "push-tag" + ], + "canonicalBuildLookup": { + "event": "push", + "branch": "main", + "exactHeadSha": true + }, + "productionApplicationRebuild": false, + "tagWithoutCanonicalMainPackageFailsClosed": true, + "manualLocalRebuildFallbackRetained": true, + "releaseFixturePreserved": "p1-release-smoke.cfg", + "releaseFixtureAndDatSealedAndHashed": true, + "exactPortableBytesPromoted": true, + "exactInstallerInputBytesPromoted": true, + "releaseNativeBridgePrePackageTestRetained": true, + "installedNativeBridgeTestAdded": true, + "installerCompileRetained": true, + "silentInstallUninstallRetained": true, + "checksumSbomProvenanceAttestationRetained": true, + "stableReleaseImmutabilityRetained": true, + "provenanceAdds": [ + "canonicalPackageAuthority", + "canonicalPackageRun", + "canonicalPackageArtifactSha256", + "canonicalPortableSha256" + ], + "rollback": "Restore non-manual release restore/build/test/publish steps and stop requiring the sealed package. Never weaken exact source/engine/ArdIrec identity, package file hashes, canonical TRX, portable identity or stable-release immutability to make promotion pass." + }, + "ciP3ValidatedInstallerPromotion": { + "issueNumber": 457, + "lane": "installer-windows-to-release-windows", + "approach": "Installer validation compiles once from the exact sealed Build ARSAS package, performs silent install/uninstall plus installed native tests, then seals installer SHA/size and exact source/engine/ArdIrec/canonical-package provenance. Production release waits for the matching successful push/main installer validation, verifies that authority, promotes the exact installer bytes and keeps release-specific installed runtime smoke before checksums/SBOM/provenance/attestation/publication.", + "staticLimitsChanged": false, + "installerArtifactName": "ARSAS--win-x64-installer", + "installerManifest": "ci-installer-authority.json", + "exactIdentity": [ + "source SHA", + "ARIEC61850 SHA", + "ArdIrec SHA", + "canonical package artifact SHA-256", + "installer workflow run/attempt", + "installer SHA-256/size" + ], + "installerValidationReleasePromotionAuthority": false, + "productionInnoCompileRemoved": true, + "releaseSpecificSilentInstallUninstallRetained": true, + "releaseSpecificInstalledNativeTestRetained": true, + "manualInnoCompileFallbackRetained": true, + "tagRequiresExactMainInstaller": true, + "provenanceAdds": [ + "validatedInstallerAuthority", + "validatedInstallerArtifactSha256", + "validatedInstallerSha256" + ], + "rollback": "Restore production Inno install/compile in release-windows.yml and stop consuming validated installer authority. Never weaken exact source/package/installer identity or stable-release immutability." + }, + "ciP3InstallerPackageReadyGate": { + "issueNumber": 460, + "lane": "installer-windows", + "approach": "For PR/push events, wait on ubuntu-latest for the latest exact Build ARSAS head/branch/event run to expose exactly one non-expired ARSAS-windows-package-input artifact. Only then allocate the Windows installer job. Metadata gate never downloads package bytes or claims package/installer authority; Windows verifier remains the fail-closed authority.", + "observedBaseline": { + "mainCommit": "0814793c7473dbae1c1c64da56df12cbeecf5df7", + "buildRunId": 37695338673, + "installerRunId": 37695338631, + "installerStartedAt": "2026-10-07T22:18:39Z", + "packageWaitStartedAt": "2026-10-07T22:19:01Z", + "packageArtifactCreatedAt": "2026-10-07T22:24:11Z", + "packageReuseCompletedAt": "2026-10-07T22:24:27Z", + "observedWindowsPackageWaitSeconds": 326, + "installerSpecificWorkAfterPackageSeconds": 152 + }, + "staticLimitsChanged": false, + "addedWorkflowFiles": 0, + "addedCheckoutActions": 0, + "addedSetupPythonActions": 0, + "metadataGateRunner": "ubuntu-latest", + "windowsRunnerDeferred": true, + "latestCompletedCanonicalFailureRejected": true, + "duplicateCanonicalPackageMetadataRejected": true, + "packageBytesTrustedByGate": false, + "fullPackageVerifierRetained": true, + "installerCompileRetained": true, + "silentInstallUninstallRetained": true, + "installedNativeRuntimeTestsRetained": true, + "manualWorkflowDispatchBypassesGate": true, + "releaseAuthorityChanged": false, + "rollback": "Remove package-ready job and installer needs/if dependency to restore direct Windows polling. Never remove the full sealed-package verifier or installer/runtime smoke to reduce wait time." + }, + "ciP3ReleaseReadyGate": { + "issueNumber": 462, + "lane": "release-windows", + "approach": "For non-manual production release events, checkout the exact release source on ubuntu-latest, resolve version/ARIEC61850/ArdIrec identity, and wait for exact Build ARSAS package metadata plus exact completed-success validated installer metadata before allocating windows-latest. Windows release still revalidates and materializes both artifacts byte-for-byte.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 35, + "actionsSetupDotnet": 11, + "actionsSetupPython": 18, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 35, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 36, + "actionsSetupDotnet": 11, + "actionsSetupPython": 19, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 35, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "staticLimitsChanged": true, + "addedWorkflowFiles": 0, + "addedCheckoutActions": 1, + "addedSetupPythonActions": 1, + "addedDotnetRestoreBuildTest": 0, + "metadataGateRunner": "ubuntu-latest", + "boundedWaitSeconds": 2700, + "canonicalPackageMetadataRequired": true, + "completedSuccessfulInstallerMetadataRequired": true, + "exactSourceShaRequired": true, + "exactMainPushDependencyRunsRequired": true, + "gateResolvesVersionEngineAndArdIrecIdentity": true, + "packageBytesTrustedByGate": false, + "installerBytesTrustedByGate": false, + "releasePromotionAuthority": false, + "fullCanonicalPackageVerifierRetained": true, + "fullValidatedInstallerVerifierRetained": true, + "releasePortableSmokeRetained": true, + "releaseInstalledRuntimeSmokeRetained": true, + "checksumsSbomProvenanceAttestationsRetained": true, + "manualWorkflowDispatchBypassesGate": true, + "installerArchiveTransient404Retry": { + "status": 404, + "boundedByExistingVerifierDeadline": true, + "otherHttpErrorsHardFail": true, + "persistent404TimesOut": true + }, + "rollback": "Remove release-ready job and windows-release needs/if dependency to restore direct Windows polling. Keep exact package and installer verifiers, runtime smoke and release authority unchanged." + }, + "ciP3SupersededPrCancellation": { + "issueNumber": 464, + "lane": "windows-heavy-pull-request-workflows", + "approach": "Share one concurrency group only for the same pull request; all non-PR events fall back to unique github.run_id. cancel-in-progress is unconditional inside the group, so only stale PR revisions can collide and be cancelled.", + "staticLimitsChanged": false, + "affectedWorkflows": [ + "build.yml", + "installer-windows.yml", + "smart-discovery-mainline-readiness.yml", + "rcb-export-guard.yml", + "validate-sv-evidence.yml", + "validate-io-testing.yml", + "comtrade-viewer-integration.yml", + "smart-discovery-golden-provenance.yml", + "smart-discovery-golden-budget-lock.yml", + "smart-discovery-repeat-run-stability.yml" + ], + "groupExpression": "${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}", + "cancelExpression": "true", + "pullRequestSupersession": true, + "pushMainCancellation": false, + "tagCancellation": false, + "manualCancellation": false, + "releaseAuthorityChanged": false, + "canonicalPackageAuthorityChanged": false, + "installerAuthorityChanged": false, + "rollback": "Remove the P3H concurrency blocks from affected workflows. Never broaden cancel-in-progress to main/tag/manual runs.", + "observedFirstAttempt": "Event-scoped cancel expression left newer runs pending behind older runs; corrected to unique non-PR grouping plus unconditional cancellation." + }, + "ciP3SpecializedTestPromotion": { + "issueNumber": 467, + "lane": "validate-sv-evidence + validate-io-testing", + "approach": "On pull_request, preserve workflow/job check names but run on ubuntu-latest, resolve exact engine lock, and require exact canonical Build ARSAS all-pass TRX plus explicit domain test-family presence. workflow_dispatch retains existing focused Windows restore/build/test diagnostics.", + "staticLimitsChanged": true, + "addedWorkflowFiles": 0, + "addedCheckoutActions": 0, + "addedSetupPythonActions": 0, + "addedSetupDotnetActions": 0, + "addedUploadArtifactActions": -2, + "prWindowsRunnerRequired": false, + "canonicalIdentity": [ + "PR head/branch", + "synthetic merge/source SHA", + "ARIEC61850 SHA", + "Build ARSAS run ID/attempt", + "canonical TRX SHA-256 and all-pass counters" + ], + "requiredDomainTests": { + "sv": [ + "SmvSnapshotEvidenceExporterTests" + ], + "io": [ + "IoTest", + "IoList", + "IoFat", + "FatDataSet", + "FatScl" + ] + }, + "missingDomainTestFailsClosed": true, + "nonPassingDomainMatchFailsClosed": true, + "manualFocusedWindowsDiagnosticsRetained": true, + "releaseAuthorityChanged": false, + "packageAuthorityChanged": false, + "physicalAuthorityChanged": false, + "rollback": "Restore pull_request execution to the focused Windows restore/build/test path. Never waive exact canonical identity or required domain-test assertions to make a specialized check pass.", + "limitsBefore": { + "workflowFiles": 31, + "actionsCheckout": 36, + "actionsSetupDotnet": 11, + "actionsSetupPython": 19, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 35, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "limitsAfter": { + "workflowFiles": 31, + "actionsCheckout": 36, + "actionsSetupDotnet": 11, + "actionsSetupPython": 19, + "dotnetRestore": 11, + "dotnetBuild": 11, + "dotnetTest": 12, + "actionsUploadArtifact": 33, + "workflowsWithDotnetBuild": 10, + "workflowsWithDotnetTest": 9 + }, + "manualCompilerLogFoldedIntoDomainEvidenceArtifact": true, + "artifactNamesRetained": [ + "ARSAS-sv-evidence-test-results", + "ARSAS-io-testing-test-results" + ], + "retiredRedundantManualArtifactNames": [ + "ARSAS-sv-evidence-compiler-log", + "ARSAS-io-testing-compiler-log" + ] + }, + "ciP3JNativeShadowParity": { + "issueNumber": 470, + "lane": "canonical Build ARSAS / independent COMTRADE Windows", + "approach": "Record two small, SHA-bound CTest files after canonical native build, compare exact PR synthetic merge SHA, PR head/branch, ARIEC61850/ArdIrec pin, test inventory and independently built native bridge SHA-256; keep independent COMTRADE CMake/CTest and routing guards mandatory.", + "staticLimitsChanged": true, + "addedWorkflowFiles": 0, + "addedCheckoutActions": 0, + "addedUploadArtifactActions": 1, + "artifactName": "ARSAS-native-ctest-shadow", + "artifactContents": [ + "ctest.xml", + "ci-native-ctest-authority.json" + ], + "artifactRetentionDays": 14, + "boundedShadowWaitSeconds": 600, + "crossRunRouting": [ + "exact PR head SHA", + "exact PR branch", + "exact synthetic merge source SHA", + "engine lock SHA", + "ArdIrec lock SHA", + "canonical run ID/attempt" + ], + "comparisonStatus": "advisory", + "provisionalEvidenceDoesNotImplyCompletedCanonicalBuild": true, + "nativeBridgeIdentityDifferenceIsVisibleNotWaived": true, + "independentComtradeWindowsCTestRetained": true, + "sourceRoutingContractRetained": true, + "releaseAuthorityChanged": false, + "physicalAuthorityChanged": false, + "binaryReuseAllowed": false, + "rollback": "Remove the tiny shadow upload and advisory comparison only. Keep canonical package native CTest proof, independent Windows CMake/CTest and all release/physical authority intact." + }, + "ciP3JNativeShadowObserver": { + "issueNumber": 470, + "lane": "independent COMTRADE native Windows and read-only Ubuntu observer", + "approach": "Keep independent pinned Windows CMake/CTest and native-only route gate mandatory; stage its actual DLL/JUnit only on PR, then move the existing wait-for-canonical-native-shadow and hash/test-inventory comparison to lightweight Ubuntu after Windows job succeeds.", + "measurement": { + "referenceRun": "37755975584", + "nativeBuildTestSeconds": 67, + "shadowComparisonWaitSeconds": 152, + "independentWindowsJobSeconds": 237, + "observedUtc": "2026-10-08T09:21:44Z", + "basis": "GitHub Actions jobs/steps recorded timestamps; a baseline, not a performance guarantee." + }, + "staticBudgetChanged": true, + "addedWorkflowFiles": 0, + "addedWindowsJobs": 0, + "addedUbuntuJobs": 1, + "addedCheckoutActions": 1, + "addedUploadArtifactActions": 1, + "artifactName": "ARSAS-independent-native-ctest-shadow", + "artifactFiles": [ + "ctest.xml", + "ardirec_bridge.dll" + ], + "artifactRetentionDays": 14, + "manualWindowsCTestRetained": true, + "independentNativeRouteGuardRetained": true, + "comparisonAdvisory": true, + "releaseAuthorityChanged": false, + "physicalAuthorityChanged": false, + "nativeDeduplicationAuthorized": false, + "rollback": "Remove Ubuntu observer and independent evidence upload, restore existing Windows advisory comparison; never remove mandatory Windows CMake/CTest or native-only route checks." + } +} diff --git a/scripts/audit-ci-workflows.py b/scripts/audit-ci-workflows.py new file mode 100644 index 000000000..5ca972771 --- /dev/null +++ b/scripts/audit-ci-workflows.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Deterministic, read-only inventory and integrity guard for GitHub Actions workflows. + +CI-P0 deliberately avoids a YAML dependency. It inspects the stable structural subset +used by this repository: tracked workflow files, top-level workflow keys, job IDs, +step names and expensive CI primitives. GitHub remains the YAML parser of record. + +The guard does not decide whether an existing workflow is semantically necessary. +It prevents accidental workflow duplication/corruption and makes CI-cost growth +explicit through a reviewed budget file. +""" +from __future__ import annotations + +import argparse +import collections +import json +import re +import subprocess +from pathlib import Path +from typing import Iterable + +WORKFLOW_PREFIX = ".github/workflows/" +WORKFLOW_SUFFIXES = (".yml", ".yaml") + +PRIMITIVES = { + "actionsCheckout": "actions/checkout@", + "actionsSetupDotnet": "actions/setup-dotnet@", + "actionsSetupPython": "actions/setup-python@", + "actionsUploadArtifact": "actions/upload-artifact@", + "actionsDownloadArtifact": "actions/download-artifact@", + "dotnetRestore": "dotnet restore", + "dotnetBuild": "dotnet build", + "dotnetTest": "dotnet test", + "dotnetPublish": "dotnet publish", + "workflowCall": "workflow_call:", + "concurrencyBlocks": "concurrency:", +} + + +def git(root: Path, *args: str) -> bytes: + return subprocess.check_output( + ("git", "-C", str(root), *args), + stderr=subprocess.PIPE, + ) + + +def tracked_paths(root: Path) -> list[str]: + return sorted( + { + item.decode("utf-8", "surrogateescape").replace("\\", "/") + for item in git(root, "ls-files", "-z").split(b"\0") + if item + } + ) + + +def workflow_paths(root: Path) -> list[str]: + return [ + path + for path in tracked_paths(root) + if path.startswith(WORKFLOW_PREFIX) + and Path(path).suffix.lower() in WORKFLOW_SUFFIXES + ] + + +def events(content: str) -> list[str]: + found: set[str] = set() + in_on = False + for line in content.splitlines(): + if re.match(r"^on:\s*(?:#.*)?$", line): + in_on = True + continue + inline = re.match(r"^on:\s*\[([^]]+)\]", line) + if inline: + found.update( + item.strip().strip("'\"") + for item in inline.group(1).split(",") + if item.strip() + ) + break + if in_on and line and not line[0].isspace() and not line.lstrip().startswith("#"): + break + if in_on: + match = re.match(r"^ ([\w-]+):(?:\s|$)", line) + if match: + found.add(match.group(1)) + return sorted(found) + + +def primitive_counts(content: str) -> dict[str, int]: + return {name: content.count(token) for name, token in PRIMITIVES.items()} + + +def structure_findings(path: str, content: str) -> tuple[dict, list[str]]: + lines = content.splitlines() + errors: list[str] = [] + + top_name_count = sum(1 for line in lines if re.match(r"^name:\s*\S", line)) + jobs_count = sum(1 for line in lines if re.match(r"^jobs:\s*(?:#.*)?$", line)) + if top_name_count != 1: + errors.append( + f"{path}: expected exactly one top-level workflow name, found {top_name_count}." + ) + if jobs_count != 1: + errors.append(f"{path}: expected exactly one top-level jobs block, found {jobs_count}.") + + in_jobs = False + current_job: str | None = None + job_ids: list[str] = [] + step_names: dict[str, list[str]] = collections.defaultdict(list) + + for line in lines: + if re.match(r"^jobs:\s*(?:#.*)?$", line): + in_jobs = True + current_job = None + continue + + if in_jobs and line and not line[0].isspace() and not line.lstrip().startswith("#"): + in_jobs = False + current_job = None + + if not in_jobs: + continue + + job_match = re.match(r"^ ([A-Za-z0-9_-]+):\s*(?:#.*)?$", line) + if job_match: + current_job = job_match.group(1) + job_ids.append(current_job) + continue + + if current_job is None: + continue + + step_match = re.match(r"^\s{6}-\s+name:\s*(.+?)\s*$", line) + if step_match: + raw_name = step_match.group(1).strip().strip("'\"") + step_names[current_job].append(raw_name) + + duplicate_jobs = sorted( + job for job, count in collections.Counter(job_ids).items() if count > 1 + ) + for job in duplicate_jobs: + errors.append(f"{path}: duplicate job id '{job}'.") + + duplicate_steps: dict[str, list[str]] = {} + for job, names in step_names.items(): + duplicates = sorted( + name for name, count in collections.Counter(names).items() if count > 1 + ) + if duplicates: + duplicate_steps[job] = duplicates + for name in duplicates: + errors.append( + f"{path}: job '{job}' contains duplicate step name '{name}'." + ) + + return { + "topLevelNameCount": top_name_count, + "topLevelJobsCount": jobs_count, + "jobIds": job_ids, + "duplicateJobIds": duplicate_jobs, + "duplicateStepNamesByJob": duplicate_steps, + }, errors + + +def inventory(root: Path) -> dict: + workflows: list[dict] = [] + structure_errors: list[str] = [] + totals = collections.Counter() + + for path in workflow_paths(root): + full = root / path + if not full.is_file(): + raise FileNotFoundError(f"Tracked workflow is missing: {path}") + content = full.read_text(encoding="utf-8-sig", errors="replace") + + metrics = primitive_counts(content) + structure, errors = structure_findings(path, content) + structure_errors.extend(errors) + totals.update(metrics) + + workflows.append( + { + "path": path, + "events": events(content), + "metrics": metrics, + "structure": structure, + } + ) + + totals["workflowFiles"] = len(workflows) + totals["workflowsWithDotnetBuild"] = sum( + 1 for workflow in workflows if workflow["metrics"]["dotnetBuild"] > 0 + ) + totals["workflowsWithDotnetTest"] = sum( + 1 for workflow in workflows if workflow["metrics"]["dotnetTest"] > 0 + ) + totals["pullRequestWorkflows"] = sum( + 1 for workflow in workflows if "pull_request" in workflow["events"] + ) + + return { + "schemaVersion": 1, + "sourceCommit": git(root, "rev-parse", "HEAD").decode("ascii").strip(), + "scope": ( + "Tracked GitHub Actions workflows only. Counts are textual CI-cost indicators; " + "they do not prove runtime duration or semantic necessity." + ), + "totals": dict(sorted(totals.items())), + "structureErrors": sorted(structure_errors), + "workflows": workflows, + } + + +def load_budget(path: Path) -> dict: + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("schemaVersion") != 1: + raise ValueError("Unsupported CI workflow budget schema.") + if not isinstance(data.get("limits"), dict): + raise ValueError("CI workflow budget is missing 'limits'.") + return data + + +def budget_violations(report: dict, budget: dict) -> list[str]: + violations: list[str] = [] + totals = report["totals"] + for metric, limit in sorted(budget["limits"].items()): + if metric not in totals: + violations.append(f"Budget references unknown metric '{metric}'.") + continue + actual = int(totals[metric]) + maximum = int(limit) + if actual > maximum: + violations.append( + f"CI budget exceeded: {metric}={actual}, allowed maximum={maximum}." + ) + return violations + + +def evaluate(root: Path, budget_path: Path | None = None) -> dict: + report = inventory(root) + budget_errors: list[str] = [] + if budget_path is not None: + budget = load_budget(budget_path) + budget_errors = budget_violations(report, budget) + report["budget"] = { + "path": str(budget_path).replace("\\", "/"), + "baselineSourceCommit": budget.get("baselineSourceCommit", ""), + "violations": budget_errors, + } + else: + report["budget"] = {"violations": []} + + report["isHealthy"] = not report["structureErrors"] and not budget_errors + return report + + +def print_summary(report: dict) -> None: + totals = report["totals"] + print( + "CI workflow inventory: " + f"workflows={totals.get('workflowFiles', 0)}, " + f"dotnetBuild={totals.get('dotnetBuild', 0)}, " + f"dotnetTest={totals.get('dotnetTest', 0)}, " + f"dotnetRestore={totals.get('dotnetRestore', 0)}, " + f"setupDotnet={totals.get('actionsSetupDotnet', 0)}, " + f"checkout={totals.get('actionsCheckout', 0)}" + ) + for error in report["structureErrors"]: + print("STRUCTURE ERROR: " + error) + for error in report["budget"].get("violations", []): + print("BUDGET ERROR: " + error) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parent.parent, + ) + parser.add_argument("--budget", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + root = args.root.resolve() + budget = args.budget.resolve() if args.budget else None + report = evaluate(root, budget) + payload = json.dumps(report, indent=2, ensure_ascii=False) + "\n" + + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(payload, encoding="utf-8") + + print_summary(report) + if not report["isHealthy"]: + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build-ardirec-bridge.ps1 b/scripts/build-ardirec-bridge.ps1 index a5f502735..c9c08f869 100644 --- a/scripts/build-ardirec-bridge.ps1 +++ b/scripts/build-ardirec-bridge.ps1 @@ -4,7 +4,9 @@ param( [Parameter(Mandatory = $true)] [string]$OutputDirectory, - [string]$BuildDirectory = "" + [string]$BuildDirectory = "", + + [string]$CTestJunitPath = "" ) $ErrorActionPreference = "Stop" @@ -33,6 +35,10 @@ New-Item -ItemType Directory -Path $output -Force | Out-Null $destinationBridge = Join-Path $output "ardirec_bridge.dll" if (Test-Path $destinationBridge -PathType Leaf) { + if (-not [string]::IsNullOrWhiteSpace($CTestJunitPath) -and + -not (Test-Path $CTestJunitPath -PathType Leaf)) { + throw "Cannot reuse pinned bridge without its requested CTest JUnit evidence." + } Write-Host "==> Reusing pinned ArdIrec bridge: $destinationBridge" Write-Output $destinationBridge exit 0 @@ -84,7 +90,8 @@ Write-Host "==> Configuring pinned ArdIrec native bridge (desktop/Qt disabled)" -DARDIREC_BUILD_DESKTOP=OFF ` -DARDIREC_BUILD_BRIDGE=ON ` -DARDIREC_BUILD_TESTS=ON ` - -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded + -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded ` + -DCMAKE_SHARED_LINKER_FLAGS_RELEASE=/Brepro if ($LASTEXITCODE -ne 0) { throw "ArdIrec bridge CMake configure failed with exit code $LASTEXITCODE." } @@ -96,10 +103,21 @@ if ($LASTEXITCODE -ne 0) { } Write-Host "==> Running ArdIrec core/bridge regression tests" -& ctest --test-dir $build -C Release --output-on-failure +if (-not [string]::IsNullOrWhiteSpace($CTestJunitPath)) { + $ctestOutput = [System.IO.Path]::GetFullPath($CTestJunitPath) + New-Item -ItemType Directory -Force (Split-Path -Parent $ctestOutput) | Out-Null + if (Test-Path $ctestOutput) { Remove-Item $ctestOutput -Force } + & ctest --test-dir $build -C Release --output-on-failure --output-junit $ctestOutput +} else { + & ctest --test-dir $build -C Release --output-on-failure +} if ($LASTEXITCODE -ne 0) { throw "ArdIrec native regression tests failed with exit code $LASTEXITCODE." } +if (-not [string]::IsNullOrWhiteSpace($CTestJunitPath) -and + -not (Test-Path $ctestOutput -PathType Leaf)) { + throw "CTest completed but the requested JUnit evidence was not produced." +} $builtBridge = Join-Path $build "bridge\Release\ardirec_bridge.dll" if (-not (Test-Path $builtBridge -PathType Leaf)) { 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/classify-ci-changes.py b/scripts/classify-ci-changes.py new file mode 100644 index 000000000..55f20da35 --- /dev/null +++ b/scripts/classify-ci-changes.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""CI-P1: classify changed paths conservatively without network/API access. + +The PR-merge mode diffs HEAD^1..HEAD, where HEAD is the checkout of GitHub's +synthetic PR merge commit. If this relationship is unavailable, select the full +build/test surface; never silently skip verification. +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +from pathlib import Path + +SOURCE_EXTENSIONS = {".cs", ".csproj", ".sln", ".slnx", ".xaml", ".props", ".targets", + ".resx", ".razor", ".fs", ".fsproj", ".config", ".manifest"} +DOC_EXTENSIONS = {".md", ".rst", ".adoc", ".txt"} +WEBSITE_EXTENSIONS = {".html", ".css", ".js", ".mjs", ".png", ".jpg", ".jpeg", + ".webp", ".svg", ".gif", ".ico", ".woff", ".woff2"} +SCL_TOKENS = ("scl", "mms", "iec61850", "discovery", "report", "rcb", "control", + "dataset", "fcda", "ldmodel", "ied", "sasi") +SV_TOKENS = ("smv", "sampledvalue", "sampled-value", "processbus", "goose", "sv-evidence") +IO_TOKENS = ("iotest", "iolist", "iofat", "fatdataset", "fatscl", "io-testing") + + +def classify(paths: list[str]) -> dict: + """Pure classifier. Unknown files intentionally require the broad build.""" + def normalize(name: str) -> str: + normalized = name.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + return normalized + + changed = sorted({normalize(name) for name in paths if name.strip()}) + areas: set[str] = set() + build = tests = iec = sv = io = packaging = website = ci = unknown = False + + if not changed: + # A missing/unsupported comparison is never interpreted as a docs-only change. + changed = ["__unclassified_source_change__"] + + for path in changed: + lower = path.lower() + basename = Path(lower).name + suffix = Path(lower).suffix + if path == "__unclassified_source_change__": + areas.add("unknown") + build = tests = iec = sv = io = packaging = unknown = True + continue + + if lower.startswith("landing/") or lower.startswith("website/"): + areas.add("website") + website = True + continue + + if lower.startswith(".github/"): + areas.add("ci") + ci = True + if not (lower.startswith(".github/workflows/") or + lower.startswith(".github/actions/")): + build = tests = True + # CI-only changes are covered by the P0 guard and the opt-in P1 shadow. + continue + + if lower.startswith("docs/") and lower != "docs/interoperability_reference_contract.md": + areas.add("docs") + continue + + if lower.startswith("scripts/"): + areas.add("ci") + ci = True + if basename not in { + "classify-ci-changes.py", + "test-classify-ci-changes.py", + "audit-ci-workflows.py", + "test-ci-workflows.py", + }: + build = tests = True # scripts can affect release/build semantics + continue + + if lower.startswith("evidence/"): + areas.add("authority") + iec = True + build = tests = True # existing authority workflows remain mandatory + continue + + if lower.startswith("engines/") or lower.startswith("engine-patches/"): + areas.add("engine") + build = tests = iec = sv = io = True + continue + + if lower.startswith("installer/") or lower.startswith(".release/") or lower == "version": + areas.add("packaging") + packaging = True + build = tests = True + continue + + if lower == "docs/interoperability_reference_contract.md": + areas.add("authority") + iec = True + build = tests = True + continue + + if lower.startswith("assets/") or lower.startswith("resources/"): + areas.add("app") + build = tests = True + continue + + if suffix in SOURCE_EXTENSIONS or lower in { + "global.json", "nuget.config", "directory.build.props", + "directory.build.targets", "directory.packages.props", "app.manifest" + }: + areas.add("app") + build = tests = True + if any(token in lower for token in SCL_TOKENS): + iec = True + areas.add("iec61850") + if any(token in lower for token in SV_TOKENS): + sv = True + areas.add("process-bus") + if any(token in lower for token in IO_TOKENS): + io = True + areas.add("io-fat") + continue + + if lower.startswith("tests/"): + # Non-source test assets can change regression truth. + areas.add("tests") + build = tests = True + continue + + if suffix in DOC_EXTENSIONS or lower in {"readme", "license", "notice"}: + areas.add("docs") + continue + + if suffix in WEBSITE_EXTENSIONS: + areas.add("website") + website = True + continue + + # Unclassified paths can affect the build, packaging, or evidence. + areas.add("unknown") + build = tests = iec = sv = io = packaging = unknown = True + + flags = { + "build": build, + "tests": tests, + "iec": iec, + "sv": sv, + "io": io, + "packaging": packaging, + "website": website, + "ci": ci, + "unknown": unknown, + } + risk = "broad" if unknown or "engine" in areas else ("targeted" if build else "none") + return { + "schemaVersion": 1, + "areas": sorted(areas), + "changedFiles": changed, + "flags": flags, + "risk": risk, + "requiresLegacyAuthority": bool(iec or packaging), + "interpretation": ( + "Advisory classification only. Legacy CI remains authoritative; " + "unknown input always selects broad verification." + ), + } + + +def merge_diff(root: Path) -> list[str]: + """Collect PR merge-tree changes; absence of a merge parent is a full-build signal.""" + try: + parents = subprocess.check_output( + ["git", "-C", str(root), "rev-list", "--parents", "-n", "1", "HEAD"], + stderr=subprocess.PIPE, + ).decode("ascii", "strict").split() + if len(parents) != 3: + return ["__unclassified_source_change__"] + + raw = subprocess.check_output( + ["git", "-C", str(root), "diff", "--name-only", "-z", parents[1], "HEAD"], + stderr=subprocess.PIPE, + ) + return [name.decode("utf-8", "surrogateescape") + for name in raw.split(b"\0") if name] or ["__unclassified_source_change__"] + except (subprocess.CalledProcessError, UnicodeError, OSError): + return ["__unclassified_source_change__"] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--from-pr-merge", action="store_true") + parser.add_argument("--paths", nargs="*", default=None) + parser.add_argument("--github-output", type=Path) + parser.add_argument("--summary", type=Path) + parser.add_argument("--json-output", type=Path) + args = parser.parse_args() + + report = classify( + merge_diff(args.root.resolve()) if args.from_pr_merge else (args.paths or []) + ) + payload = json.dumps(report, sort_keys=True, ensure_ascii=False) + print(payload) + + if args.github_output: + with args.github_output.open("a", encoding="utf-8") as stream: + for key, value in sorted(report["flags"].items()): + stream.write(f"{key}={str(value).lower()}\n") + stream.write(f"risk={report['risk']}\n") + + if args.json_output: + args.json_output.write_text(payload + "\n", encoding="utf-8") + + if args.summary: + with args.summary.open("a", encoding="utf-8") as stream: + stream.write("## CI-P1 advisory change classifier\n\n") + stream.write(f"- areas: {', '.join(report['areas'])}\n") + stream.write(f"- risk: {report['risk']}\n") + stream.write(f"- full Windows build required: {report['flags']['build']}\n") + stream.write("- legacy authority remains required and unchanged\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/compare-ci-native-shadow.py b/scripts/compare-ci-native-shadow.py new file mode 100644 index 000000000..9b876bb80 --- /dev/null +++ b/scripts/compare-ci-native-shadow.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""CI-P3J-C read-only PR-native COMTRADE/canonical shadow comparison. + +Advisory only: this tool never authorizes dropping independent native CTest. +""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import io +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent +_spec = importlib.util.spec_from_file_location( + "arsas_native_shadow_ctest", _ROOT / "write-ci-native-ctest-proof.py" +) +assert _spec and _spec.loader +_native = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_native) + +ARTIFACT = "ARSAS-native-ctest-shadow" +MAX_ZIP = 4 * 1024 * 1024 +JUNIT = "ctest.xml" +PROOF = "ci-native-ctest-authority.json" +NAMES = frozenset((JUNIT, PROOF)) +REPO = "masarray/arsas" + + +class ShadowError(RuntimeError): + pass + + +class SafeRedirect(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 ShadowError("GITHUB_TOKEN with read-only actions scope is required") + self.token = token + + def get(self, url: str, *, binary: bool = False): + request = urllib.request.Request( + url, headers={ + "Authorization": "Bearer " + self.token, + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "ARSAS-CI-P3J-C-Native-Shadow", + }, method="GET", + ) + try: + with urllib.request.build_opener(SafeRedirect()).open(request, timeout=30) as result: + limit = MAX_ZIP if binary else 1024 * 1024 + payload = result.read(limit + 1) + if len(payload) > limit: + raise ShadowError("Shadow evidence download exceeds bounded size") + return payload if binary else json.loads(payload) + except (urllib.error.URLError, ValueError) as exc: + raise ShadowError(f"Read-only GitHub shadow evidence request failed: {exc}") from exc + + +def choose_exact_run(payload: dict, *, head_sha: str, head_branch: str) -> dict | None: + runs = payload.get("workflow_runs") + if not isinstance(runs, list): + raise ShadowError("Canonical run list is malformed") + chosen = [ + run for run in runs + if run.get("name") == "Build ARSAS" + and run.get("event") == "pull_request" + and run.get("head_sha") == head_sha + and run.get("head_branch") == head_branch + and isinstance(run.get("id"), int) + and isinstance(run.get("run_attempt"), int) + ] + if not chosen: + return None + # Reruns and retries supersede prior proof on the same PR/head. + return max(chosen, key=lambda run: (run["id"], run["run_attempt"])) + + +def one_artifact(payload: dict, run_id: int) -> dict | None: + entries = payload.get("artifacts") + if not isinstance(entries, list): + raise ShadowError("Canonical artifact list is malformed") + matches = [ + a for a in entries + if a.get("name") == ARTIFACT + and a.get("expired") is False + and a.get("workflow_run", {}).get("id") == run_id + and isinstance(a.get("id"), int) + ] + if len(matches) > 1: + raise ShadowError("Duplicate canonical native CTest shadow artifacts") + return matches[0] if matches else None + + +def parse_small_artifact(blob: bytes, expected_digest: str) -> tuple[bytes, bytes]: + if not blob or len(blob) > MAX_ZIP: + raise ShadowError("Native CTest artifact ZIP missing or oversized") + if not isinstance(expected_digest, str) or not expected_digest.startswith("sha256:"): + raise ShadowError("Canonical artifact SHA-256 metadata missing") + if hashlib.sha256(blob).hexdigest() != expected_digest.removeprefix("sha256:"): + raise ShadowError("Canonical artifact ZIP SHA-256 does not match API metadata") + try: + with zipfile.ZipFile(io.BytesIO(blob)) as archive: + files = [i for i in archive.infolist() if not i.is_dir()] + if {i.filename for i in files} != NAMES or len(files) != 2: + raise ShadowError("Canonical native artifact has missing/extra evidence files") + if any(i.file_size > MAX_ZIP or i.flag_bits & 0x1 for i in files): + raise ShadowError("Canonical native artifact contains unsafe entries") + return archive.read(JUNIT), archive.read(PROOF) + except (zipfile.BadZipFile, OSError, KeyError, EOFError) as exc: + raise ShadowError("Canonical native shadow artifact is not a readable ZIP") from exc + + +def compare_proofs( + *, junit: bytes, canonical_json: bytes, + independent_junit: Path, independent_bridge: Path, + source_sha: str, engine_sha: str, ardirec_sha: str, + run_id: int, run_attempt: int, event: str, +) -> dict: + try: + claimed = json.loads(canonical_json) + if not isinstance(claimed, dict): + raise ValueError("Native proof JSON must be an object") + canonical = _native.verify_sealed_native_proof( + proof_bytes=canonical_json, junit_bytes=junit, + bridge_sha256=claimed.get("nativeBridgeSha256"), + bridge_size=claimed.get("nativeBridgeSizeBytes"), + source_sha=source_sha, engine_sha=engine_sha, ardirec_sha=ardirec_sha, + run_id=run_id, attempt=run_attempt, event=event, + ) + local_names = _native.parse_ctest_junit(independent_junit) + local_sha = _native.digest(independent_bridge) + local_size = independent_bridge.stat().st_size + except (OSError, ValueError, KeyError, TypeError) as exc: + raise ShadowError(f"Invalid canonical/independent native evidence: {exc}") from exc + inventory_match = local_names == canonical["testNames"] + bytes_match = (local_sha == canonical["nativeBridgeSha256"] + and local_size == canonical["nativeBridgeSizeBytes"]) + return { + "sourceSha": source_sha, + "engineSha": engine_sha, + "ardirecSha": ardirec_sha, + "canonicalRunId": run_id, + "canonicalRunAttempt": run_attempt, + "canonicalTestNames": canonical["testNames"], + "independentTestNames": local_names, + "canonicalBridgeSha256": canonical["nativeBridgeSha256"], + "independentBridgeSha256": local_sha, + "ctestInventoryEqual": inventory_match, + "bridgeBytesEqual": bytes_match, + "shadowParityObserved": inventory_match and bytes_match, + "independentWindowsCTestRetained": True, + "releasePromotionAuthority": False, + "deduplicationAuthorized": False, + } + + +def compare_online( + api, *, head_sha: str, head_branch: str, source_sha: str, + engine_sha: str, ardirec_sha: str, independent_junit: Path, + independent_bridge: Path, wait_seconds: int = 600, poll_seconds: int = 15, + require_completed: bool = False, +) -> dict: + for label, sha in (("head", head_sha), ("source", source_sha), + ("engine", engine_sha), ("ArdIrec", ardirec_sha)): + if not _native.SHA_RE.fullmatch(sha): + raise ShadowError(f"Invalid exact {label} SHA") + if not head_branch or len(head_branch) > 180 or "\n" in head_branch: + raise ShadowError("Invalid exact PR branch") + if wait_seconds < 0 or wait_seconds > 1200 or poll_seconds < 1: + raise ShadowError("Invalid bounded shadow wait") + base = "https://api.github.com/repos/" + REPO + query = urllib.parse.urlencode({ + "event": "pull_request", "head_sha": head_sha, "per_page": 100, + }) + deadline = time.monotonic() + wait_seconds + last = "canonical run/artifact not ready" + while True: + run = choose_exact_run( + api.get(f"{base}/actions/workflows/build.yml/runs?{query}"), + head_sha=head_sha, head_branch=head_branch, + ) + if run is not None: + status, conclusion = run.get("status"), run.get("conclusion") + last = f"run {run['id']}/{run['run_attempt']} {status}/{conclusion}" + if status == "completed" and conclusion != "success": + raise ShadowError("Latest matching canonical build failed: " + last) + if status == "completed" or (status == "in_progress" and not require_completed): + art = one_artifact( + api.get(f"{base}/actions/runs/{run['id']}/artifacts?per_page=100"), + run["id"], + ) + if art is not None: + try: + blob = api.get(art["archive_download_url"], binary=True) + except ShadowError as exc: + if "404" not in str(exc): + raise + else: + j, proof = parse_small_artifact(blob, art.get("digest")) + result = compare_proofs( + junit=j, canonical_json=proof, + independent_junit=independent_junit, + independent_bridge=independent_bridge, + source_sha=source_sha, engine_sha=engine_sha, + ardirec_sha=ardirec_sha, + run_id=run["id"], run_attempt=run["run_attempt"], + event="pull_request", + ) + result["canonicalRunStatus"] = status + result["canonicalRunConclusion"] = conclusion + result["fullCanonicalBuildPassed"] = ( + status == "completed" and conclusion == "success" + ) + result["provisional"] = not result["fullCanonicalBuildPassed"] + result["completedCanonicalRequired"] = require_completed + return result + if time.monotonic() >= deadline: + raise ShadowError(f"Bounded shadow comparison timeout; last={last}") + time.sleep(min(poll_seconds, deadline - time.monotonic())) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--head-sha", required=True) + parser.add_argument("--head-branch", 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("--independent-junit", type=Path, required=True) + parser.add_argument("--independent-bridge", type=Path, required=True) + parser.add_argument("--wait-seconds", type=int, default=600) + parser.add_argument("--require-completed-canonical", action="store_true") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + result = compare_online( + GitHubReadOnly(os.environ.get("GITHUB_TOKEN", "")), + head_sha=args.head_sha, head_branch=args.head_branch, + source_sha=args.source_sha, engine_sha=args.engine_sha, + ardirec_sha=args.ardirec_sha, + independent_junit=args.independent_junit, + independent_bridge=args.independent_bridge, + wait_seconds=args.wait_seconds, + require_completed=args.require_completed_canonical, + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, sort_keys=True, indent=2) + "\n", + encoding="utf-8") + print("CI-P3J-C shadow result: " + json.dumps(result, sort_keys=True)) + if not result["shadowParityObserved"]: + raise ShadowError("Independent native build differs; keep Windows COMTRADE CTest") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/compare-p10-brcb-sessions.py b/scripts/compare-p10-brcb-sessions.py new file mode 100644 index 000000000..fb40ab890 --- /dev/null +++ b/scripts/compare-p10-brcb-sessions.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""Offline diagnostic comparison for exact BRCB slots. No IEC/MMS I/O. + +Observational scope only. Even matching SqNum/EntryID-relation chronologies +cannot prove SOE continuity, event delivery or original wire PDU ordering. +""" +from __future__ import annotations + +import argparse +import json +import re +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +STREAM = re.compile(r"^\s*stream\[\d+\]\s*:\s*(BRCB|URCB),\s*rcb=([^,\r\n]+),\s*dataset=([^,\r\n]+),.*\bframes=(\d+),") +TRIAGE = re.compile(r"^\s*RCB triage\s*:\s*anomalousFrames=(\d+),\s*sqDiscontinuities=(\d+),") +FOLLOW = re.compile(r"^\s*RCB follow-up\s*:\s*receivedAfterLastFinding=(\d+),") +FRAME = re.compile(r"^\s*(?:frame|anomalyFrame)#(\d+):\s*receivedUtc=([^,\s]+),\s*SqNum=([^,\s]+),\s*GIreason=([^,\s]+),\s*BufOvfl=([^,\s]+),\s*EntryIDrelation=([^,\s]+),\s*findings=(\d+)") +FINGERPRINT = re.compile(r"\bfingerprint=([a-fA-F0-9]{64})\b") +IED = re.compile(r"^\s*IED\s*:\s*([^\r\n]+)$") +ENGINE = re.compile(r"^Engine version\s*:\s*[^+\r\n]+\+([0-9a-f]{40})\s*$") +DATASET = re.compile(r"^\s*Static DataSets\s*:\s*(\d+)") +BUFOVFL = re.compile(r"(?:^|,)\s*BufOvfl=(\d+)(?:,|$)") +SQ_GI = re.compile(r"\bsqWithGIreason=(\d+)\b") +LIFECYCLE = re.compile(r"^\s*RCB lifecycle\s*:") +CHRONOLOGY = re.compile(r"^\s*BRCB chronology:\s*first=(\d+),\s*recent=(\d+),\s*omittedMiddle=(\d+)") + +def _field(line: str, key: str) -> str | None: + match = re.search(rf"(?:^|,\s*){re.escape(key)}=([^,;\s]+)", line) + value = match.group(1) if match else None + return value if value and value != "-" else None + +@dataclass(frozen=True) +class Stream: + kind: str + rcb: str + dataset: str + frames: int + overflow_frames: int = 0 + anomalous_frames: int = 0 + discontinuities: int = 0 + after_last_finding: int | None = None + sq_with_gi_reason: int | None = None + gi_reason_frames: int | None = None + start_return_utc: str | None = None + last_anomaly_utc: str | None = None + chronology: tuple[tuple[str, ...], ...] = () + chronology_declared: tuple[int, int, int] | None = None + recent_anomalies: tuple[tuple[str, ...], ...] = () + +def parse_report(report: str) -> dict: + streams: dict[str, dict] = {} + active: str | None = None + fingerprint: str | None = None + ied: str | None = None + engine: str | None = None + for line in report.splitlines(): + m = FINGERPRINT.search(line) + if m and line.lstrip().startswith(("Discovery", "Open SCL", "LiveDiscovery:")): + fingerprint = m.group(1).lower() + m = IED.match(line) + if m: + ied = m.group(1).strip() + m = ENGINE.match(line) + if m: + engine = m.group(1) + m = STREAM.match(line) + if m: + kind, rcb, dataset, count = m.groups() + overflow_match = BUFOVFL.search(line) + if kind == "BRCB" and overflow_match is None: + raise ValueError(f"missing BRCB BufOvfl count for {rcb.strip()}") + overflow_frames = int(overflow_match.group(1)) if overflow_match else 0 + if overflow_frames > int(count): + raise ValueError(f"BRCB BufOvfl count exceeds received frames for {rcb.strip()}") + active = rcb.strip().casefold() + if active in streams: + raise ValueError(f"duplicate RCB in diagnostic: {active}") + streams[active] = dict(kind=kind, rcb=rcb.strip(), dataset=dataset.strip(), + frames=int(count), overflow_frames=overflow_frames, + anomalous_frames=0, discontinuities=0, + after_last_finding=None, sq_with_gi_reason=None, + gi_reason_frames=None, start_return_utc=None, + last_anomaly_utc=None, chronology=[], + chronology_declared=None, recent_anomalies=[]) + continue + if active is None or active not in streams: + continue + m = TRIAGE.match(line) + if m: + streams[active]["anomalous_frames"] = int(m.group(1)) + streams[active]["discontinuities"] = int(m.group(2)) + gi = SQ_GI.search(line) + streams[active]["sq_with_gi_reason"] = int(gi.group(1)) if gi else None + m = FOLLOW.match(line) + if m: + streams[active]["after_last_finding"] = int(m.group(1)) + if LIFECYCLE.match(line): + count = _field(line, "GIreasonFrames") + streams[active]["gi_reason_frames"] = int(count) if count is not None and count.isdecimal() else None + streams[active]["start_return_utc"] = _field(line, "startReturnUtc") + streams[active]["last_anomaly_utc"] = _field(line, "lastAnomalyUtc") + m = CHRONOLOGY.match(line) + if m and streams[active]["kind"] == "BRCB": + streams[active]["chronology_declared"] = tuple(map(int, m.groups())) + m = FRAME.match(line) + if m: + values = m.groups() + key = "recent_anomalies" if "anomalyFrame#" in line else "chronology" + streams[active][key].append(values) + if not ied or not fingerprint or not engine or not streams: + raise ValueError("missing IED, engine SHA, static fingerprint, or RCB streams") + return dict(ied=ied, fingerprint=fingerprint, engine=engine, + streams={k: Stream(**{**v, "chronology": tuple(v["chronology"]), + "recent_anomalies": tuple(v["recent_anomalies"])}) + for k, v in streams.items()}) + +def _require_matching_authority(left: dict, right: dict) -> None: + if left["ied"].casefold() != right["ied"].casefold(): + raise ValueError("different IED identities; comparison blocked") + if left["fingerprint"] != right["fingerprint"]: + raise ValueError("different static semantic fingerprints; comparison blocked") + if left["engine"] != right["engine"]: + raise ValueError("different engine commits; comparison blocked") + +def _elapsed_ms(start: str | None, end: str | None) -> float | None: + if not start or not end: + return None + try: + a, b = datetime.fromisoformat(start.replace("Z", "+00:00")), datetime.fromisoformat(end.replace("Z", "+00:00")) + delta = (b - a).total_seconds() * 1000 + return round(delta, 3) if 0 <= delta <= 86400000 else None + except (TypeError, ValueError): + return None + +def _receive_order_gap_evidence(stream: Stream) -> dict: + """Only a fully retained decoder receive chronology authorizes temporal inferences. + + Neither callback timestamps nor SqNum/EntryID establish MMS packet order or SOE delivery. + An invalid, bounded, omitted or untimed chronology returns unknown, not zero. + """ + rows = stream.chronology + declared = stream.chronology_declared + reason = None + if declared is None: + reason = "MISSING_CHRONOLOGY_DECLARATION" + elif declared[2] != 0 or sum(declared[:2]) != stream.frames: + reason = "CHRONOLOGY_HAS_OMITTED_OR_UNACCOUNTED_FRAMES" + elif len(rows) != stream.frames or len(rows) != sum(declared[:2]): + reason = "RETAINED_CHRONOLOGY_INCOMPLETE" + elif [int(row[0]) for row in rows] != list(range(1, stream.frames + 1)): + reason = "RECEIVE_FRAME_INDICES_NOT_CONTIGUOUS" + times = [] + if reason is None: + for row in rows: + try: + moment = datetime.fromisoformat(row[1].replace("Z", "+00:00")) + if moment.tzinfo is None or moment.utcoffset() is None: + raise ValueError("no UTC offset") + except (TypeError, ValueError, OverflowError): + reason = "MISSING_OR_INVALID_RECEIVE_TIMESTAMPS" + break + times.append(moment) + if reason is None and any(b < a for a, b in zip(times, times[1:])): + reason = "NONMONOTONIC_DECODER_RECEIVE_TIMESTAMPS" + if reason is not None: + return dict(coverage="UNVERIFIABLE", reason=reason, + recorded_frames=len(rows), expected_frames=stream.frames, + longest_receive_gap_ms=None, + non_gi_backward_finding_after_gap=None) + gaps = [(b - a).total_seconds() * 1000 for a, b in zip(times, times[1:])] + candidates = [] + for index, gap in enumerate(gaps, start=1): + before, after = rows[index - 1], rows[index] + try: + prev_sq, curr_sq = int(before[2]), int(after[2]) + except ValueError: + continue + # A genuine decoded finding that recurs without GI or BufOvfl on this + # received frame; this is NOT evidence of harmless replay or lost SOE. + if (after[3] == "NO" and after[4] == "NO" and + int(after[6]) > 0 and curr_sq <= prev_sq): + candidates.append(dict(previous_frame=int(before[0]), frame=int(after[0]), + previous_sqnum=prev_sq, sqnum=curr_sq, + gap_ms=round(gap, 3), + elapsed_since_start_return_ms=_elapsed_ms( + stream.start_return_utc, after[1]), + received_utc=after[1], + entry_id_relation=after[5])) + strongest = max(candidates, key=lambda x: x["gap_ms"]) if candidates else None + return dict(coverage="COMPLETE_BOUNDED_DECODER_RECEIVE_ORDER", reason=None, + recorded_frames=len(rows), expected_frames=stream.frames, + longest_receive_gap_ms=round(max(gaps), 3) if gaps else None, + non_gi_backward_finding_after_gap=strongest) + +def independent_triage(left: dict, right: dict) -> dict: + """Evidence for *separate* sessions, never a paired BRCB reliability comparison.""" + _require_matching_authority(left, right) + la = {k: s for k, s in left["streams"].items() if s.kind == "BRCB"} + ra = {k: s for k, s in right["streams"].items() if s.kind == "BRCB"} + if not la or not ra: + raise ValueError("no BRCB streams") + matched = set(la) == set(ra) and all( + la[key].dataset.casefold() == ra[key].dataset.casefold() for key in la) + def session_rows(streams: dict[str, Stream]) -> list[dict]: + rows = [] + for stream in sorted(streams.values(), key=lambda s: s.rcb.casefold()): + gi = stream.sq_with_gi_reason + if stream.anomalous_frames > stream.frames or ( + gi is not None and gi > stream.discontinuities): + raise ValueError(f"impossible BRCB diagnostic counts for {stream.rcb}") + rows.append(dict(rcb=stream.rcb, dataset=stream.dataset, + received_frames=stream.frames, bufovfl_true_frames=stream.overflow_frames, + anomalous_frames=stream.anomalous_frames, + sq_discontinuities=stream.discontinuities, + sq_with_gi_reason=gi, + sq_without_gi_reason=(stream.discontinuities - gi) if gi is not None else None, + received_after_last_finding=stream.after_last_finding, + gi_reason_frames=stream.gi_reason_frames, + last_anomaly_ms_after_start_return=_elapsed_ms( + stream.start_return_utc, stream.last_anomaly_utc), + retained_chronology_frames=len(stream.chronology), + retained_anomaly_frames=len(stream.recent_anomalies), + receive_order_gap_evidence=_receive_order_gap_evidence(stream))) + return rows + return dict(status="INDEPENDENT_SESSIONS_OBSERVATION_ONLY", + same_slot_dataset_scope=matched, + paired_comparison_blocked_reason=None if matched else "different BRCB slots or DataSets", + ied=left["ied"], fingerprint=left["fingerprint"], engine_commit=left["engine"], + sessions=dict(open_scl=session_rows(la), discovery=session_rows(ra)), + qualification="NOT_PAIRED_RELIABILITY_COMPARISON; DECODER_RECEIVE_TIMESTAMPS_NOT_WIRE_ORDER; NOT_SOE_LOSSLESS; NOT_R10_PHYSICAL_ACCEPTANCE") + +def compare(left: dict, right: dict) -> dict: + _require_matching_authority(left, right) + la = {k: s for k, s in left["streams"].items() if s.kind == "BRCB"} + ra = {k: s for k, s in right["streams"].items() if s.kind == "BRCB"} + if not la or not ra: + raise ValueError("no BRCB streams") + if set(la) != set(ra): + raise ValueError("different BRCB slots: no cross-slot reliability comparison") + results = [] + for ref in sorted(la): + a, b = la[ref], ra[ref] + if a.dataset.casefold() != b.dataset.casefold(): + raise ValueError(f"different DataSet on BRCB {ref}; comparison blocked") + results.append(dict(rcb=a.rcb, dataset=a.dataset, + left_frames=a.frames, right_frames=b.frames, + left_bufovfl_frames=a.overflow_frames, + right_bufovfl_frames=b.overflow_frames, + left_findings_frames=a.anomalous_frames, + right_findings_frames=b.anomalous_frames, + left_sq_discontinuities=a.discontinuities, + right_sq_discontinuities=b.discontinuities, + left_clean_followup=a.after_last_finding, + right_clean_followup=b.after_last_finding, + left_chronology_frames=len(a.chronology), + right_chronology_frames=len(b.chronology), + left_anomaly_history_frames=len(a.recent_anomalies), + right_anomaly_history_frames=len(b.recent_anomalies))) + return dict(status="SAME_SLOT_OBSERVATION_ONLY", ied=left["ied"], + fingerprint=left["fingerprint"], engine_commit=left["engine"], + slots=results, + qualification="NOT_WIRE_ORDER_PROOF; NOT_SOE_LOSSLESS; NOT_R10_PHYSICAL_ACCEPTANCE") + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("open_scl", type=Path, help="Open SCL ARSAS Diagnostic Report") + ap.add_argument("discovery", type=Path, help="Discovery IP ARSAS Diagnostic Report") + ap.add_argument("--output", type=Path, help="optional JSON output path") + ap.add_argument("--independent-triage", action="store_true", + help="separate per-session evidence when real BRCB slots differ; never paired reliability") + args = ap.parse_args() + try: + a = parse_report(args.open_scl.read_text(encoding="utf-8-sig")) + b = parse_report(args.discovery.read_text(encoding="utf-8-sig")) + result = independent_triage(a, b) if args.independent_triage else compare(a, b) + except (OSError, UnicodeError, ValueError) as exc: + ap.exit(2, f"INCOMPARABLE: {exc}\n") + formatted = json.dumps(result, indent=2, sort_keys=True) + if args.output: + args.output.write_text(formatted + "\n", encoding="utf-8") + print(formatted) + return 0 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish-windows-portable.ps1 b/scripts/publish-windows-portable.ps1 index aaa0fefc6..e3aa95190 100644 --- a/scripts/publish-windows-portable.ps1 +++ b/scripts/publish-windows-portable.ps1 @@ -116,6 +116,8 @@ $publishArguments = @( "-p:UseAppHost=true", "-p:DebugType=None", "-p:DebugSymbols=false", + "-p:Deterministic=true", + "-p:ContinuousIntegrationBuild=true", "-p:Version=$normalizedVersion", "-p:AssemblyVersion=$numericVersion", "-p:FileVersion=$numericVersion", @@ -161,7 +163,54 @@ if ($SingleFile) { throw "Versioned portable single EXE was not produced: $singleExePath" } + function Get-GitIdentity([string]$path) { + try { + $repoRoot = (& git -C $path rev-parse --show-toplevel 2>$null).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) { return $null } + $head = (& git -C $repoRoot rev-parse HEAD 2>$null).Trim().ToLowerInvariant() + if ($LASTEXITCODE -ne 0 -or $head -notmatch '^[0-9a-f]{40}$') { return $null } + return $head + } + catch { + return $null + } + } + + $engineProjectDirectory = Split-Path -Parent ([System.IO.Path]::GetFullPath($EngineProject)) + $sourceCommit = Get-GitIdentity $root + $engineCommit = Get-GitIdentity $engineProjectDirectory + $ardirecLockPath = Join-Path $root "engines\ARDIREC.lock.json" + $ardirecLockCommit = $null + if (Test-Path $ardirecLockPath -PathType Leaf) { + $ardirecLock = Get-Content $ardirecLockPath -Raw | ConvertFrom-Json + if ($ardirecLock.commit -match '^[0-9a-f]{40}$') { + $ardirecLockCommit = [string]$ardirecLock.commit + } + } + + $bridgeHash = (Get-FileHash $ArdIrecBridgePath -Algorithm SHA256).Hash.ToLowerInvariant() + $portableHash = (Get-FileHash $singleExePath -Algorithm SHA256).Hash.ToLowerInvariant() + $identityPath = Join-Path $outputRoot "ARSAS-$normalizedVersion-$Runtime-portable-build-identity.json" + [ordered]@{ + schemaVersion = 1 + kind = "arsas-portable-build-identity" + version = $normalizedVersion + runtime = $Runtime + sourceCommit = $sourceCommit + engineCommit = $engineCommit + ardIrecLockCommit = $ardirecLockCommit + ardIrecBridgeSha256 = $bridgeHash + ardIrecBridgeSizeBytes = (Get-Item $ArdIrecBridgePath).Length + portableSha256 = $portableHash + portableSizeBytes = (Get-Item $singleExePath).Length + deterministicManagedBuild = $true + reproducibleNativeLinkRequested = $true + } | ConvertTo-Json -Depth 4 | Set-Content $identityPath -Encoding utf8 + Write-Host "==> Real portable single EXE with embedded ArdIrec bridge: $singleExePath" + Write-Host "==> Portable build identity: $identityPath" + Write-Host "==> Portable SHA256: $portableHash" + Write-Host "==> ArdIrec bridge SHA256: $bridgeHash" Write-Output $singleExePath exit 0 } diff --git a/scripts/resolve-ariec-lock.ps1 b/scripts/resolve-ariec-lock.ps1 new file mode 100644 index 000000000..00f4172c4 --- /dev/null +++ b/scripts/resolve-ariec-lock.ps1 @@ -0,0 +1,162 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS +Resolve the exact, immutable ARIEC61850 integration SHA from a tracked lock. +.DESCRIPTION +Read-only and fail-closed. Historical physical evidence remains separate from +the current integration pin; this resolver never rewrites either one. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$LockPath, + + [string]$GitHubOutput = '' +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $LockPath -PathType Leaf)) { + throw "ARIEC61850 lock file does not exist: $LockPath" +} + +try { + $lock = Get-Content -LiteralPath $LockPath -Raw -Encoding UTF8 | + ConvertFrom-Json -Depth 40 +} +catch { + throw "ARIEC61850 lock cannot be parsed: $($_.Exception.Message)" +} + +if ([int]$lock.schemaVersion -ne 1) { + throw 'ARIEC61850 lock schemaVersion must be 1.' +} +if ([string]$lock.repository -cne 'masarray/ARIEC61850') { + throw 'ARIEC61850 repository must match the trusted masarray/ARIEC61850 source.' +} +if ([string]$lock.ref -cne 'main') { + throw 'ARIEC61850 integration ref must remain main; checkout is pinned by SHA.' +} + +function Assert-Sha { + param([string]$Value, [string]$Name) + if ($Value -cnotmatch '^[0-9a-f]{40}$') { + throw "Invalid immutable SHA field $Name." + } +} + +Assert-Sha -Value ([string]$lock.commit) -Name 'commit' + +if ($null -ne $lock.physicalTestedCommit) { + Assert-Sha -Value ([string]$lock.physicalTestedCommit) -Name 'physicalTestedCommit' +} +if ($null -ne $lock.mergedMainCommit) { + Assert-Sha -Value ([string]$lock.mergedMainCommit) -Name 'mergedMainCommit' +} +if ($null -ne $lock.mergedMainTree) { + Assert-Sha -Value ([string]$lock.mergedMainTree) -Name 'mergedMainTree' +} +if ($null -ne $lock.previousStablePin) { + Assert-Sha -Value ([string]$lock.previousStablePin.commit) -Name 'previousStablePin.commit' + if ($null -ne $lock.mergedMainCommit -and + [string]$lock.previousStablePin.commit -cne [string]$lock.mergedMainCommit) { + throw 'Previous stable pin no longer matches historical merged-main authority.' + } +} +if ($null -ne $lock.sclAssociationInteroperability) { + $association = $lock.sclAssociationInteroperability + if ($null -ne $association.testedEngineCommit) { + Assert-Sha -Value ([string]$association.testedEngineCommit) -Name 'sclAssociationInteroperability.testedEngineCommit' + if ([string]$association.testedEngineCommit -cne [string]$lock.commit) { + # Historical SCL physical acceptance is immutable. The active + # runtime SHA may differ ONLY for an exact, ancestry-bound trial. + $trial = $lock.smartInteroperabilityCodeTrial + $serverAt = $lock.sclServerAtTrial + $closure = $lock.datasetFirstDiscoveryTrial + $p10 = $lock.p10IdentityTrial + $historicalAnchorValid = ( + [string]$association.testedEngineCommit -ceq '84e9820e5a32690475960e49d5ef74e6637847fd' -and + [string]$association.mergedEngineCommit -ceq 'e5deed1d8aa11d97991695c6e390baafea7ab797' -and + [string]$lock.mergedMainCommit -ceq '648124097621046f5f127ceb1cf853fea54db730' -and + [string]$lock.previousStablePin.commit -ceq [string]$lock.mergedMainCommit -and + [string]$association.contractId -ceq 'SCL-ASSOC-PARTIAL-ADDR-PHYSICAL' -and + [bool]$association.preservesR10DiscoveryModelBaseline -and + [bool]$association.fullDiscoveryFallbackForbidden -and + [bool]$association.cyclicMmsProcessPollingForbidden + ) + $trialValid = $null -ne $trial -and + [string]$trial.exactCommit -ceq '9c5292570f55dd81be1b3a6b56f937e5ba1ed276' -and + [string]$trial.status -ceq 'code-verified-not-physical' -and + [int]$trial.integrationPullRequest -eq 148 -and + [bool]$trial.physicalPromotionRequired -and + [bool]$trial.fullDiscoveryFallbackForbidden -and + [bool]$trial.vendorBranchingForbidden + $serverAtValid = $trialValid -and $null -ne $serverAt -and + [string]$serverAt.exactCommit -ceq '352c81e6a798635c6addcee0683235ca87ad416d' -and + [string]$serverAt.baseEngineCommit -ceq [string]$trial.exactCommit -and + [int]$serverAt.sourcePullRequest -eq 153 -and + [string]$serverAt.status -ceq 'code-verified-candidate-not-physical' -and + [bool]$serverAt.physicalQualificationRequired -and + [bool]$serverAt.noAutomaticMmsFailover + $closureValid = $serverAtValid -and $null -ne $closure -and + [string]$closure.exactCommit -ceq '8b0c0f7e695b016834e40a6f068994dcc945cb1a' -and + [string]$closure.baseEngineCommit -ceq [string]$serverAt.exactCommit -and + [int]$closure.enginePullRequest -eq 157 -and + [int]$closure.consumerPullRequest -eq 497 -and + [string]$closure.status -ceq 'code-trial-not-physical' -and + [bool]$closure.physicalQualificationRequired -and + [bool]$closure.fullNameListFallbackForbidden -and + [bool]$closure.cyclicMmsProcessPollingForbidden + $p10Valid = $closureValid -and $null -ne $p10 -and + [string]$p10.exactCommit -ceq '00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1' -and + [string]$p10.baseEngineCommit -ceq [string]$closure.exactCommit -and + [int]$p10.enginePullRequest -eq 161 -and + [string]$p10.status -ceq 'code-trial-not-physical' -and + [string]$p10.identitySource -ceq 'TrustedSclExactDomainMatch' -and + [bool]$p10.physicalQualificationRequired -and + [bool]$p10.fullNameListFallbackForbidden -and + [bool]$p10.cyclicMmsProcessPollingForbidden + + . (Join-Path $PSScriptRoot 'test-p10-9f-core-routing-trial.ps1') + $coreRoutingTrialValid = Test-P109FCoreRoutingTrial -Lock $lock + $acceptedTrial = ( + ($trialValid -and [string]$lock.commit -ceq [string]$trial.exactCommit -and [int]$lock.sourcePullRequest -eq 148) -or + ($serverAtValid -and [string]$lock.commit -ceq [string]$serverAt.exactCommit -and [int]$lock.sourcePullRequest -eq 153) -or + ($closureValid -and [string]$lock.commit -ceq [string]$closure.exactCommit -and [int]$lock.sourcePullRequest -eq 157) -or + ($p10Valid -and [string]$lock.commit -ceq [string]$p10.exactCommit -and [int]$lock.sourcePullRequest -eq 161) -or + ($p10Valid -and $coreRoutingTrialValid) + ) + if (-not $historicalAnchorValid -or -not $acceptedTrial) { + throw 'SCL association physical-tested engine head differs from integration lock.' + } + Write-Host 'Exact stacked code trial recognized; physical SCL-association/R10 authority remains historical and promotion-blocked.' + } + } + if ($null -ne $association.mergedEngineCommit) { + Assert-Sha -Value ([string]$association.mergedEngineCommit) -Name 'sclAssociationInteroperability.mergedEngineCommit' + } +} + +$resolved = [pscustomobject]@{ + repository = [string]$lock.repository + ref = [string]$lock.ref + commit = [string]$lock.commit + physicalTestedCommit = [string]$lock.physicalTestedCommit + mergedMainCommit = [string]$lock.mergedMainCommit + baselineIsCurrent = ([string]$lock.commit -ceq [string]$lock.mergedMainCommit) +} + +if ($GitHubOutput) { + @( + "repository=$($resolved.repository)" + "ref=$($resolved.ref)" + "commit=$($resolved.commit)" + "physical_tested_commit=$($resolved.physicalTestedCommit)" + "merged_main_commit=$($resolved.mergedMainCommit)" + "baseline_is_current=$($resolved.baselineIsCurrent.ToString().ToLowerInvariant())" + ) | Out-File -LiteralPath $GitHubOutput -Encoding UTF8 -Append +} + +Write-Host "Verified immutable ARIEC61850 integration SHA $($resolved.commit)." +Write-Host "Historical engine main authority remains $($resolved.mergedMainCommit)." +return $resolved diff --git a/scripts/test-ci-canonical-reuse.py b/scripts/test-ci-canonical-reuse.py new file mode 100644 index 000000000..3513bc9f7 --- /dev/null +++ b/scripts/test-ci-canonical-reuse.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""Synthetic fail-closed tests for canonical CI proof production and consumption.""" +from __future__ import annotations + +import importlib.util +import io +import json +import unittest +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def load(name: str, path: str): + spec = importlib.util.spec_from_file_location(name, ROOT / path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +producer = load("arsas_canonical_producer", "write-ci-canonical-proof.py") +consumer = load("arsas_canonical_consumer", "verify-ci-canonical-reuse.py") +SOURCE = "a" * 40 +ENGINE = "b" * 40 +HEAD = "c" * 40 +BRANCH = "ci/p2a-canonical-regression-reuse" +RUN = 1234 + +TRX = b""" + + + + + + + + + + + + +""" + + +def fixture(event_name: str = "pull_request"): + return producer.create_manifest( + source_sha=SOURCE, + checkout_sha=SOURCE, + engine_expected=ENGINE, + engine_actual=ENGINE, + workflow_run_id=RUN, + run_attempt=1, + event_name=event_name, + trx=TRX, + trx_name="arsas-tests.trx", + ) + + +def zip_archive(manifest: dict, trx: bytes = TRX, extra: dict | None = None) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("ARSAS/TestResults/ci-canonical-authority.json", json.dumps(manifest)) + archive.writestr("ARSAS/TestResults/arsas-tests.trx", trx) + for name, content in (extra or {}).items(): + archive.writestr(name, content) + return buffer.getvalue() + + +class FakeApi: + def __init__( + self, + run_status: str = "completed", + result: str = "success", + artifact_available: bool = True, + event_name: str = "pull_request", + branch: str = BRANCH, + head_sha: str = HEAD, + source_sha: str = SOURCE, + ): + self.status, self.result = run_status, result + self.artifact_available = artifact_available + self.event_name = event_name + self.branch = branch + self.head_sha = head_sha + manifest = fixture(event_name=event_name) + if source_sha != SOURCE: + manifest = dict(manifest) + manifest["exactSourceSha"] = source_sha + self.blob = zip_archive(manifest) + self.listing = [ + {"id": RUN, "run_attempt": 1, "name": "Build ARSAS", + "head_sha": head_sha, "head_branch": branch, "event": event_name, + "status": self.status, "conclusion": self.result} + ] + + def get(self, url: str, binary: bool = False): + if binary: + return self.blob + if "/workflows/build.yml/runs?" in url: + return {"workflow_runs": self.listing} + if "/runs/" in url and "/artifacts" in url: + if not self.artifact_available: + return {"artifacts": []} + return {"artifacts": [ + {"id": 22, "name": "ARSAS-test-evidence", "expired": False, + "archive_download_url": "https://example.invalid/artifact.zip", + "workflow_run": {"id": RUN}} + ]} + raise RuntimeError("Unexpected test API request " + url) + + +def run_once( + api: FakeApi, + *, + allow_in_progress_artifact: bool = False, + event_name: str = "pull_request", + branch: str = BRANCH, + head_sha: str = HEAD, + source_sha: str = SOURCE, + required_test_substrings: list[str] | None = None, +): + return consumer.verify_canonical( + api, + repository="masarray/arsas", + branch=branch, + head_sha=head_sha, + merge_sha=source_sha, + engine_sha=ENGINE, + wait_seconds=0, + poll_seconds=1, + allow_in_progress_artifact=allow_in_progress_artifact, + event_name=event_name, + required_test_substrings=required_test_substrings, + ) + + +class CanonicalReuseTests(unittest.TestCase): + def test_producer_and_consumer_accept_verified_exact_evidence(self): + proof = run_once(FakeApi()) + self.assertEqual(proof["passed"], 1320) + self.assertEqual(proof["failed"], 0) + self.assertEqual(proof["mergeSha"], SOURCE) + self.assertEqual(proof["engineSha"], ENGINE) + + def test_manifest_never_claims_release_authority(self): + self.assertIs(fixture()["releasePromotionAuthority"], False) + self.assertTrue(fixture()["fullRegressionPassed"]) + + def test_required_domain_test_family_is_proven_from_canonical_trx(self): + proof = run_once( + FakeApi(), + required_test_substrings=[ + "SmvSnapshotEvidenceExporterTests", + "IoTest", + "IoList", + "IoFat", + "FatDataSet", + "FatScl", + ], + ) + self.assertEqual(proof["requiredTests"]["SmvSnapshotEvidenceExporterTests"], 1) + self.assertEqual(proof["requiredTests"]["IoTest"], 1) + self.assertEqual(proof["requiredTests"]["FatScl"], 1) + + def test_missing_required_domain_test_family_fails_closed(self): + with self.assertRaisesRegex(consumer.ProofError, "missing required test family"): + run_once( + FakeApi(), + required_test_substrings=["DefinitelyMissingTests"], + ) + + def test_required_domain_test_family_rejects_nonpassing_match(self): + bad = TRX.replace( + b'testName="ARSAS.Tests.IoTestWorkflowTests.BuildsPlan" outcome="Passed"', + b'testName="ARSAS.Tests.IoTestWorkflowTests.BuildsPlan" outcome="Failed"', + ) + manifest = producer.create_manifest( + source_sha=SOURCE, + checkout_sha=SOURCE, + engine_expected=ENGINE, + engine_actual=ENGINE, + workflow_run_id=RUN, + run_attempt=1, + event_name="pull_request", + trx=bad, + trx_name="arsas-tests.trx", + ) + api = FakeApi() + api.blob = zip_archive(manifest, bad) + with self.assertRaisesRegex(consumer.ProofError, "not entirely Passed"): + run_once(api, required_test_substrings=["IoTest"]) + + def test_reject_wrong_merge_sha(self): + api = FakeApi() + with self.assertRaisesRegex(consumer.ProofError, "stale/different source revision|Timed out"): + consumer.validate_artifact_archive( + api.blob, merge_sha="f" * 40, engine_sha=ENGINE, + workflow_run_id=RUN, run_attempt=1, + ) + + def test_reject_wrong_engine_sha(self): + with self.assertRaisesRegex(consumer.ProofError, "engine SHA"): + consumer.validate_artifact_archive( + FakeApi().blob, merge_sha=SOURCE, engine_sha="f" * 40, + workflow_run_id=RUN, run_attempt=1, + ) + + def test_reject_manifest_run_attempt_mismatch(self): + with self.assertRaisesRegex(consumer.ProofError, "run attempt"): + consumer.validate_artifact_archive( + FakeApi().blob, merge_sha=SOURCE, engine_sha=ENGINE, + workflow_run_id=RUN, run_attempt=2, + ) + + def test_reject_changed_trx_bytes(self): + api = FakeApi() + api.blob = zip_archive(fixture(), TRX + b"malicious addition") + with self.assertRaisesRegex(consumer.ProofError, "TRX digest"): + run_once(api) + + def test_reject_nonpassing_trx_even_with_matching_hash(self): + bad = TRX.replace(b'failed="0"', b'failed="1"').replace(b'passed="1320"', b'passed="1319"') + with self.assertRaisesRegex(ValueError, "not entirely green"): + producer.counters_from_trx(bad) + + def test_reject_unsafe_archive_entry(self): + with self.assertRaisesRegex(consumer.ProofError, "unsafe"): + consumer.validate_artifact_archive( + zip_archive(fixture(), extra={"../../do-not-extract.txt": "unsafe"}), + merge_sha=SOURCE, engine_sha=ENGINE, + workflow_run_id=RUN, run_attempt=1, + ) + + def test_reject_duplicate_manifest(self): + blob = zip_archive( + fixture(), extra={"duplicate/ci-canonical-authority.json": json.dumps(fixture())} + ) + with self.assertRaisesRegex(consumer.ProofError, "duplicate"): + consumer.validate_artifact_archive( + blob, merge_sha=SOURCE, engine_sha=ENGINE, + workflow_run_id=RUN, run_attempt=1, + ) + + def test_latest_failed_canonical_run_must_reject_even_if_older_passed(self): + api = FakeApi(result="failure") + api.listing.append( + {"id": RUN - 1, "run_attempt": 1, "name": "Build ARSAS", + "head_sha": HEAD, "head_branch": BRANCH, "event": "pull_request", + "status": "completed", "conclusion": "success"} + ) + with self.assertRaisesRegex(consumer.ProofError, "Latest canonical.*failed"): + run_once(api) + + def test_pending_run_fails_closed_at_timeout(self): + with self.assertRaisesRegex(consumer.ProofError, "Timed out"): + run_once(FakeApi(run_status="in_progress", result=None)) + + def test_artifact_ready_mode_accepts_exact_all_pass_proof_while_packaging_runs(self): + proof = run_once( + FakeApi(run_status="in_progress", result=None), + allow_in_progress_artifact=True, + ) + self.assertEqual(proof["proofStage"], "full-regression-artifact-ready") + self.assertIs(proof["workflowCompleted"], False) + self.assertIs(proof["packagingSmokeProven"], False) + self.assertEqual(proof["canonicalRunStatus"], "in_progress") + self.assertEqual(proof["passed"], 1320) + + def test_artifact_ready_mode_still_rejects_completed_failed_run(self): + with self.assertRaisesRegex(consumer.ProofError, "Latest canonical.*failed"): + run_once( + FakeApi(run_status="completed", result="failure"), + allow_in_progress_artifact=True, + ) + + def test_artifact_ready_mode_waits_when_expected_artifact_is_not_uploaded(self): + with self.assertRaisesRegex(consumer.ProofError, "artifact-ready canonical"): + run_once( + FakeApi(run_status="in_progress", result=None, artifact_available=False), + allow_in_progress_artifact=True, + ) + + def test_main_push_accepts_exact_same_sha_artifact_ready_proof(self): + main_sha = "d" * 40 + api = FakeApi( + run_status="in_progress", + result=None, + event_name="push", + branch="main", + head_sha=main_sha, + source_sha=main_sha, + ) + proof = run_once( + api, + allow_in_progress_artifact=True, + event_name="push", + branch="main", + head_sha=main_sha, + source_sha=main_sha, + ) + self.assertEqual(proof["eventName"], "push") + self.assertEqual(proof["branch"], "main") + self.assertEqual(proof["mergeSha"], main_sha) + self.assertEqual(proof["proofStage"], "full-regression-artifact-ready") + self.assertFalse(proof["packagingSmokeProven"]) + + def test_main_push_rejects_pr_manifest_event_mismatch(self): + main_sha = "d" * 40 + api = FakeApi( + run_status="completed", + result="success", + event_name="push", + branch="main", + head_sha=main_sha, + source_sha=main_sha, + ) + api.blob = zip_archive(fixture(event_name="pull_request")) + with self.assertRaisesRegex(consumer.ProofError, "event mismatch"): + run_once( + api, + event_name="push", + branch="main", + head_sha=main_sha, + source_sha=main_sha, + ) + + def test_main_push_rejects_wrong_branch_or_sha(self): + main_sha = "d" * 40 + api = FakeApi( + event_name="push", + branch="main", + head_sha=main_sha, + source_sha=main_sha, + ) + with self.assertRaisesRegex(consumer.ProofError, "Timed out"): + run_once( + api, + event_name="push", + branch="release", + head_sha=main_sha, + source_sha=main_sha, + ) + + def test_different_pr_head_or_branch_fails_closed(self): + api = FakeApi() + api.listing[0]["head_sha"] = "d" * 40 + with self.assertRaisesRegex(consumer.ProofError, "Timed out"): + run_once(api) + + def test_wrong_artifact_type_is_not_accepted(self): + api = FakeApi() + api.get = lambda url, binary=False: ( + {"workflow_runs": api.listing} if "/workflows/build.yml/runs?" in url + else {"artifacts": []} + ) + with self.assertRaisesRegex(consumer.ProofError, "exactly one"): + run_once(api) + + def test_cross_host_redirect_strips_bearer_token(self): + handler = consumer.StripCrossOriginAuthorization() + request = urllib.request.Request( + "https://api.github.com/repos/masarray/arsas/actions/artifacts/42/zip", + headers={"Authorization": "Bearer example-token"} + ) + redirected = handler.redirect_request( + request, None, 302, "Found", {}, + "https://artifact.blob.core.windows.net/archive" + ) + self.assertIsNotNone(redirected) + self.assertIsNone(redirected.get_header("Authorization")) + + def test_untrusted_repository_rejected(self): + with self.assertRaisesRegex(consumer.ProofError, "restricted"): + consumer.validate_identity( + "untrusted/arsas", HEAD, SOURCE, ENGINE, BRANCH + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-ci-installer-reuse.py b/scripts/test-ci-installer-reuse.py new file mode 100644 index 000000000..ca03ec984 --- /dev/null +++ b/scripts/test-ci-installer-reuse.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Offline fail-closed fixtures for CI-P3D validated installer reuse.""" +from __future__ import annotations +import hashlib, importlib.util, io, json, tempfile, unittest, zipfile +from pathlib import Path + +ROOT=Path(__file__).resolve().parent + +def load(name, filename): + s=importlib.util.spec_from_file_location(name, ROOT/filename); assert s and s.loader + m=importlib.util.module_from_spec(s); s.loader.exec_module(m); return m +producer=load("installer_producer","write-ci-installer-manifest.py") +verifier=load("installer_verifier","verify-ci-installer-reuse.py") +S="a"*40; E="b"*40; A="c"*40; P="d"*64; RUN=777 + +def manifest(installer=b"installer"): + return { + "schemaVersion":1,"kind":"arsas-validated-windows-installer","version":"1.6.40", + "sourceSha":S,"engineSha":E,"ardirecSha":A,"canonicalPackageRun":"4321/1", + "canonicalPackageArtifactSha256":P,"installerWorkflow":"Validate ARSAS Windows installer", + "workflowRunId":RUN,"runAttempt":1,"eventName":"push", + "installerFile":"ARSAS-1.6.40-win-x64-setup.exe", + "installerSha256":hashlib.sha256(installer).hexdigest(),"installerSizeBytes":len(installer), + "installedSmokePassed":True,"releasePromotionAuthority":False} + +def archive(m=None, installer=b"installer", extra=None): + b=io.BytesIO() + with zipfile.ZipFile(b,"w") as z: + z.writestr("ARSAS-1.6.40-win-x64-setup.exe",installer) + z.writestr("ci-installer-authority.json",json.dumps(m or manifest(installer))) + z.writestr("SHA256SUMS.txt",b"fixture") + for k,v in (extra or {}).items(): z.writestr(k,v) + return b.getvalue() + +class FakeApi: + def __init__(self,status="completed",conclusion="success",binary_failures=0,binary_status=404): + self.status=status; self.conclusion=conclusion; self.blob=archive() + self.binary_failures=binary_failures; self.binary_status=binary_status + def get(self,url,*,binary=False): + if binary: + if self.binary_failures > 0: + self.binary_failures -= 1 + raise verifier.GitHubRequestError( + f"fixture HTTP {self.binary_status}", status=self.binary_status) + return self.blob + if "installer-windows.yml/runs?" in url: + return {"workflow_runs":[{"id":RUN,"run_attempt":1,"name":"Validate ARSAS Windows installer", + "event":"push","head_branch":"main","head_sha":S,"status":self.status,"conclusion":self.conclusion}]} + if "/artifacts" in url: + return {"artifacts":[{"id":9,"name":"ARSAS-1.6.40-win-x64-installer","expired":False, + "archive_download_url":"https://example.invalid/installer.zip"}]} + raise RuntimeError(url) + +class InstallerReuseTests(unittest.TestCase): + def validate(self, blob=None, **kw): + return verifier.validate_archive(blob or archive(),version="1.6.40",source_sha=S,engine_sha=E, + ardirec_sha=A,package_artifact_sha256=P,workflow_run_id=RUN,run_attempt=1,**kw) + def test_accepts_exact_installer(self): + with tempfile.TemporaryDirectory() as t: + p=self.validate(output_dir=Path(t)) + self.assertTrue((Path(t)/p["installerFile"]).is_file()); self.assertTrue(p["installedSmokePassed"]) + def test_rejects_tampered_installer(self): + with self.assertRaisesRegex(verifier.InstallerProofError,"SHA-256|size"): + self.validate(blob=archive(manifest(),b"tampered")) + def test_rejects_wrong_package_digest(self): + with self.assertRaisesRegex(verifier.InstallerProofError,"canonicalPackageArtifactSha256"): + verifier.validate_archive(archive(),version="1.6.40",source_sha=S,engine_sha=E,ardirec_sha=A, + package_artifact_sha256="e"*64,workflow_run_id=RUN,run_attempt=1) + def test_rejects_release_authority_claim(self): + m=manifest(); m["releasePromotionAuthority"]=True + with self.assertRaisesRegex(verifier.InstallerProofError,"releasePromotionAuthority"): self.validate(blob=archive(m)) + def test_rejects_unsafe_path(self): + with self.assertRaisesRegex(verifier.InstallerProofError,"unsafe"): + self.validate(blob=archive(extra={"../escape":b"x"})) + def test_latest_failed_run_is_hard_failure(self): + with self.assertRaisesRegex(verifier.InstallerProofError,"failed"): + verifier.verify(FakeApi(conclusion="failure"),repository="masarray/arsas",source_sha=S,version="1.6.40", + engine_sha=E,ardirec_sha=A,package_artifact_sha256=P,wait_seconds=0,poll_seconds=1,output_dir=None) + + def test_transient_archive_404_recovers_within_existing_deadline(self): + proof = verifier.verify( + FakeApi(binary_failures=1, binary_status=404), + repository="masarray/arsas", source_sha=S, version="1.6.40", + engine_sha=E, ardirec_sha=A, package_artifact_sha256=P, + wait_seconds=1, poll_seconds=1, output_dir=None) + self.assertTrue(proof["installedSmokePassed"]) + + def test_persistent_archive_404_times_out_fail_closed(self): + with self.assertRaisesRegex(verifier.InstallerProofError, "Timed out"): + verifier.verify( + FakeApi(binary_failures=100, binary_status=404), + repository="masarray/arsas", source_sha=S, version="1.6.40", + engine_sha=E, ardirec_sha=A, package_artifact_sha256=P, + wait_seconds=0, poll_seconds=1, output_dir=None) + + def test_non_transient_archive_error_is_hard_failure(self): + with self.assertRaises(verifier.GitHubRequestError): + verifier.verify( + FakeApi(binary_failures=1, binary_status=403), + repository="masarray/arsas", source_sha=S, version="1.6.40", + engine_sha=E, ardirec_sha=A, package_artifact_sha256=P, + wait_seconds=1, poll_seconds=1, output_dir=None) + def test_release_workflow_promotes_validated_installer_and_keeps_manual_compile(self): + w=(ROOT.parent/".github/workflows/release-windows.yml").read_text(encoding="utf-8") + for token in ("Promote exact validated Windows installer for release","verify-ci-installer-reuse.py", + "RELEASE_INSTALLER_AUTHORITY=validated-installer:","RELEASE_INSTALLER_SHA256", + "if: github.event_name == 'workflow_dispatch'","build-windows-installer.ps1", + "Smoke-test silent installer and uninstaller"): + self.assertIn(token,w) + +if __name__=="__main__": unittest.main() diff --git a/scripts/test-ci-native-ctest-proof.py b/scripts/test-ci-native-ctest-proof.py new file mode 100644 index 000000000..7f8b50970 --- /dev/null +++ b/scripts/test-ci-native-ctest-proof.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Offline CI-P3J-A native CTest evidence fail-closed fixtures.""" +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +SRC = Path(__file__).with_name("write-ci-native-ctest-proof.py") +spec = importlib.util.spec_from_file_location("native_ctest_proof", SRC) +assert spec and spec.loader +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +GOOD_JUNIT = """ + + + + +""" + +class NativeCTestProofTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.junit = self.root / "ctest.xml" + self.bridge = self.root / "ardirec_bridge.dll" + self.output = self.root / "ci-native-ctest-authority.json" + self.junit.write_text(GOOD_JUNIT, encoding="utf-8") + self.bridge.write_bytes(b"pinned-native-test-bridge") + self.args = dict( + junit=self.junit, bridge=self.bridge, source_sha="a" * 40, + engine_sha="b" * 40, ardirec_sha="c" * 40, run_id=123, + attempt=1, event="pull_request", + ) + + def tearDown(self): + self.tmp.cleanup() + + def seal(self): + return module.write_or_verify(self.output, verify_only=False, **self.args) + + def verify(self): + return module.write_or_verify(self.output, verify_only=True, **self.args) + + def test_exact_native_proof_passes_and_is_nonpromotional(self): + proof = self.seal() + self.assertEqual(proof["nativeTestsPassed"], 2) + self.assertEqual(proof["testNames"], ["BridgeAbi", "LocusGeometry"]) + self.assertFalse(proof["independentComtradeLaneReplaced"]) + self.assertFalse(proof["releasePromotionAuthority"]) + self.assertEqual(self.verify(), proof) + + def test_rejects_missing_native_tests(self): + self.junit.write_text('', encoding="utf-8") + with self.assertRaisesRegex(ValueError, "no test suites|empty"): + self.seal() + + def test_rejects_failed_or_skipped_ctest(self): + for snippet in ( + GOOD_JUNIT.replace('failures="0"', 'failures="1"'), + GOOD_JUNIT.replace('', + ''), + GOOD_JUNIT.replace('skipped="0"', 'skipped="1"'), + GOOD_JUNIT.replace('', + ''), + ): + with self.subTest(snippet=snippet[:80]): + self.junit.write_text(snippet, encoding="utf-8") + with self.assertRaisesRegex(ValueError, "nonzero|failing"): + self.seal() + + def test_rejects_counter_mismatch(self): + self.junit.write_text(GOOD_JUNIT.replace('tests="2"', 'tests="3"'), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "counter disagrees"): + self.seal() + + def test_rejects_duplicate_tests(self): + self.junit.write_text(GOOD_JUNIT.replace("LocusGeometry", "BridgeAbi"), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "duplicate"): + self.seal() + + def test_rejects_tampered_ctest_xml_and_bridge(self): + self.seal() + self.junit.write_text(GOOD_JUNIT.replace("BridgeAbi", "OtherTest"), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "does not match"): + self.verify() + self.junit.write_text(GOOD_JUNIT, encoding="utf-8") + self.bridge.write_bytes(b"tampered-bridge") + with self.assertRaisesRegex(ValueError, "does not match"): + self.verify() + + def test_rejects_stale_identity_or_tampered_manifest(self): + self.seal() + self.args["source_sha"] = "d" * 40 + with self.assertRaisesRegex(ValueError, "does not match"): + self.verify() + self.args["source_sha"] = "a" * 40 + proof = json.loads(self.output.read_text(encoding="utf-8")) + proof["nativeTestsPassed"] = 999 + self.output.write_text(json.dumps(proof), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "does not match"): + self.verify() + + def test_rejects_broken_or_unsafe_xml(self): + for value in ('not xml', ''): + with self.subTest(value=value): + self.junit.write_text(value, encoding="utf-8") + with self.assertRaisesRegex(ValueError, "valid XML|prohibited"): + self.seal() + + def test_canonical_workflow_captures_native_junit_without_replacing_comtrade(self): + root = Path(__file__).resolve().parents[1] + native = (root / "scripts/build-ardirec-bridge.ps1").read_text(encoding="utf-8") + build = (root / ".github/workflows/build.yml").read_text(encoding="utf-8") + comtrade = (root / ".github/workflows/comtrade-viewer-integration.yml").read_text(encoding="utf-8") + for token in ("CTestJunitPath", "--output-junit", "ctest --test-dir"): + self.assertIn(token, native) + for token in ("-CTestJunitPath", "write-ci-native-ctest-proof.py", + "ci-native-ctest-authority.json", "ctest.xml"): + self.assertIn(token, build) + for token in ("Build and test pinned native bridge", "build-ardirec-bridge.ps1", + "Enforce field-tested native-only ARSAS COMTRADE routing"): + self.assertIn(token, comtrade) + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-ci-native-shadow.py b/scripts/test-ci-native-shadow.py new file mode 100644 index 000000000..2fb9c2efc --- /dev/null +++ b/scripts/test-ci-native-shadow.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +"""Offline fail-closed and advisory CI-P3J-C native shadow fixtures.""" +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 +spec = importlib.util.spec_from_file_location( + "arsas_shadow", ROOT / "compare-ci-native-shadow.py" +) +assert spec and spec.loader +shadow = importlib.util.module_from_spec(spec) +spec.loader.exec_module(shadow) +spec2 = importlib.util.spec_from_file_location( + "arsas_native_proof_test", ROOT / "write-ci-native-ctest-proof.py" +) +assert spec2 and spec2.loader +native = importlib.util.module_from_spec(spec2) +spec2.loader.exec_module(native) + +HEAD, MERGE, ENGINE, ARDIREC = "d" * 40, "a" * 40, "b" * 40, "c" * 40 +BRANCH = "ci/p3j-c-native-shadow" +RUN = 1234 +XML = b'' + + +def artifact_bytes(*, source=MERGE, bridge=b"canonical-tested-binary", names=XML, + run_id=RUN, event="pull_request") -> tuple[bytes, str]: + proof = native.record_from_bytes( + junit_bytes=names, + bridge_sha256=hashlib.sha256(bridge).hexdigest(), + bridge_size=len(bridge), + source_sha=source, engine_sha=ENGINE, ardirec_sha=ARDIREC, + run_id=run_id, attempt=1, event=event, + ) + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as f: + f.writestr(shadow.JUNIT, names) + f.writestr(shadow.PROOF, json.dumps(proof).encode()) + return output.getvalue(), hashlib.sha256(output.getvalue()).hexdigest() + + +class FakeApi: + def __init__(self, *, blob, digest, run_status="completed", run_conclusion="success", + head=HEAD, source_branch=BRANCH, artifact=True, duplicate=False): + self.blob = blob + self.digest = digest + self.run_status = run_status + self.run_conclusion = run_conclusion + self.head = head + self.branch = source_branch + self.artifact = artifact + self.duplicate = duplicate + + def get(self, url: str, *, binary=False): + if binary: + return self.blob + if "/workflows/build.yml/runs?" in url: + return {"workflow_runs": [{ + "id": RUN, "run_attempt": 1, "name": "Build ARSAS", + "head_sha": self.head, "head_branch": self.branch, + "event": "pull_request", "status": self.run_status, + "conclusion": self.run_conclusion, + }]} + if "/artifacts?" in url: + entries = [] + if self.artifact: + entries = [{ + "id": 77, "name": shadow.ARTIFACT, + "expired": False, + "workflow_run": {"id": RUN}, + "digest": "sha256:" + self.digest, + "archive_download_url": "https://example.invalid/artifact.zip", + }] + if self.duplicate: + entries.append(dict(entries[0], id=78)) + return {"artifacts": entries} + raise RuntimeError("Unexpected fake API request: " + url) + + +class NativeShadowTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.local = self.root / "local-ctest.xml" + self.bridge = self.root / "native.dll" + self.local.write_bytes(XML) + self.bridge.write_bytes(b"canonical-tested-binary") + self.blob, self.digest = artifact_bytes() + self.kwargs = { + "head_sha": HEAD, "head_branch": BRANCH, + "source_sha": MERGE, "engine_sha": ENGINE, "ardirec_sha": ARDIREC, + "independent_junit": self.local, "independent_bridge": self.bridge, + "wait_seconds": 0, + } + + def tearDown(self): + self.tmp.cleanup() + + def test_same_exact_merge_and_native_bits_parity(self): + result = shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest), **self.kwargs + ) + self.assertTrue(result["shadowParityObserved"]) + self.assertTrue(result["fullCanonicalBuildPassed"]) + self.assertFalse(result["deduplicationAuthorized"]) + self.assertFalse(result["releasePromotionAuthority"]) + self.assertEqual(result["canonicalRunId"], RUN) + + def test_native_dll_mismatch_is_visible_not_approved(self): + self.bridge.write_bytes(b"non-identical-native-dll") + result = shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest), **self.kwargs + ) + self.assertFalse(result["shadowParityObserved"]) + self.assertFalse(result["bridgeBytesEqual"]) + + def test_native_test_inventory_mismatch_is_visible(self): + self.local.write_bytes(XML.replace(b"Locus", b"Other")) + result = shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest), **self.kwargs + ) + self.assertFalse(result["shadowParityObserved"]) + self.assertFalse(result["ctestInventoryEqual"]) + + def test_wrong_exact_source_or_engine_is_rejected(self): + for edits in ({"source_sha": "f" * 40}, + {"engine_sha": "f" * 40}, + {"ardirec_sha": "f" * 40}): + with self.subTest(edits=edits): + kw = dict(self.kwargs, **edits) + with self.assertRaisesRegex(shadow.ShadowError, "evidence"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest), **kw + ) + + def test_wrong_head_sha_is_not_matched_to_old_run(self): + kw = dict(self.kwargs, head_sha="f" * 40) + with self.assertRaisesRegex(shadow.ShadowError, "timeout"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest), **kw + ) + + def test_rejected_failed_latest_canonical_run(self): + with self.assertRaisesRegex(shadow.ShadowError, "Latest matching"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest, + run_status="completed", run_conclusion="failure"), + **self.kwargs, + ) + + def test_in_progress_artifact_is_provisional_not_promotion(self): + result = shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest, + run_status="in_progress", run_conclusion=None), + **self.kwargs, + ) + self.assertTrue(result["provisional"]) + self.assertFalse(result["fullCanonicalBuildPassed"]) + self.assertFalse(result["deduplicationAuthorized"]) + + def test_completed_success_is_required_for_final_shadow(self): + result = shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest), + **dict(self.kwargs, require_completed=True), + ) + self.assertTrue(result["fullCanonicalBuildPassed"]) + self.assertTrue(result["completedCanonicalRequired"]) + self.assertFalse(result["provisional"]) + self.assertTrue(result["shadowParityObserved"]) + + def test_in_progress_cannot_pass_final_shadow_even_if_bits_equal(self): + with self.assertRaisesRegex(shadow.ShadowError, "timeout"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest, + run_status="in_progress", run_conclusion=None), + **dict(self.kwargs, require_completed=True), + ) + + def test_final_shadow_rejects_failed_canonical_run(self): + with self.assertRaisesRegex(shadow.ShadowError, "Latest matching"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest, + run_status="completed", run_conclusion="failure"), + **dict(self.kwargs, require_completed=True), + ) + + def test_final_shadow_blocks_missing_artifact_even_after_build_success(self): + with self.assertRaisesRegex(shadow.ShadowError, "timeout"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest, artifact=False), + **dict(self.kwargs, require_completed=True), + ) + + def test_final_shadow_ignores_in_progress_then_checks_completed_run(self): + class CompletingApi(FakeApi): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.polls = 0 + self.artifact_before_completion = False + + def get(self, url: str, *, binary=False): + if "/workflows/build.yml/runs?" in url: + self.polls += 1 + self.run_status = "in_progress" if self.polls == 1 else "completed" + self.run_conclusion = None if self.polls == 1 else "success" + if "/artifacts?" in url and self.run_status != "completed": + self.artifact_before_completion = True + return super().get(url, binary=binary) + + api = CompletingApi(blob=self.blob, digest=self.digest) + result = shadow.compare_online( + api, + **dict(self.kwargs, require_completed=True, + wait_seconds=3, poll_seconds=1), + ) + self.assertGreaterEqual(api.polls, 2) + self.assertFalse(api.artifact_before_completion) + self.assertTrue(result["fullCanonicalBuildPassed"]) + self.assertFalse(result["provisional"]) + + def test_missing_digest_and_modified_archive_are_rejected(self): + with self.assertRaisesRegex(shadow.ShadowError, "metadata missing"): + shadow.parse_small_artifact(self.blob, "") + with self.assertRaisesRegex(shadow.ShadowError, "SHA-256"): + shadow.parse_small_artifact(self.blob, "sha256:" + "0" * 64) + + def test_extra_file_or_duplicate_artifact_is_rejected(self): + with zipfile.ZipFile(io.BytesIO(self.blob)) as source: + data = {x: source.read(x) for x in source.namelist()} + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as dest: + for name, value in data.items(): + dest.writestr(name, value) + dest.writestr("payload.exe", b"unexpected") + payload = buffer.getvalue() + with self.assertRaisesRegex(shadow.ShadowError, "extra evidence"): + shadow.parse_small_artifact( + payload, "sha256:" + hashlib.sha256(payload).hexdigest() + ) + with self.assertRaisesRegex(shadow.ShadowError, "Duplicate"): + shadow.compare_online( + FakeApi(blob=self.blob, digest=self.digest, duplicate=True), + **self.kwargs, + ) + + def test_ci_preserves_native_independent_ctest_and_advisory_only(self): + comtrade = (ROOT.parent / ".github/workflows/comtrade-viewer-integration.yml").read_text() + canonical = (ROOT.parent / ".github/workflows/build.yml").read_text() + for token in ("-CTestJunitPath", "compare-ci-native-shadow.py", + "continue-on-error: true", "Enforce field-tested native-only"): + self.assertIn(token, comtrade) + self.assertIn("ARSAS-native-ctest-shadow", canonical) + self.assertIn("actions/upload-artifact@v7", canonical) + self.assertIn("Build and test pinned native bridge", comtrade) + self.assertEqual(comtrade.count("name: Validate COMTRADE viewer integration"), 1) + self.assertEqual(comtrade.count("name: Advisory exact-SHA native comparison"), 1) + self.assertEqual(comtrade.count("name: Checkout immutable ArdIrec revision"), 1) + + self.assertIn("native-shadow-observer:", comtrade) + self.assertIn("needs: windows-viewer-integration", comtrade) + self.assertIn("runs-on: ubuntu-latest", comtrade) + self.assertIn("ARSAS-independent-native-ctest-shadow", comtrade) + self.assertIn("actions/upload-artifact@v7", comtrade) + self.assertIn("actions/download-artifact@v8", comtrade) + self.assertIn("if: github.event_name == 'pull_request'", comtrade) + self.assertIn("name: Build pinned ArdIrec native bridge", comtrade) + self.assertIn("name: Enforce field-tested native-only ARSAS COMTRADE routing", comtrade) + self.assertIn("name: Advisory exact-SHA native comparison", comtrade) + # There must be no second CMake/CTest outside the required Windows job. + before, observer = comtrade.split(" native-shadow-observer:", 1) + self.assertIn("build-ardirec-bridge.ps1", before) + self.assertNotIn("build-ardirec-bridge.ps1", observer) + self.assertNotIn("ctest --test-dir", observer) + self.assertIn("compare-ci-native-shadow.py", observer) + self.assertIn("--require-completed-canonical", observer) + self.assertIn("continue-on-error: true", observer) + self.assertEqual(comtrade.count("native-shadow-observer:"), 1) + self.assertIn("workflow_dispatch", before) + + self.assertIn("ARIEC61850_COMMIT=$($engineLock.commit)", comtrade) + self.assertIn("engines\\ARIEC61850.lock.json", comtrade) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-ci-package-reuse.py b/scripts/test-ci-package-reuse.py new file mode 100644 index 000000000..a4e632b96 --- /dev/null +++ b/scripts/test-ci-package-reuse.py @@ -0,0 +1,790 @@ +#!/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, PurePosixPath + +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") +native = load("arsas_native_ctest", "write-ci-native-ctest-proof.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") + + portable_identity = root / "ARSAS-1.6.40-win-x64-portable-build-identity.json" + portable_identity.write_text( + json.dumps( + { + "schemaVersion": 1, + "kind": "arsas-portable-build-identity", + "version": "1.6.40", + "runtime": "win-x64", + "sourceCommit": SOURCE, + "engineCommit": ENGINE, + "ardIrecLockCommit": ARDIREC, + "ardIrecBridgeSha256": hashlib.sha256(bridge.read_bytes()).hexdigest(), + "ardIrecBridgeSizeBytes": bridge.stat().st_size, + "portableSha256": hashlib.sha256(portable.read_bytes()).hexdigest(), + "portableSizeBytes": portable.stat().st_size, + "deterministicManagedBuild": True, + "reproducibleNativeLinkRequested": True, + }, + sort_keys=True, + ) + + "\n", + 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") + (fixtures / "p1-release-smoke.cfg").write_text("release-cfg", encoding="utf-8") + (fixtures / "p1-release-smoke.dat").write_bytes(b"release-dat") + + # CI-P3J-B: native evidence must be bound to the tested bridge and run. + native_dir = verification / "native" + native_dir.mkdir() + native_junit = native_dir / "ctest.xml" + native_junit.write_text( + '' + '' + '' + '', + encoding="utf-8", + ) + native_record = native.expected_proof( + junit=native_junit, bridge=bridge, source_sha=SOURCE, engine_sha=ENGINE, + ardirec_sha=ARDIREC, run_id=RUN, attempt=1, event=event_name, + ) + (native_dir / "ci-native-ctest-authority.json").write_text( + json.dumps(native_record, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + + artifact = root / "artifact" + manifest = producer.stage_and_create_manifest( + artifact_root=artifact, + portable=portable, + portable_identity=portable_identity, + 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", + release_fixture_relative="fixtures/p1-release-smoke.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 + + +def update_sealed_fixture( + blob: bytes, *, updates: dict[str, bytes | None] +) -> bytes: + """Mutate a test archive AND recalculate its file manifest digests.""" + with zipfile.ZipFile(io.BytesIO(blob)) as source: + names = source.namelist() + raw = {info.filename: source.read(info) for info in source.infolist()} + manifest_name = next(name for name in names if name.endswith("/ci-windows-package-authority.json")) + manifest = json.loads(raw[manifest_name]) + for name, data in updates.items(): + archive_name = next(item for item in names if item.endswith(name)) + if data is None: + raw.pop(archive_name) + manifest["files"] = [ + f for f in manifest["files"] if not archive_name.endswith(f["path"]) + ] + else: + raw[archive_name] = data + entry = next(f for f in manifest["files"] if archive_name.endswith(f["path"])) + entry["size"] = len(data) + entry["sha256"] = hashlib.sha256(data).hexdigest() + manifest["fileCount"] = len(manifest["files"]) + manifest["totalBytes"] = sum(x["size"] for x in manifest["files"]) + raw[manifest_name] = (json.dumps(manifest, sort_keys=True) + "\n").encode() + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as dest: + for name, data in raw.items(): + dest.writestr(name, data) + return buffer.getvalue() + + +class FakeApi: + def __init__( + self, + *, + event_name: str = "pull_request", + status: str = "completed", + conclusion: str | None = "success", + artifact_available: bool = True, + binary_404_count: int = 0, + head_sha: str = HEAD, + branch: str = BRANCH, + ): + self.blob, _ = make_payload(event_name) + self.artifact_available = artifact_available + self.binary_404_count = binary_404_count + self.runs = [{ + "id": RUN, + "run_attempt": 1, + "name": "Build ARSAS", + "head_sha": head_sha, + "head_branch": branch, + "event": event_name, + "status": status, + "conclusion": conclusion, + }] + + def get(self, url: str, *, binary: bool = False): + if binary: + if self.binary_404_count > 0: + self.binary_404_count -= 1 + raise verifier.GitHubRequestError( + "GitHub package evidence request failed: HTTP 404 Not Found", + status=404, + ) + 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["portablePath"], manifest["portable"]["path"]) + self.assertEqual( + proof["portableIdentityPath"], + manifest["portableIdentity"]["path"], + ) + self.assertEqual(proof["nativeBridgeSha256"], manifest["nativeBridge"]["sha256"]) + self.assertEqual( + proof["releaseFixturePath"], + manifest["verification"]["releaseFixturePath"], + ) + + def test_native_archive_verifier_requires_real_ctest_evidence(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", + require_native_ctest_proof=True, + ) + self.assertTrue(proof["nativeCtestProofAvailable"]) + self.assertTrue(proof["nativeCtestPassed"]) + self.assertEqual(proof["nativeTestNames"], ["NativeBridgeAbi", "NativeLocus"]) + self.assertEqual(proof["nativeBridgeSha256"], manifest["nativeBridge"]["sha256"]) + + def test_missing_ctest_pair_is_legacy_only_not_authoritative(self): + blob, _ = make_payload() + blob = update_sealed_fixture( + blob, updates={ + "verification/native/ctest.xml": None, + "verification/native/ci-native-ctest-authority.json": None, + }, + ) + legacy = 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.assertFalse(legacy["nativeCtestProofAvailable"]) + with self.assertRaisesRegex(verifier.PackageProofError, "Required native CTest"): + 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", + require_native_ctest_proof=True, + ) + + def test_half_present_native_proof_rejected_even_in_legacy_mode(self): + blob, _ = make_payload() + blob = update_sealed_fixture( + blob, updates={"verification/native/ctest.xml": None}, + ) + with self.assertRaisesRegex(verifier.PackageProofError, "pair is incomplete"): + 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", + ) + + def test_tampered_native_identity_is_rejected_even_with_resealed_file_hash(self): + blob, _ = make_payload() + with zipfile.ZipFile(io.BytesIO(blob)) as archive: + raw = next( + archive.read(x) for x in archive.namelist() + if x.endswith("verification/native/ci-native-ctest-authority.json") + ) + for key, value in ( + ("sourceSha", "f" * 40), + ("engineSha", "f" * 40), + ("ardirecSha", "f" * 40), + ("workflowRunId", 999), + ("nativeBridgeSha256", "0" * 64), + ("nativeCTestPassed", False), + ("releasePromotionAuthority", True), + ("independentComtradeLaneReplaced", True), + ): + with self.subTest(key=key): + value_json = json.loads(raw) + value_json[key] = value + edited = update_sealed_fixture( + blob, + updates={ + "verification/native/ci-native-ctest-authority.json": + json.dumps(value_json).encode(), + }, + ) + with self.assertRaisesRegex(verifier.PackageProofError, "Native CTest archive proof"): + verifier.validate_package_archive( + edited, source_sha=SOURCE, engine_sha=ENGINE, + ardirec_sha=ARDIREC, workflow_run_id=RUN, + run_attempt=1, event_name="pull_request", + ) + + def test_failing_native_junit_rejected_even_when_file_digest_resealed(self): + blob, _ = make_payload() + with zipfile.ZipFile(io.BytesIO(blob)) as archive: + raw = next( + archive.read(x) for x in archive.namelist() + if x.endswith("verification/native/ctest.xml") + ) + edited = update_sealed_fixture( + blob, + updates={ + "verification/native/ctest.xml": + raw.replace(b'failures="0"', b'failures="1"'), + }, + ) + with self.assertRaisesRegex(verifier.PackageProofError, "Native CTest archive proof"): + verifier.validate_package_archive( + edited, source_sha=SOURCE, engine_sha=ENGINE, + ardirec_sha=ARDIREC, workflow_run_id=RUN, + run_attempt=1, event_name="pull_request", + ) + + def test_strict_current_consumers_keep_manual_fallback_intact(self): + root = ROOT.parent + installer = (root / ".github/workflows/installer-windows.yml").read_text( + encoding="utf-8" + ) + capture = (root / ".github/workflows/smart-discovery-capture-build.yml").read_text( + encoding="utf-8" + ) + release = (root / ".github/workflows/release-windows.yml").read_text( + encoding="utf-8" + ) + for workflow in (installer, capture): + self.assertIn("--require-native-ctest-proof", workflow) + self.assertIn("--allow-in-progress-artifact", workflow) + self.assertIn("workflow_dispatch", installer) + self.assertIn("workflow_dispatch", capture) + self.assertNotIn("--require-native-ctest-proof", release) + + 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_tampered_portable_build_identity(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("portable-build-identity.json"): + identity = json.loads(data) + identity["sourceCommit"] = "f" * 40 + data = json.dumps(identity, sort_keys=True).encode() + dst.writestr(info.filename, data) + with self.assertRaisesRegex( + verifier.PackageProofError, + "size differs|digest differs|Portable build identity", + ): + 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_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_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) + 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_release_workflow_promotes_exact_sealed_package_and_keeps_manual_fallback(self): + workflow = (ROOT.parent / ".github/workflows/release-windows.yml").read_text( + encoding="utf-8" + ) + required = ( + "Promote exact sealed canonical Windows package for release", + "verify-ci-package-reuse.py", + "--head-branch main", + "--event-name push", + "--allow-in-progress-artifact", + "proof.releaseFixturePath", + "RELEASE_PACKAGE_AUTHORITY=sealed-build-arsas:", + "RELEASE_INSTALLER_INPUT_DIR", + "RELEASE_PORTABLE_PATH", + "-PublishedDirectory $env:RELEASE_INSTALLER_INPUT_DIR", + "-TestAssemblyPath $env:RELEASE_TEST_ASSEMBLY", + "& dotnet vstest $env:RELEASE_TEST_ASSEMBLY", + "canonicalPackageArtifactSha256", + "canonicalPortableSha256", + "Release source mismatch", + ) + for token in required: + self.assertIn(token, workflow) + + manual_only = ( + "Restore, build and test exact release source for manual fallback", + "Publish installer source folder for manual fallback", + "Publish real portable single EXE for manual fallback", + ) + for token in manual_only: + self.assertIn(token, workflow) + self.assertNotIn( + "- name: Restore, build and test exact release source\n shell:", + 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_push_main_exact_source_package_can_be_reused_for_release(self): + api = FakeApi( + event_name="push", + status="completed", + conclusion="success", + head_sha=SOURCE, + branch="main", + ) + proof = verifier.verify_canonical_package( + api, + repository="masarray/arsas", + branch="main", + head_sha=SOURCE, + source_sha=SOURCE, + engine_sha=ENGINE, + ardirec_sha=ARDIREC, + event_name="push", + wait_seconds=0, + poll_seconds=1, + allow_in_progress_artifact=True, + ) + self.assertEqual(proof["sourceSha"], SOURCE) + self.assertEqual(proof["canonicalRunStatus"], "completed") + self.assertEqual(proof["proofStage"], "completed-workflow") + + def test_transient_artifact_archive_404_is_retried_without_fallback(self): + api = FakeApi( + status="in_progress", + conclusion=None, + binary_404_count=2, + ) + original_sleep = verifier.time.sleep + try: + verifier.time.sleep = lambda _: 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=2, + poll_seconds=1, + allow_in_progress_artifact=True, + ) + finally: + verifier.time.sleep = original_sleep + self.assertEqual(proof["proofStage"], "sealed-package-artifact-ready") + self.assertEqual(api.binary_404_count, 0) + + def test_persistent_artifact_archive_404_times_out_fail_closed(self): + api = FakeApi( + status="in_progress", + conclusion=None, + binary_404_count=100, + ) + with self.assertRaisesRegex( + verifier.PackageProofError, + "Timed out waiting for exact canonical Windows package artifact", + ): + 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_non_404_artifact_download_error_remains_hard_failure(self): + api = FakeApi(status="in_progress", conclusion=None) + original_get = api.get + + def failing_get(url: str, *, binary: bool = False): + if binary: + raise verifier.GitHubRequestError( + "GitHub package evidence request failed: HTTP 403 Forbidden", + status=403, + ) + return original_get(url, binary=binary) + + api.get = failing_get + with self.assertRaisesRegex(verifier.GitHubRequestError, "HTTP 403"): + 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_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, + ) + + + def test_installer_package_ready_gate_only_schedules_windows_authority(self): + workflow = (ROOT.parent / ".github/workflows/installer-windows.yml").read_text( + encoding="utf-8" + ) + self.assertIn(" package-ready:", workflow) + self.assertIn(" needs: [package-ready]", workflow) + self.assertIn("runs-on: ubuntu-latest", workflow) + self.assertIn("Wait for exact Build ARSAS package artifact metadata", workflow) + self.assertIn("ARSAS-windows-package-input", workflow) + self.assertIn( + "Latest exact Build ARSAS run failed before package readiness", + workflow, + ) + + gate = workflow.split(" package-ready:", 1)[1].split("\n installer:", 1)[0] + self.assertNotIn("archive_download_url", gate) + self.assertNotIn("verify-ci-package-reuse.py", gate) + self.assertIn( + "package bytes remain untrusted until Windows verifier completes", + gate, + ) + + installer = workflow.split("\n installer:", 1)[1] + self.assertIn("verify-ci-package-reuse.py", installer) + self.assertIn("--allow-in-progress-artifact", installer) + self.assertIn("Compile installer from exact tested payload", installer) + self.assertIn("Silent install and field-runtime smoke test", installer) + self.assertIn( + "github.event_name == 'workflow_dispatch' ||", + installer, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-ci-release-ready.py b/scripts/test-ci-release-ready.py new file mode 100644 index 000000000..9e9567b60 --- /dev/null +++ b/scripts/test-ci-release-ready.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Offline fail-closed fixtures for CI-P3G release readiness.""" +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +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 + + +gate = load("release_ready_gate", "wait-ci-release-ready.py") +S = "a" * 40 +E = "b" * 40 +A = "c" * 40 +BUILD = 111 +INSTALLER = 222 +VERSION = "1.6.40" + + +def run_obj(run_id: int, name: str, status: str = "completed", conclusion: str | None = "success"): + return { + "id": run_id, + "run_attempt": 1, + "name": name, + "event": "push", + "head_branch": "main", + "head_sha": S, + "status": status, + "conclusion": conclusion, + } + + +class FakeApi: + def __init__( + self, + *, + build_status="completed", + build_conclusion="success", + installer_status="completed", + installer_conclusion="success", + package_count=1, + installer_count=1, + ): + self.build = run_obj(BUILD, "Build ARSAS", build_status, build_conclusion) + self.installer = run_obj( + INSTALLER, + "Validate ARSAS Windows installer", + installer_status, + installer_conclusion, + ) + self.package_count = package_count + self.installer_count = installer_count + + def get(self, url: str): + if "workflows/build.yml/runs?" in url: + return {"workflow_runs": [self.build]} + if "workflows/installer-windows.yml/runs?" in url: + return {"workflow_runs": [self.installer]} + if f"/runs/{BUILD}/artifacts" in url: + return { + "artifacts": [ + { + "id": 300 + i, + "name": "ARSAS-windows-package-input", + "expired": False, + "workflow_run": {"id": BUILD}, + } + for i in range(self.package_count) + ] + } + if f"/runs/{INSTALLER}/artifacts" in url: + return { + "artifacts": [ + { + "id": 400 + i, + "name": f"ARSAS-{VERSION}-win-x64-installer", + "expired": False, + "workflow_run": {"id": INSTALLER}, + } + for i in range(self.installer_count) + ] + } + raise RuntimeError(url) + + +def identity(): + return { + "sourceSha": S, + "version": VERSION, + "engineSha": E, + "ardirecSha": A, + "refType": "branch", + "refName": "main", + } + + +class ReleaseReadyTests(unittest.TestCase): + def test_accepts_only_when_package_and_completed_installer_metadata_are_ready(self): + proof = gate.wait_release_ready( + FakeApi(), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + self.assertTrue(proof["metadataReady"]) + self.assertFalse(proof["releasePromotionAuthority"]) + self.assertEqual(proof["buildRunId"], BUILD) + self.assertEqual(proof["installerRunId"], INSTALLER) + self.assertEqual(proof["packageArtifactId"], 300) + self.assertEqual(proof["installerArtifactId"], 400) + + def test_completed_failed_build_is_immediate_hard_failure(self): + with self.assertRaisesRegex(gate.ReleaseReadyError, "Build ARSAS run failed"): + gate.wait_release_ready( + FakeApi(build_conclusion="failure"), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + + def test_completed_failed_installer_is_immediate_hard_failure(self): + with self.assertRaisesRegex(gate.ReleaseReadyError, "installer validation failed"): + gate.wait_release_ready( + FakeApi(installer_conclusion="failure"), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + + def test_in_progress_installer_is_not_ready(self): + with self.assertRaisesRegex(gate.ReleaseReadyError, "Timed out"): + gate.wait_release_ready( + FakeApi(installer_status="in_progress", installer_conclusion=None), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + + def test_missing_package_is_not_ready_while_build_runs(self): + with self.assertRaisesRegex(gate.ReleaseReadyError, "Timed out"): + gate.wait_release_ready( + FakeApi( + build_status="in_progress", + build_conclusion=None, + package_count=0, + installer_status="in_progress", + installer_conclusion=None, + ), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + + def test_duplicate_package_or_installer_metadata_fails_closed(self): + with self.assertRaisesRegex(gate.ReleaseReadyError, "exactly one"): + gate.wait_release_ready( + FakeApi(package_count=2), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + with self.assertRaisesRegex(gate.ReleaseReadyError, "exactly one"): + gate.wait_release_ready( + FakeApi(installer_count=2), + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + + def test_wrong_head_branch_or_event_never_matches(self): + api = FakeApi() + api.build["head_branch"] = "release" + api.installer["event"] = "workflow_dispatch" + with self.assertRaisesRegex(gate.ReleaseReadyError, "Timed out"): + gate.wait_release_ready( + api, + repository="masarray/arsas", + identity=identity(), + wait_seconds=0, + poll_seconds=1, + ) + + def test_identity_resolution_binds_main_manifest_version_and_locks(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / ".release").mkdir() + (root / "engines").mkdir() + (root / "VERSION").write_text(VERSION + "\n", encoding="utf-8") + (root / "Directory.Build.props").write_text( + f"{VERSION}", + encoding="utf-8", + ) + (root / "ArIED61850Tester.csproj").write_text( + f"{VERSION}", + encoding="utf-8", + ) + (root / ".release/windows.json").write_text( + json.dumps({"version": VERSION}), encoding="utf-8" + ) + (root / "engines/ARIEC61850.lock.json").write_text( + json.dumps( + { + "repository": "masarray/ARIEC61850", + "ref": "main", + "commit": E, + } + ), + encoding="utf-8", + ) + (root / "engines/ARDIREC.lock.json").write_text( + json.dumps( + { + "schema": 3, + "repository": "masarray/ardirec", + "ref": "feature/test", + "commit": A, + "bridge": {"abi": 1, "mode": "native-only"}, + } + ), + encoding="utf-8", + ) + resolved = gate.resolve_identity( + root, source_sha=S, ref_type="branch", ref_name="main" + ) + self.assertEqual(resolved["version"], VERSION) + self.assertEqual(resolved["engineSha"], E) + self.assertEqual(resolved["ardirecSha"], A) + + def test_release_workflow_defers_windows_but_retains_full_authority_verifiers(self): + workflow = (ROOT.parent / ".github/workflows/release-windows.yml").read_text( + encoding="utf-8" + ) + for token in ( + "release-ready:", + "runs-on: ubuntu-latest", + "wait-ci-release-ready.py", + "needs: [release-ready]", + "github.event_name == 'workflow_dispatch'", + "verify-ci-package-reuse.py", + "verify-ci-installer-reuse.py", + "Smoke-test exact release portable single EXE", + "Smoke-test silent installer and uninstaller", + "Generate SPDX 2.3 package SBOM", + "Create or update GitHub Release", + ): + self.assertIn(token, workflow) + self.assertIn( + "authority: scheduling metadata only; Windows release revalidates all artifact bytes", + workflow, + ) + + def test_tag_identity_must_match_canonical_version(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / ".release").mkdir() + (root / "engines").mkdir() + (root / "VERSION").write_text(VERSION, encoding="utf-8") + xml = f"{VERSION}" + (root / "Directory.Build.props").write_text(xml, encoding="utf-8") + (root / "ArIED61850Tester.csproj").write_text(xml, encoding="utf-8") + (root / ".release/windows.json").write_text( + json.dumps({"version": VERSION}), encoding="utf-8" + ) + (root / "engines/ARIEC61850.lock.json").write_text( + json.dumps({"repository": "masarray/ARIEC61850", "ref": "main", "commit": E}), + encoding="utf-8", + ) + (root / "engines/ARDIREC.lock.json").write_text( + json.dumps( + { + "schema": 3, + "repository": "masarray/ardirec", + "commit": A, + "bridge": {"abi": 1, "mode": "native-only"}, + } + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(gate.ReleaseReadyError, "version identity mismatch"): + gate.resolve_identity( + root, source_sha=S, ref_type="tag", ref_name="v9.9.9" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-ci-workflows.py b/scripts/test-ci-workflows.py new file mode 100644 index 000000000..1cb6bf9d9 --- /dev/null +++ b/scripts/test-ci-workflows.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Offline tests for the CI-P0 workflow inventory and integrity guard.""" +from __future__ import annotations + +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +SOURCE = Path(__file__).with_name("audit-ci-workflows.py") +spec = importlib.util.spec_from_file_location("arsas_ci_inventory", SOURCE) +assert spec and spec.loader +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class CiWorkflowInventoryTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + subprocess.run(["git", "-C", str(self.root), "init", "-q"], check=True) + + def tearDown(self): + self.tmp.cleanup() + + def write(self, path: str, body: str) -> None: + dest = self.root / path + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(body, encoding="utf-8") + + def commit(self) -> None: + subprocess.run(["git", "-C", str(self.root), "add", "-A"], check=True) + subprocess.run( + [ + "git", + "-C", + str(self.root), + "-c", + "user.email=test@example.invalid", + "-c", + "user.name=CI P0 Test", + "commit", + "-qm", + "synthetic fixture", + ], + check=True, + ) + + def test_inventory_counts_expensive_primitives_deterministically(self): + self.write( + ".github/workflows/check.yml", + """name: Check +on: + pull_request: + workflow_dispatch: +jobs: + build: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup + uses: actions/setup-dotnet@v6 + - name: Build + run: | + dotnet restore App.sln + dotnet build App.sln --no-restore + dotnet test Tests.csproj --no-build +""", + ) + self.commit() + + first = module.inventory(self.root) + second = module.inventory(self.root) + self.assertEqual( + json.dumps(first, sort_keys=True), + json.dumps(second, sort_keys=True), + ) + self.assertEqual(first["totals"]["workflowFiles"], 1) + self.assertEqual(first["totals"]["actionsCheckout"], 1) + self.assertEqual(first["totals"]["actionsSetupDotnet"], 1) + self.assertEqual(first["totals"]["dotnetRestore"], 1) + self.assertEqual(first["totals"]["dotnetBuild"], 1) + self.assertEqual(first["totals"]["dotnetTest"], 1) + self.assertEqual(first["totals"]["workflowsWithDotnetBuild"], 1) + self.assertEqual(first["totals"]["workflowsWithDotnetTest"], 1) + self.assertEqual(first["totals"]["pullRequestWorkflows"], 1) + self.assertEqual(first["structureErrors"], []) + + def test_duplicate_step_name_inside_same_job_is_rejected(self): + self.write( + ".github/workflows/corrupt.yml", + """name: Corrupt +on: + pull_request: +jobs: + build: + runs-on: windows-latest + steps: + - name: Setup .NET + run: echo first + - name: Setup .NET + run: echo duplicate +""", + ) + self.commit() + + report = module.inventory(self.root) + self.assertFalse(not report["structureErrors"]) + self.assertTrue( + any("duplicate step name 'Setup .NET'" in item for item in report["structureErrors"]) + ) + + def test_same_step_name_in_different_jobs_is_allowed(self): + self.write( + ".github/workflows/parallel.yml", + """name: Parallel +on: [push, pull_request] +jobs: + first: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + second: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 +""", + ) + self.commit() + + report = module.inventory(self.root) + self.assertEqual(report["structureErrors"], []) + self.assertEqual(report["totals"]["actionsCheckout"], 2) + + def test_duplicate_job_id_is_rejected(self): + self.write( + ".github/workflows/duplicate-job.yml", + """name: Duplicate Job +on: + push: +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: First + run: echo first + validate: + runs-on: windows-latest + steps: + - name: Second + run: echo second +""", + ) + self.commit() + + report = module.inventory(self.root) + self.assertTrue( + any("duplicate job id 'validate'" in item for item in report["structureErrors"]) + ) + + def test_p3h_windows_pr_workflows_cancel_only_superseded_pr_runs(self): + repo = Path(__file__).resolve().parents[1] + workflows = [ + ".github/workflows/build.yml", + ".github/workflows/installer-windows.yml", + ".github/workflows/smart-discovery-mainline-readiness.yml", + ".github/workflows/rcb-export-guard.yml", + ".github/workflows/validate-sv-evidence.yml", + ".github/workflows/validate-io-testing.yml", + ".github/workflows/comtrade-viewer-integration.yml", + ".github/workflows/smart-discovery-golden-provenance.yml", + ".github/workflows/smart-discovery-golden-budget-lock.yml", + ".github/workflows/smart-discovery-repeat-run-stability.yml", + ] + expected_group = ( + "group: ${{ github.workflow }}-" + "${{ github.event_name == 'pull_request' && " + "github.event.pull_request.number || github.run_id }}" + ) + expected_cancel = "cancel-in-progress: true" + for relative in workflows: + source = (repo / relative).read_text(encoding="utf-8") + self.assertIn(expected_group, source, relative) + self.assertIn(expected_cancel, source, relative) + + release = (repo / ".github/workflows/release-windows.yml").read_text( + encoding="utf-8" + ) + self.assertNotIn(expected_group, release) + + def test_p3i_main_and_manual_verification_runs_are_not_superseded(self): + """PR updates may cancel old PR work; main and manual runs must keep their proof.""" + repo = Path(__file__).resolve().parents[1] + isolated = { + "ci-p0-workflow-integrity.yml": "ci-p0-workflow-integrity", + "smart-discovery-capture-build.yml": "smart-discovery-field-capture", + "scl-interoperability-r7.yml": "scl-interoperability-r7", + "smart-discovery-merge-execution-guard.yml": "smart-discovery-merge-execution", + "smart-discovery-production-promotion.yml": "smart-discovery-production-promotion", + "interoperability-reference-guard.yml": "interoperability-reference", + } + pr_or_unique_run = ( + "${{ github.event_name == 'pull_request' && " + "github.event.pull_request.number || github.run_id }}" + ) + for filename, prefix in isolated.items(): + source = (repo / ".github/workflows" / filename).read_text( + encoding="utf-8" + ) + self.assertEqual(source.count("\nconcurrency:\n"), 1, filename) + block = source.split("\nconcurrency:\n", 1)[1].split("\njobs:\n", 1)[0] + self.assertIn(f"group: {prefix}-{pr_or_unique_run}", block, filename) + self.assertIn("cancel-in-progress: true", block, filename) + self.assertNotIn("|| github.ref", block, filename) + + post_merge = ( + repo / ".github/workflows/smart-discovery-post-merge-production.yml" + ).read_text(encoding="utf-8") + block = post_merge.split("\nconcurrency:\n", 1)[1].split( + "\njobs:\n", 1 + )[0] + self.assertIn( + "group: smart-discovery-post-merge-production-${{ github.run_id }}", + block, + ) + self.assertIn("cancel-in-progress: false", block) + self.assertNotIn("github.ref", block) + + def test_budget_rejects_new_duplicate_build_cost(self): + self.write( + ".github/workflows/build.yml", + """name: Build +on: + pull_request: +jobs: + build: + runs-on: windows-latest + steps: + - name: Build + run: dotnet build App.sln +""", + ) + self.write( + "budget.json", + json.dumps( + { + "schemaVersion": 1, + "baselineSourceCommit": "synthetic", + "limits": {"dotnetBuild": 0}, + } + ), + ) + self.commit() + + report = module.evaluate(self.root, self.root / "budget.json") + self.assertFalse(report["isHealthy"]) + self.assertIn( + "CI budget exceeded: dotnetBuild=1, allowed maximum=0.", + report["budget"]["violations"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-classify-ci-changes.py b/scripts/test-classify-ci-changes.py new file mode 100644 index 000000000..71f420be8 --- /dev/null +++ b/scripts/test-classify-ci-changes.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Offline policy/regression tests for advisory CI-P1 PR classification.""" +from __future__ import annotations + +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +SOURCE = Path(__file__).with_name("classify-ci-changes.py") +spec = importlib.util.spec_from_file_location("arsas_p1_classifier", SOURCE) +assert spec and spec.loader +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class ClassifierTests(unittest.TestCase): + def test_trusted_scl_change_requires_full_tests_and_iec(self): + r = module.classify(["Services/NativeIec61850Client.SclAssisted.cs"]) + self.assertTrue(r["flags"]["build"]) + self.assertTrue(r["flags"]["tests"]) + self.assertTrue(r["flags"]["iec"]) + self.assertFalse(r["flags"]["sv"]) + self.assertTrue(r["requiresLegacyAuthority"]) + + def test_sv_and_goose_change_is_process_bus(self): + r = module.classify(["Services/SmvSnapshotCaptureService.cs"]) + self.assertTrue(r["flags"]["sv"]) + self.assertTrue(r["flags"]["build"]) + r2 = module.classify(["Services/GooseSubscriberRuntime.cs"]) + self.assertTrue(r2["flags"]["sv"]) + + def test_fat_io_change_is_targeted(self): + r = module.classify(["Services/IoTesting/IoFatOrchestrator.cs"]) + self.assertTrue(r["flags"]["io"]) + self.assertTrue(r["flags"]["tests"]) + self.assertFalse(r["flags"]["iec"]) + + def test_engine_lock_change_is_broad(self): + r = module.classify(["engines/ARIEC61850.lock.json"]) + self.assertEqual(r["risk"], "broad") + self.assertTrue(all(r["flags"][name] for name in ("build", "tests", "iec", "io", "sv"))) + + def test_pure_documentation_skips_dotnet_advisory(self): + r = module.classify(["docs/audits/OTHER.md", "README.md"]) + self.assertFalse(r["flags"]["build"]) + self.assertEqual(r["risk"], "none") + self.assertEqual(r["areas"], ["docs"]) + + def test_ci_change_detected_separately_from_application(self): + r = module.classify([".github/workflows/ci-p1-shadow.yml"]) + self.assertTrue(r["flags"]["ci"]) + self.assertFalse(r["flags"]["build"]) + self.assertFalse(r["requiresLegacyAuthority"]) + + def test_scripts_outside_ci_infrastructure_are_conservative(self): + r = module.classify(["scripts/publish-windows-portable.ps1"]) + self.assertTrue(r["flags"]["build"]) + + def test_unknown_extension_fails_open_to_full_build(self): + r = module.classify(["config/custom-device.assetbin"]) + self.assertTrue(r["flags"]["unknown"]) + self.assertTrue(r["flags"]["iec"]) + self.assertTrue(r["flags"]["tests"]) + self.assertEqual(r["risk"], "broad") + + def test_empty_paths_do_not_silently_skip(self): + r = module.classify([]) + self.assertTrue(r["flags"]["unknown"]) + self.assertTrue(r["flags"]["build"]) + + def test_dedup_and_order_are_deterministic(self): + paths = ["README.md", "Services/MmsReporting.cs", "README.md"] + self.assertEqual( + json.dumps(module.classify(paths), sort_keys=True), + json.dumps(module.classify(list(reversed(paths))), sort_keys=True), + ) + self.assertEqual(len(module.classify(paths)["changedFiles"]), 2) + + def test_pr_merge_diff_uses_first_parent_and_falls_back_if_absent(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + def git(*args: str) -> None: + subprocess.run(["git", "-C", str(root), *args], check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + git("init", "-q") + git("config", "user.name", "CI Test") + git("config", "user.email", "test@example.invalid") + (root / "README.md").write_text("base\n") + git("add", "-A") + git("commit", "-qm", "base") + self.assertEqual( + module.merge_diff(root), ["__unclassified_source_change__"] + ) + base_branch = subprocess.check_output( + ["git", "-C", str(root), "branch", "--show-current"] + ).decode("utf-8").strip() + git("checkout", "-qb", "feature") + (root / "Services").mkdir() + (root / "Services" / "MmsReporting.cs").write_text("// change\n") + git("add", "-A") + git("commit", "-qm", "feature") + git("checkout", "-q", base_branch) + git("merge", "--no-ff", "-qm", "merge feature", "feature") + self.assertEqual( + module.merge_diff(root), ["Services/MmsReporting.cs"] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-compare-p10-brcb-sessions.py b/scripts/test-compare-p10-brcb-sessions.py new file mode 100644 index 000000000..50661570e --- /dev/null +++ b/scripts/test-compare-p10-brcb-sessions.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Regress fail-closed cross-session BRCB evidence comparisons.""" +import importlib.util +import unittest +from pathlib import Path + +PATH = Path(__file__).with_name("compare-p10-brcb-sessions.py") +spec = importlib.util.spec_from_file_location("p108d", PATH) +mod = importlib.util.module_from_spec(spec) +import sys +sys.modules["p108d"] = mod +spec.loader.exec_module(mod) + +FP = "439d4dc0507019533b154ed68f7f4007f39f6be92b5cd3f413ff3b84f619727e" +ENGINE = "00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1" + +def report(ied="BCUGE", fp=FP, engine=ENGINE, slot="GW01", + dataset="BCUGEF650/LLN0.CSWI", frames=25, overflow=1, with_chronology=True): + return f"""ARSAS Diagnostic Report +Engine version : 1.6.40+{engine} + IED : {ied} + Discovery : LiveDiscovery: fingerprint={fp}, requested=67 + stream[0] : BRCB, rcb=BCUGEF650/LLN0.BR.{slot}, dataset={dataset}, rptId=BRB01, frames={frames}, SqNum=25, findings=2, BufOvfl={overflow} + RCB triage : anomalousFrames=1, sqDiscontinuities=1, sqChangedEntryID=1, sqWithGIreason=0 + RCB follow-up : receivedAfterLastFinding=3, firstFrameUtc=2026-10-11T06:25:51Z + RCB lifecycle : RptEnaWrite=YES, GIWrite=YES, startReturnUtc=2026-10-11T06:25:50Z, GIreasonFrames=1, lastAnomalyUtc=2026-10-11T06:25:51Z + BRCB chronology: first=2, recent=0, omittedMiddle=0 +{(" frame#1: receivedUtc=unknown, SqNum=4, GIreason=NO, BufOvfl=NO, EntryIDrelation=unverified, findings=0" + chr(10)) if with_chronology else ""} OptFlds : decoded=25/25 + stream[1] : URCB, rcb=BCUGEF650/LLN0.RP.URB01, dataset=MMXU, rptId=URB01, frames=1, SqNum=1, findings=0 +""" + +class TestComparator(unittest.TestCase): + def test_exact_same_slot_only_maintains_not_soe_qualified(self): + result = mod.compare(mod.parse_report(report()), mod.parse_report(report(frames=43))) + self.assertEqual("SAME_SLOT_OBSERVATION_ONLY", result["status"]) + self.assertEqual("NOT_WIRE_ORDER_PROOF; NOT_SOE_LOSSLESS; NOT_R10_PHYSICAL_ACCEPTANCE", + result["qualification"]) + self.assertEqual(25, result["slots"][0]["left_frames"]) + self.assertEqual(43, result["slots"][0]["right_frames"]) + self.assertEqual(1, result["slots"][0]["left_chronology_frames"]) + + def test_different_slots_do_not_allow_false_comparison(self): + with self.assertRaisesRegex(ValueError, "different BRCB slots"): + mod.compare(mod.parse_report(report(slot="GW01")), + mod.parse_report(report(slot="SOGI101"))) + + def test_different_semantics_fail_closed(self): + with self.assertRaisesRegex(ValueError, "fingerprints"): + mod.compare(mod.parse_report(report()), + mod.parse_report(report(fp="f" * 64))) + + def test_engine_mismatch_fail_closed(self): + with self.assertRaisesRegex(ValueError, "engine commits"): + mod.compare(mod.parse_report(report()), + mod.parse_report(report(engine="f" * 40))) + + def test_ied_mismatch_fail_closed(self): + with self.assertRaisesRegex(ValueError, "IED identities"): + mod.compare(mod.parse_report(report()), + mod.parse_report(report(ied="WRONG"))) + + def test_different_datasets_fail_closed(self): + with self.assertRaisesRegex(ValueError, "different DataSet"): + mod.compare(mod.parse_report(report()), + mod.parse_report(report(dataset="BCUGEF650/LLN0.EVEGGIO"))) + + def test_missing_provenance_rejected(self): + with self.assertRaisesRegex(ValueError, "missing IED"): + mod.parse_report("stream[0] : BRCB, rcb=a, dataset=b, frames=1") + + def test_duplicate_stream_rejected(self): + data = report() + line = next(l for l in data.splitlines() if "stream[0]" in l) + with self.assertRaisesRegex(ValueError, "duplicate RCB"): + mod.parse_report(data + line.replace("stream[0]", "stream[9]") + "\n") + + def test_same_slot_overflow_counts_are_observable_not_loss_estimates(self): + result = mod.compare(mod.parse_report(report(overflow=1)), + mod.parse_report(report(frames=43, overflow=3))) + slot = result["slots"][0] + self.assertEqual(1, slot["left_bufovfl_frames"]) + self.assertEqual(3, slot["right_bufovfl_frames"]) + self.assertIn("NOT_SOE_LOSSLESS", result["qualification"]) + + def test_brcb_missing_overflow_evidence_fails_closed(self): + truncated = report().replace(", BufOvfl=1", "") + with self.assertRaisesRegex(ValueError, "missing BRCB BufOvfl count"): + mod.parse_report(truncated) + + def test_brcb_overflow_count_cannot_exceed_decoded_frames(self): + with self.assertRaisesRegex(ValueError, "exceeds received frames"): + mod.parse_report(report(frames=2, overflow=3)) + + def test_clean_brcb_zero_overflow_remains_comparable(self): + result = mod.compare(mod.parse_report(report(overflow=0)), + mod.parse_report(report(overflow=0))) + self.assertEqual(0, result["slots"][0]["left_bufovfl_frames"]) + self.assertEqual(0, result["slots"][0]["right_bufovfl_frames"]) + + def test_independent_triage_different_real_slots_is_explicitly_unpaired(self): + data = mod.independent_triage(mod.parse_report(report(slot="GW01")), + mod.parse_report(report(slot="SOGI101", frames=43, overflow=3))) + self.assertEqual("INDEPENDENT_SESSIONS_OBSERVATION_ONLY", data["status"]) + self.assertFalse(data["same_slot_dataset_scope"]) + self.assertEqual("different BRCB slots or DataSets", data["paired_comparison_blocked_reason"]) + self.assertNotIn("slots", data) + self.assertEqual(1, data["sessions"]["open_scl"][0]["bufovfl_true_frames"]) + self.assertEqual(3, data["sessions"]["discovery"][0]["bufovfl_true_frames"]) + self.assertIn("NOT_PAIRED_RELIABILITY_COMPARISON", data["qualification"]) + + def test_independent_triage_same_slots_does_not_establish_losslessness(self): + data = mod.independent_triage(mod.parse_report(report()), mod.parse_report(report())) + self.assertTrue(data["same_slot_dataset_scope"]) + self.assertIn("NOT_SOE_LOSSLESS", data["qualification"]) + + def test_gi_context_and_monotonic_startup_delay_are_observational(self): + one = mod.independent_triage(mod.parse_report(report()), mod.parse_report(report())) + stream = one["sessions"]["open_scl"][0] + self.assertEqual(1, stream["sq_without_gi_reason"]) + self.assertEqual(1, stream["gi_reason_frames"]) + self.assertEqual(1000.0, stream["last_anomaly_ms_after_start_return"]) + self.assertEqual(3, stream["received_after_last_finding"]) + + def test_missing_gi_and_lifecycle_are_unknown_not_zero(self): + data = report().replace(", sqWithGIreason=0", "") + data = "\n".join(x for x in data.splitlines() if "RCB lifecycle" not in x) + row = mod.independent_triage(mod.parse_report(data), mod.parse_report(data))["sessions"]["open_scl"][0] + self.assertIsNone(row["sq_with_gi_reason"]) + self.assertIsNone(row["sq_without_gi_reason"]) + self.assertIsNone(row["last_anomaly_ms_after_start_return"]) + + def test_impossible_gi_discontinuity_counts_are_rejected(self): + data = report().replace("sqWithGIreason=0", "sqWithGIreason=2") + with self.assertRaisesRegex(ValueError, "impossible BRCB diagnostic counts"): + mod.independent_triage(mod.parse_report(data), mod.parse_report(report())) + + def test_independent_triage_mismatched_engine_still_fails_closed(self): + with self.assertRaisesRegex(ValueError, "different engine commits"): + mod.independent_triage(mod.parse_report(report()), + mod.parse_report(report(engine="f" * 40))) + + def _full_chronology_report(self): + data = report(frames=4, overflow=1, with_chronology=False) + data = data.replace("BRCB chronology: first=2, recent=0, omittedMiddle=0", + "BRCB chronology: first=4, recent=0, omittedMiddle=0") + data = data.replace(" OptFlds : decoded=25/25", """ frame#1: receivedUtc=2026-10-11T06:25:51Z, SqNum=10, GIreason=NO, BufOvfl=YES, EntryIDrelation=unverified, findings=1 + frame#2: receivedUtc=2026-10-11T06:25:51.100Z, SqNum=11, GIreason=NO, BufOvfl=NO, EntryIDrelation=changed, findings=0 + frame#3: receivedUtc=2026-10-11T06:26:11Z, SqNum=1, GIreason=NO, BufOvfl=NO, EntryIDrelation=changed, findings=1 + frame#4: receivedUtc=2026-10-11T06:26:17Z, SqNum=2, GIreason=NO, BufOvfl=NO, EntryIDrelation=changed, findings=0 + OptFlds : decoded=25/25""") + return data + + def test_late_non_gi_recurrence_after_full_receive_gap(self): + sample = self._full_chronology_report() + triage = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample)) + evidence = triage["sessions"]["open_scl"][0]["receive_order_gap_evidence"] + self.assertEqual("COMPLETE_BOUNDED_DECODER_RECEIVE_ORDER", evidence["coverage"]) + self.assertEqual(19900.0, evidence["longest_receive_gap_ms"]) + anomaly = evidence["non_gi_backward_finding_after_gap"] + self.assertEqual(3, anomaly["frame"]) + self.assertEqual((11, 1), (anomaly["previous_sqnum"], anomaly["sqnum"])) + self.assertEqual(19900.0, anomaly["gap_ms"]) + self.assertEqual(21000.0, anomaly["elapsed_since_start_return_ms"]) + self.assertIn("NOT_SOE_LOSSLESS", triage["qualification"]) + + def test_truncated_chronology_never_invents_receive_gap(self): + sample = self._full_chronology_report() + sample = "\n".join(line for line in sample.splitlines() if "frame#2:" not in line) + evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] + self.assertEqual("UNVERIFIABLE", evidence["coverage"]) + self.assertEqual("RETAINED_CHRONOLOGY_INCOMPLETE", evidence["reason"]) + self.assertIsNone(evidence["longest_receive_gap_ms"]) + self.assertIsNone(evidence["non_gi_backward_finding_after_gap"]) + + def test_omitted_middle_never_certifies_gaps(self): + sample = self._full_chronology_report() + sample = sample.replace("omittedMiddle=0", "omittedMiddle=1") + evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] + self.assertEqual("CHRONOLOGY_HAS_OMITTED_OR_UNACCOUNTED_FRAMES", evidence["reason"]) + + def test_out_of_order_or_duplicate_frame_ids_do_not_certify_receive_order(self): + sample = self._full_chronology_report() + sample = sample.replace("frame#3:", "frame#2:") + evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] + self.assertEqual("RECEIVE_FRAME_INDICES_NOT_CONTIGUOUS", evidence["reason"]) + + def test_nonmonotonic_timestamps_do_not_certify_receive_order(self): + sample = self._full_chronology_report() + sample = sample.replace("frame#3: receivedUtc=2026-10-11T06:26:11Z", + "frame#3: receivedUtc=2026-10-11T06:25:50Z") + evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] + self.assertEqual("NONMONOTONIC_DECODER_RECEIVE_TIMESTAMPS", evidence["reason"]) + + def test_gi_reason_only_and_unverified_fixture_do_not_claim_non_gi_recurrence(self): + sample = self._full_chronology_report() + sample = sample.replace("frame#3: receivedUtc=2026-10-11T06:26:11Z, SqNum=1, GIreason=NO", + "frame#3: receivedUtc=2026-10-11T06:26:11Z, SqNum=1, GIreason=YES") + evidence = mod.independent_triage(mod.parse_report(sample), mod.parse_report(sample))["sessions"]["open_scl"][0]["receive_order_gap_evidence"] + self.assertEqual("COMPLETE_BOUNDED_DECODER_RECEIVE_ORDER", evidence["coverage"]) + self.assertIsNone(evidence["non_gi_backward_finding_after_gap"]) + old = mod.independent_triage(mod.parse_report(report()), mod.parse_report(report())) + self.assertEqual("UNVERIFIABLE", old["sessions"]["open_scl"][0]["receive_order_gap_evidence"]["coverage"]) + + def test_empty_chronology_allowed_but_does_not_invent_events(self): + result = mod.compare(mod.parse_report(report(with_chronology=False)), + mod.parse_report(report(with_chronology=False))) + self.assertEqual(0, result["slots"][0]["left_chronology_frames"]) + self.assertIn("NOT_WIRE_ORDER_PROOF", result["qualification"]) + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-p10-9f-core-routing-trial.ps1 b/scripts/test-p10-9f-core-routing-trial.ps1 new file mode 100644 index 000000000..2019e2253 --- /dev/null +++ b/scripts/test-p10-9f-core-routing-trial.ps1 @@ -0,0 +1,151 @@ +# PowerShell 5.1/7 compatible. Code-verification does NOT grant physical acceptance. +# Exact audited code-only candidates (P10.9F/G/I/J/K); no wildcard SHA or runtime overrides. +function Test-P109FCoreRoutingTrial { + param([Parameter(Mandatory = $true)] [object] $Lock) + $candidate = $Lock.coreReportRoutingTrial + $subscriber = $Lock.reportSubscriberIntegrityTrial + $previousHead = ( + [string]$Lock.commit -ceq 'fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7' -and + [int]$Lock.sourcePullRequest -eq 164 + ) + # P10.9G is a precisely scoped successor to the P10.9F code-only pin, + # NOT a promotion to physical or mainline acceptance. + $subscriberIntegrityValid = ( + $null -ne $subscriber -and + [string]$subscriber.status -ceq 'code-verified-not-physical' -and + [int]$subscriber.issue -eq 532 -and + [int]$subscriber.enginePullRequest -eq 165 -and + [string]$subscriber.baseEngineCommit -ceq 'fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7' -and + [string]$subscriber.exactCommit -ceq 'd700b0df741b466afb8abe6cee1d70b1619d7b4d' -and + [long]$subscriber.engineCiRun -eq 38180732078 -and + [bool]$subscriber.physicalQualificationRequired -and + [bool]$subscriber.mainlinePromotionForbidden -and + [bool]$subscriber.fullDiscoveryFallbackForbidden -and + [bool]$subscriber.cyclicMmsProcessPollingForbidden + ) + $subscriberHead = ( + [string]$Lock.commit -ceq 'd700b0df741b466afb8abe6cee1d70b1619d7b4d' -and + [int]$Lock.sourcePullRequest -eq 165 -and $subscriberIntegrityValid + ) + # P10.9I is allowed only as the exact successor to the independently + # qualified P10.9G engine record; neither entry grants physical authority. + $canonical = $Lock.canonicalReportIngressIntegrityTrial + $canonicalIntegrityValid = ( + $subscriberIntegrityValid -and + $null -ne $canonical -and + [string]$canonical.status -ceq 'code-verified-not-physical' -and + [int]$canonical.issue -eq 536 -and + [int]$canonical.enginePullRequest -eq 166 -and + [string]$canonical.baseEngineCommit -ceq 'd700b0df741b466afb8abe6cee1d70b1619d7b4d' -and + [string]$canonical.exactCommit -ceq '9bd01908ebd6fdf50a74f0a421b4e79613b878d4' -and + [long]$canonical.engineCiRun -eq 38214165576 -and + [bool]$canonical.physicalQualificationRequired -and + [bool]$canonical.mainlinePromotionForbidden -and + [bool]$canonical.fullDiscoveryFallbackForbidden -and + [bool]$canonical.cyclicMmsProcessPollingForbidden + ) + $canonicalHead = ( + [string]$Lock.commit -ceq '9bd01908ebd6fdf50a74f0a421b4e79613b878d4' -and + [int]$Lock.sourcePullRequest -eq 166 -and $canonicalIntegrityValid + ) + # P10.9J is admitted only if P10.9I still satisfies *every* explicit + # historical guard; neither the valid OID notation nor BSC metadata + # grants production/physical authority. + $ziv = $Lock.zivSclBscInteroperabilityTrial + $zivIntegrityValid = ( + $canonicalIntegrityValid -and $null -ne $ziv -and + [string]$ziv.status -ceq 'code-verified-not-physical' -and + [int]$ziv.issue -eq 538 -and + [int]$ziv.enginePullRequest -eq 167 -and + [string]$ziv.baseEngineCommit -ceq '9bd01908ebd6fdf50a74f0a421b4e79613b878d4' -and + [string]$ziv.exactCommit -ceq '67c6635b7e16139c0661e559a2e6946527c1442a' -and + [long]$ziv.engineCiRun -eq 38219429896 -and + [bool]$ziv.sclApTitlePackedPrefixOnly -and + [bool]$ziv.bscLiveSemanticGateRequired -and + [bool]$ziv.physicalQualificationRequired -and + [bool]$ziv.mainlinePromotionForbidden -and + [bool]$ziv.fullDiscoveryFallbackForbidden -and + [bool]$ziv.cyclicMmsProcessPollingForbidden + ) + $zivHead = ( + [string]$Lock.commit -ceq '67c6635b7e16139c0661e559a2e6946527c1442a' -and + [int]$Lock.sourcePullRequest -eq 167 -and $zivIntegrityValid + ) + # P10.9K does not replace any earlier physical/provenance gate. + $generic = $Lock.genericControlInteroperabilityTrial + $genericHead = ( + [string]$Lock.commit -ceq 'a43b9b4864a9565c02f544d4135fe157c2fac577' -and + [int]$Lock.sourcePullRequest -eq 168 -and + $zivIntegrityValid -and $null -ne $generic -and + [string]$generic.status -ceq 'code-verified-not-physical' -and + [int]$generic.issue -eq 540 -and + [int]$generic.enginePullRequest -eq 168 -and + [string]$generic.baseEngineCommit -ceq [string]$ziv.exactCommit -and + [string]$generic.exactCommit -ceq 'a43b9b4864a9565c02f544d4135fe157c2fac577' -and + [long]$generic.engineCiRun -eq 38231327892 -and + [bool]$generic.typedSemanticGateRequired -and + [bool]$generic.associationScopedInventoryRequired -and + [bool]$generic.vendorDispatchForbidden -and + [bool]$generic.physicalQualificationRequired -and + [bool]$generic.mainlinePromotionForbidden -and + [bool]$generic.fullDiscoveryFallbackForbidden -and + [bool]$generic.cyclicMmsProcessPollingForbidden + ) + if (-not ($previousHead -or $subscriberHead -or $canonicalHead -or $zivHead -or $genericHead) -or + [string]$Lock.repository -cne 'masarray/ARIEC61850' -or + [string]$Lock.ref -cne 'main' -or $null -eq $candidate) { return $false } + + # A code-only candidate cannot be interpreted as physical authority, + # including CI builds targeting or running on main. + foreach ($ref in @($env:PR_BASE_REF, $env:SOURCE_REF_NAME, + $env:GITHUB_BASE_REF, $env:GITHUB_REF_NAME)) { + if ([string]$ref -ceq 'main' -or [string]$ref -ceq 'refs/heads/main') { + return $false + } + } + $p10 = $Lock.p10IdentityTrial + $closure = $Lock.datasetFirstDiscoveryTrial + $association = $Lock.sclAssociationInteroperability + $historical = $Lock.smartInteroperabilityCodeTrial + $serverAt = $Lock.sclServerAtTrial + if ($null -eq $p10 -or $null -eq $closure -or $null -eq $association -or + $null -eq $historical -or $null -eq $serverAt) { return $false } + + return ( + [string]$candidate.status -ceq 'code-verified-not-physical' -and + [int]$candidate.issue -eq 531 -and + [int]$candidate.simulatorPullRequest -eq 163 -and + [int]$candidate.corePullRequest -eq 164 -and + [string]$candidate.baseEngineCommit -ceq '00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1' -and + [string]$candidate.simulatorFifoCommit -ceq 'ef0fe44f3b40e47dc18b1aac4476b635e67d531e' -and + [string]$candidate.exactCommit -ceq 'fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7' -and + [long]$candidate.simulatorCiRun -eq 38141582981 -and + [long]$candidate.coreCiRun -eq 38142115574 -and + [bool]$candidate.physicalQualificationRequired -and + [bool]$candidate.mainlinePromotionForbidden -and + [bool]$candidate.fullDiscoveryFallbackForbidden -and + [bool]$candidate.cyclicMmsProcessPollingForbidden -and + [string]$p10.exactCommit -ceq [string]$candidate.baseEngineCommit -and + [string]$p10.exactCommit -ceq '00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1' -and + [int]$p10.enginePullRequest -eq 161 -and + [string]$p10.baseEngineCommit -ceq [string]$closure.exactCommit -and + [string]$p10.identitySource -ceq 'TrustedSclExactDomainMatch' -and + [string]$p10.status -ceq 'code-trial-not-physical' -and + [bool]$p10.physicalQualificationRequired -and + [bool]$p10.fullNameListFallbackForbidden -and + [bool]$p10.cyclicMmsProcessPollingForbidden -and + [string]$closure.exactCommit -ceq '8b0c0f7e695b016834e40a6f068994dcc945cb1a' -and + [string]$closure.baseEngineCommit -ceq [string]$serverAt.exactCommit -and + [string]$serverAt.exactCommit -ceq '352c81e6a798635c6addcee0683235ca87ad416d' -and + [string]$serverAt.baseEngineCommit -ceq [string]$historical.exactCommit -and + [string]$historical.exactCommit -ceq '9c5292570f55dd81be1b3a6b56f937e5ba1ed276' -and + [string]$association.testedEngineCommit -ceq '84e9820e5a32690475960e49d5ef74e6637847fd' -and + [string]$association.mergedEngineCommit -ceq 'e5deed1d8aa11d97991695c6e390baafea7ab797' -and + [string]$association.contractId -ceq 'SCL-ASSOC-PARTIAL-ADDR-PHYSICAL' -and + [bool]$association.preservesR10DiscoveryModelBaseline -and + [bool]$association.fullDiscoveryFallbackForbidden -and + [bool]$association.cyclicMmsProcessPollingForbidden -and + [string]$Lock.mergedMainCommit -ceq '648124097621046f5f127ceb1cf853fea54db730' -and + [string]$Lock.previousStablePin.commit -ceq [string]$Lock.mergedMainCommit + ) +} diff --git a/scripts/test-resolve-ariec-lock.ps1 b/scripts/test-resolve-ariec-lock.ps1 new file mode 100644 index 000000000..7de2db225 --- /dev/null +++ b/scripts/test-resolve-ariec-lock.ps1 @@ -0,0 +1,171 @@ +#Requires -Version 7.0 +# Pure synthetic resolver tests. Never contacts a network endpoint or alters repo locks. +$ErrorActionPreference = 'Stop' +$resolver = Join-Path $PSScriptRoot 'resolve-ariec-lock.ps1' +$temp = Join-Path ([System.IO.Path]::GetTempPath()) ("arsas-ci-p1-resolver-" + [guid]::NewGuid().ToString('N')) +New-Item -Path $temp -ItemType Directory -Force | Out-Null +$checks = 0 + +function Assert-Resolves { + param([hashtable]$Data) + $script:checks++ + $path = Join-Path $temp ("valid-$script:checks.json") + ConvertTo-Json -InputObject $Data -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8 + $result = & $resolver -LockPath $path + if ([string]$result.commit -cne [string]$Data.commit) { + throw "CI-P1 resolver valid fixture did not preserve integration SHA." + } +} + +function Assert-Rejects { + param([hashtable]$Data, [string]$Expected) + $script:checks++ + $path = Join-Path $temp ("invalid-$script:checks.json") + ConvertTo-Json -InputObject $Data -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8 + $rejected = $false + try { + $null = & $resolver -LockPath $path + } + catch { + if ($_.Exception.Message -notlike "*$Expected*") { + throw "CI-P1 resolver returned an unexpected error: $($_.Exception.Message)" + } + $rejected = $true + } + if (-not $rejected) { + throw "CI-P1 resolver accepted a forbidden fixture: $Expected" + } +} + +try { + $stable = @{ + schemaVersion = 1 + repository = 'masarray/ARIEC61850' + ref = 'main' + commit = ('a' * 40) + physicalTestedCommit = ('b' * 40) + mergedMainCommit = ('a' * 40) + } + Assert-Resolves $stable + + $candidate = $stable.Clone() + $candidate.commit = ('c' * 40) + $candidate.previousStablePin = @{ commit = ('a' * 40) } + $candidate.sclAssociationInteroperability = @{ + testedEngineCommit = ('c' * 40) + mergedEngineCommit = ('d' * 40) + } + Assert-Resolves $candidate + + $wrongRepository = $stable.Clone() + $wrongRepository.repository = 'untrusted/ARIEC61850' + Assert-Rejects $wrongRepository 'repository' + + $wrongSha = $stable.Clone() + $wrongSha.commit = 'main' + Assert-Rejects $wrongSha 'immutable SHA' + + $wrongRef = $stable.Clone() + $wrongRef.ref = 'feature' + Assert-Rejects $wrongRef 'ref' + + $wrongHistorical = $candidate.Clone() + $wrongHistorical.previousStablePin = @{ commit = ('f' * 40) } + Assert-Rejects $wrongHistorical 'historical' + + $wrongAssociation = $candidate.Clone() + $wrongAssociation.sclAssociationInteroperability = @{ + testedEngineCommit = ('f' * 40) + } + Assert-Rejects $wrongAssociation 'physical-tested' + + # Exact P10 lineage can be resolved as CODE TRIAL without converting + # the older physically accepted association head into a false runtime pin. + function New-TrackedP10Fixture { + $tracked = Join-Path $PSScriptRoot '..\engines\ARIEC61850.lock.json' + $source = Get-Content -LiteralPath $tracked -Raw -Encoding UTF8 | + ConvertFrom-Json -AsHashtable -Depth 40 + $copy = @{} + foreach ($key in $source.Keys) { $copy[$key] = $source[$key] } + return $copy + } + + Assert-Resolves (New-TrackedP10Fixture) + + $wrongCanonicalCi = New-TrackedP10Fixture + $wrongCanonicalCi.canonicalReportIngressIntegrityTrial.engineCiRun = 0 + Assert-Rejects $wrongCanonicalCi 'physical-tested' + + $wrongCanonicalParent = New-TrackedP10Fixture + $wrongCanonicalParent.canonicalReportIngressIntegrityTrial.baseEngineCommit = ('f' * 40) + Assert-Rejects $wrongCanonicalParent 'physical-tested' + + $wrongCanonicalSafety = New-TrackedP10Fixture + $wrongCanonicalSafety.canonicalReportIngressIntegrityTrial.mainlinePromotionForbidden = $false + Assert-Rejects $wrongCanonicalSafety 'physical-tested' + + $wrongZivSha = New-TrackedP10Fixture + $wrongZivSha.zivSclBscInteroperabilityTrial.exactCommit = ('f' * 40) + Assert-Rejects $wrongZivSha 'physical-tested' + + $wrongZivCi = New-TrackedP10Fixture + $wrongZivCi.zivSclBscInteroperabilityTrial.engineCiRun = 0 + Assert-Rejects $wrongZivCi 'physical-tested' + + $wrongZivBase = New-TrackedP10Fixture + $wrongZivBase.zivSclBscInteroperabilityTrial.baseEngineCommit = ('f' * 40) + Assert-Rejects $wrongZivBase 'physical-tested' + + $wrongZivScl = New-TrackedP10Fixture + $wrongZivScl.zivSclBscInteroperabilityTrial.sclApTitlePackedPrefixOnly = $false + Assert-Rejects $wrongZivScl 'physical-tested' + + $wrongGenericSha = New-TrackedP10Fixture + $wrongGenericSha.genericControlInteroperabilityTrial.exactCommit = ('f' * 40) + Assert-Rejects $wrongGenericSha 'physical-tested' + + $wrongGenericCi = New-TrackedP10Fixture + $wrongGenericCi.genericControlInteroperabilityTrial.engineCiRun = 0 + Assert-Rejects $wrongGenericCi 'physical-tested' + + $wrongGenericParent = New-TrackedP10Fixture + $wrongGenericParent.genericControlInteroperabilityTrial.baseEngineCommit = ('f' * 40) + Assert-Rejects $wrongGenericParent 'physical-tested' + + $wrongGenericSafety = New-TrackedP10Fixture + $wrongGenericSafety.genericControlInteroperabilityTrial.typedSemanticGateRequired = $false + Assert-Rejects $wrongGenericSafety 'physical-tested' + + $wrongGenericInventory = New-TrackedP10Fixture + $wrongGenericInventory.genericControlInteroperabilityTrial.associationScopedInventoryRequired = $false + Assert-Rejects $wrongGenericInventory 'physical-tested' + + $wrongGenericVendor = New-TrackedP10Fixture + $wrongGenericVendor.genericControlInteroperabilityTrial.vendorDispatchForbidden = $false + Assert-Rejects $wrongGenericVendor 'physical-tested' + + $wrongP10Parent = New-TrackedP10Fixture + $wrongP10Parent.p10IdentityTrial.baseEngineCommit = ('f' * 40) + Assert-Rejects $wrongP10Parent 'physical-tested' + + $wrongP10Source = New-TrackedP10Fixture + $wrongP10Source.sourcePullRequest = 160 + Assert-Rejects $wrongP10Source 'physical-tested' + + $wrongP10Polling = New-TrackedP10Fixture + $wrongP10Polling.p10IdentityTrial.cyclicMmsProcessPollingForbidden = $false + Assert-Rejects $wrongP10Polling 'physical-tested' + + $wrongP10History = New-TrackedP10Fixture + $wrongP10History.sclAssociationInteroperability.testedEngineCommit = ('f' * 40) + Assert-Rejects $wrongP10History 'physical-tested' + + $wrongP10Pin = New-TrackedP10Fixture + $wrongP10Pin.commit = ('f' * 40) + Assert-Rejects $wrongP10Pin 'physical-tested' + + Write-Host "CI-P1 immutable engine resolver: $checks synthetic cases PASS." +} +finally { + Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/test-verify-p10-ge-field-scl.py b/scripts/test-verify-p10-ge-field-scl.py new file mode 100644 index 000000000..894dbbee3 --- /dev/null +++ b/scripts/test-verify-p10-ge-field-scl.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Offline P10.4 GE field SCL gate regression: no hardware or network required.""" +import hashlib +import importlib.util +import tempfile +import unittest +from pathlib import Path +from xml.etree import ElementTree as ET + +SCRIPT = Path(__file__).with_name("verify-p10-ge-field-scl.py") +spec = importlib.util.spec_from_file_location("p10_ge_gate", SCRIPT) +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) +N = gate.NS +T = gate.TAG + + +def example(*, ip="192.16.1.33", ied="BCUGE", inst="F650", + ap="S1", wire_ld_name=None, members=124, remove_ln=False): + ET.register_namespace("", N) + root = ET.Element(T("SCL")) + comm = ET.SubElement(root, T("Communication")) + subnet = ET.SubElement(comm, T("SubNetwork")) + conn = ET.SubElement(subnet, T("ConnectedAP"), + {"iedName": ied, "apName": ap}) + address = ET.SubElement(conn, T("Address")) + ET.SubElement(address, T("P"), {"type": "IP"}).text = ip + node = ET.SubElement(root, T("IED"), {"name": ied}) + access = ET.SubElement(node, T("AccessPoint"), {"name": ap}) + server = ET.SubElement(access, T("Server")) + attrs = {"inst": inst} + if wire_ld_name: + attrs["ldName"] = wire_ld_name + ld = ET.SubElement(server, T("LDevice"), attrs) + ln = ET.SubElement(ld, T("LN0"), {"lnClass": "LLN0"}) + if not remove_ln: + ET.SubElement(ld, T("LN"), {"lnClass": "CSWI", "inst": "7"}) + for index in range(8): + ds = ET.SubElement(ln, T("DataSet"), {"name": f"DS{index + 1}"}) + # 4*16+4*15=124: non-overlapping ordered FCDA evidence. + count = (16 if index < 4 else 15) + if members != 124 and index == 0: + count += members - 124 + for subindex in range(max(count, 0)): + ET.SubElement(ds, T("FCDA"), + {"ldInst": inst, "lnClass": "CSWI", + "lnInst": "7", "doName": f"Pos{subindex}", + "daName": "stVal", "fc": "ST"}) + if index < 6: + ET.SubElement(ln, T("ReportControl"), + {"name": f"RCB{index + 1}", "datSet": f"DS{index + 1}", + "buffered": "true"}) + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +class GeFieldEvidenceTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.src = Path(self.temp.name, "BCU_GE.iid") + self.out = Path(self.temp.name, "BCUGE-discovery.icd") + self.src.write_bytes(example()) + self.out.write_bytes(example()) + + def check(self, sha=None, domain="BCUGEF650"): + return gate.assess( + self.src, self.out, + sha or hashlib.sha256(self.src.read_bytes()).hexdigest(), + domain, "192.16.1.33", "S1") + + def test_valid_structure_is_explicitly_not_physical_qualification(self): + result = self.check() + self.assertEqual("STRUCTURE_PASS_ONLY", result["structuralVerdict"]) + self.assertEqual(8, result["dataSets"]) + self.assertEqual(124, result["staticMembers"]) + self.assertEqual("NOT_VERIFIED", result["physicalQualification"]) + self.assertFalse(result["r10PromotionAllowed"]) + self.assertTrue(any("BRCB" in s for s in result["unverifiedPhysicalGates"])) + + def test_source_sha_drift_fails_closed(self): + with self.assertRaisesRegex(gate.RejectedEvidence, "SHA-256 mismatch"): + self.check("0" * 64) + + def test_same_scl_file_fails_closed(self): + with self.assertRaisesRegex(gate.RejectedEvidence, "same file"): + gate.assess(self.src, self.src, + hashlib.sha256(self.src.read_bytes()).hexdigest(), + "BCUGEF650", "192.16.1.33", "S1") + + def test_wrong_observed_wire_domain_fails_even_if_scl_graph_equal(self): + self.assertEqual("FAIL", self.check(domain="BCUGECTRL")["structuralVerdict"]) + + def test_wrong_endpoint_rejected(self): + self.out.write_bytes(example(ip="192.16.1.34")) + self.assertEqual("FAIL", self.check()["structuralVerdict"]) + + def test_ipless_scl_is_explicit_review_not_invented_endpoint(self): + root = ET.fromstring(example()) + root.find(f".//{T('Address')}").clear() + self.out.write_bytes(ET.tostring(root)) + result = self.check() + self.assertEqual("STRUCTURE_PASS_ONLY", result["structuralVerdict"]) + self.assertTrue(any("no authoritative ConnectedAP IP" in s for s in result["review"])) + + def test_f650_must_not_become_mms_domain_inst(self): + self.out.write_bytes(example(inst="BCUGEF650")) + self.assertEqual("FAIL", self.check()["structuralVerdict"]) + + def test_extra_explicit_ldname_is_detected(self): + self.out.write_bytes(example(wire_ld_name="OTHER_WIRE_DOMAIN")) + self.assertEqual("FAIL", self.check()["structuralVerdict"]) + + def test_missing_static_member_fails(self): + self.out.write_bytes(example(members=123)) + self.assertEqual("FAIL", self.check()["structuralVerdict"]) + + def test_member_order_change_fails_not_just_count(self): + root = ET.fromstring(example()) + ds = root.find(f".//{T('DataSet')}") + ds[0], ds[1] = ds[1], ds[0] + self.out.write_bytes(ET.tostring(root)) + self.assertEqual("FAIL", self.check()["structuralVerdict"]) + + def test_fcda_unknown_ln_rejected(self): + self.out.write_bytes(example(remove_ln=True)) + with self.assertRaisesRegex(gate.RejectedEvidence, "unknown LN"): + self.check() + + def test_no_false_rcb_continuity_from_matching_logical_rcbs(self): + result = self.check() + self.assertEqual(6, result["logicalReportControls"]["discoveryExport"]) + self.assertEqual("NOT_VERIFIED", result["physicalQualification"]) + + def test_different_rcb_configuration_is_review_not_physical_pass(self): + root = ET.fromstring(example()) + root.find(f".//{T('ReportControl')}").set("buffered", "false") + self.out.write_bytes(ET.tostring(root)) + result = self.check() + self.assertEqual("STRUCTURE_PASS_ONLY", result["structuralVerdict"]) + self.assertTrue(any("Logical ReportControl" in s for s in result["review"])) + self.assertFalse(result["r10PromotionAllowed"]) + + def test_source_extra_design_ln_is_review_not_dataset_failure(self): + root = ET.fromstring(example()) + ld = root.find(f".//{T('LDevice')}") + ET.SubElement(ld, T("LN"), {"lnClass": "MMXU", "inst": "1"}) + self.src.write_bytes(ET.tostring(root)) + result = self.check() + self.assertEqual("STRUCTURE_PASS_ONLY", result["structuralVerdict"]) + self.assertTrue(any("LN design/live scope differs" in s for s in result["review"])) + + def test_external_ied_fcda_is_not_silently_treated_as_local(self): + root = ET.fromstring(example()) + root.find(f".//{T('FCDA')}").set("iedName", "DIFFERENT_IED") + self.out.write_bytes(ET.tostring(root)) + with self.assertRaisesRegex(gate.RejectedEvidence, "external IED"): + self.check() + + def test_dtd_and_entities_rejected(self): + evil = b']>' + self.out.write_bytes(evil) + with self.assertRaisesRegex(gate.RejectedEvidence, "DTD/ENTITY"): + self.check() + + def test_ambiguous_accesspoint_rejected(self): + root = ET.fromstring(example()) + access = root.find(f".//{T('AccessPoint')}") + root.find(f".//{T('IED')}").append(ET.fromstring(ET.tostring(access))) + self.out.write_bytes(ET.tostring(root)) + with self.assertRaisesRegex(gate.RejectedEvidence, "AccessPoint"): + self.check() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate-supply-chain-workflows.py b/scripts/validate-supply-chain-workflows.py index ae63b1f7e..79ec4863c 100644 --- a/scripts/validate-supply-chain-workflows.py +++ b/scripts/validate-supply-chain-workflows.py @@ -76,6 +76,15 @@ def main() -> int: "actions/attest@v4", "subject-path: ArIED61850Tester/dist/ARSAS-Windows-x64-Setup.exe", "sbom-path: ArIED61850Tester/dist/ARSAS-Windows-x64-SBOM.spdx.json", "supplyChain = [ordered]@{", "attestationWorkflow = \"release-windows.yml\"", + "Promote exact sealed canonical Windows package for release", + "verify-ci-package-reuse.py", "--head-branch main", "--event-name push", + "RELEASE_PACKAGE_AUTHORITY=sealed-build-arsas:", + "Promote exact validated Windows installer for release", + "verify-ci-installer-reuse.py", + "RELEASE_INSTALLER_AUTHORITY=validated-installer:", + "validatedInstallerArtifactSha256", "validatedInstallerSha256", + "-PublishedDirectory $env:RELEASE_INSTALLER_INPUT_DIR", + "canonicalPackageArtifactSha256", "canonicalPortableSha256", ) for value in primary_contract: if value not in primary: diff --git a/scripts/verify-ci-canonical-reuse.py b/scripts/verify-ci-canonical-reuse.py new file mode 100644 index 000000000..bca4a9dda --- /dev/null +++ b/scripts/verify-ci-canonical-reuse.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""CI-P2: reuse canonical Build ARSAS regression proof, fail closed. + +This proof reader runs on a read-only GitHub token. Strict mode requires the +canonical Build ARSAS workflow to complete successfully. CI-P2D adds an explicit +artifact-ready mode that can prove the full regression earlier, after the exact +manifest/TRX artifact is uploaded, while portable packaging/smoke remains a +separate Build ARSAS responsibility. + +Downloaded artifacts are treated strictly as untrusted ZIP *data*. No file +from the artifact is extracted or executed. +""" +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile +import xml.etree.ElementTree as ET +from pathlib import PurePosixPath + +import importlib.util +from pathlib import Path + +_source = Path(__file__).with_name("write-ci-canonical-proof.py") +_spec = importlib.util.spec_from_file_location("arsas_ci_canonical_proof", _source) +assert _spec and _spec.loader +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +counters_from_trx = _module.counters_from_trx +sha = _module.sha + +MAX_ARCHIVE_BYTES = 50 * 1024 * 1024 +MAX_EVIDENCE_BYTES = 12 * 1024 * 1024 +EXPECTED_ARTIFACT = "ARSAS-test-evidence" +EXPECTED_MANIFEST = "ci-canonical-authority.json" +EXPECTED_TRX = "arsas-tests.trx" + + +class ProofError(RuntimeError): + pass + + +def required_test_matches(trx: bytes, required_substrings: list[str]) -> dict[str, int]: + if not required_substrings: + return {} + + normalized: list[str] = [] + for value in required_substrings: + item = (value or "").strip() + if not item or len(item) > 200 or "\n" in item or "\r" in item: + raise ProofError("Required canonical test substring is invalid") + if item not in normalized: + normalized.append(item) + + try: + root = ET.fromstring(trx) + except ET.ParseError as exc: + raise ProofError("Canonical TRX cannot be parsed for required domain tests") from exc + + results: list[tuple[str, str]] = [] + for element in root.findall(".//{*}UnitTestResult"): + name = element.attrib.get("testName", "") + outcome = element.attrib.get("outcome", "") + if name: + results.append((name, outcome)) + + proof: dict[str, int] = {} + for required in normalized: + matches = [(name, outcome) for name, outcome in results if required in name] + if not matches: + raise ProofError( + f"Canonical TRX is missing required test family: {required}" + ) + nonpassing = [name for name, outcome in matches if outcome != "Passed"] + if nonpassing: + raise ProofError( + f"Canonical TRX required test family is not entirely Passed: {required}" + ) + proof[required] = len(matches) + return proof + + +def validate_identity(repo: str, head_sha: str, merge_sha: str, engine_sha: str, + branch: str, event_name: str = "pull_request") -> None: + if repo != "masarray/arsas": + raise ProofError("Canonical CI reuse is restricted to masarray/arsas") + for label, value in [ + ("PR head", head_sha), ("PR merge", merge_sha), ("engine", engine_sha) + ]: + try: + sha(value, label) + except ValueError as exc: + raise ProofError(str(exc)) from exc + if not branch or len(branch) > 200 or "\n" in branch: + raise ProofError("Invalid canonical branch identity") + if event_name not in {"pull_request", "push"}: + raise ProofError("Unsupported canonical workflow event") + + +def candidate_runs( + payload: dict, head_sha: str, branch: str, event_name: str = "pull_request" +) -> list[dict]: + runs = payload.get("workflow_runs", []) + if not isinstance(runs, list): + raise ProofError("GitHub Actions 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 validate_artifact_archive( + payload: bytes, *, merge_sha: str, engine_sha: str, + workflow_run_id: int, run_attempt: int, + expected_event_name: str = "pull_request", + required_test_substrings: list[str] | None = None, +) -> dict: + if not payload or len(payload) > MAX_ARCHIVE_BYTES: + raise ProofError("Canonical artifact is missing or exceeds bounded size") + + try: + archive = zipfile.ZipFile(io.BytesIO(payload)) + except (zipfile.BadZipFile, OSError) as exc: + raise ProofError("Canonical test evidence is not a valid ZIP archive") from exc + + with archive: + entries = {} + for info in archive.infolist(): + if info.is_dir(): + continue + name = info.filename.replace("\\", "/") + path = PurePosixPath(name) + if ( + path.is_absolute() + or ".." in path.parts + or ":" in name + or info.file_size < 0 + or info.file_size > MAX_EVIDENCE_BYTES + ): + raise ProofError("Canonical evidence archive has an unsafe or oversized entry") + basename = path.name + if basename in (EXPECTED_MANIFEST, EXPECTED_TRX): + if basename in entries: + raise ProofError(f"Canonical artifact contains duplicate {basename}") + entries[basename] = info + + if set(entries) != {EXPECTED_MANIFEST, EXPECTED_TRX}: + raise ProofError("Canonical manifest/TRX evidence is missing from artifact") + + try: + manifest = json.loads(archive.read(entries[EXPECTED_MANIFEST])) + trx = archive.read(entries[EXPECTED_TRX]) + except (ValueError, KeyError, RuntimeError, OSError) as exc: + raise ProofError("Canonical archive proof cannot be decoded safely") from exc + + if manifest.get("schemaVersion") != 1: + raise ProofError("Canonical proof schema version changed unexpectedly") + if manifest.get("kind") != "arsas-canonical-regression-evidence": + raise ProofError("Wrong canonical artifact provenance kind") + if manifest.get("canonicalWorkflow") != "Build ARSAS": + raise ProofError("Proof is not from canonical Build ARSAS") + if manifest.get("eventName") != expected_event_name: + raise ProofError( + f"Canonical proof event mismatch: expected {expected_event_name}" + ) + if manifest.get("workflowRunId") != workflow_run_id: + raise ProofError("Canonical workflow run ID differs from API run") + if manifest.get("runAttempt") != run_attempt: + raise ProofError("Canonical run attempt differs from API run attempt") + if manifest.get("exactSourceSha") != merge_sha: + raise ProofError("Canonical test proof is for a stale/different source revision") + if manifest.get("exactEngineSha") != engine_sha: + raise ProofError("Canonical engine SHA differs from this PR's immutable lock") + if manifest.get("fullRegressionPassed") is not True: + raise ProofError("Canonical test evidence does not prove full test PASS") + if manifest.get("releasePromotionAuthority") is not False: + raise ProofError("Canonical regression proof misrepresents release authority") + if manifest.get("testFile") != EXPECTED_TRX: + raise ProofError("Canonical proof references unexpected TRX filename") + if manifest.get("testTrxSha256") != hashlib.sha256(trx).hexdigest(): + raise ProofError("Canonical TRX digest differs from recorded proof") + try: + actual = counters_from_trx(trx) + except (ValueError, ArithmeticError) as exc: + raise ProofError("TRX does not prove a fully passing test suite") from exc + if manifest.get("testCounters") != actual: + raise ProofError("Canonical TRX counters disagree with manifest") + required_tests = required_test_matches(trx, required_test_substrings or []) + return { + "canonicalRunId": workflow_run_id, + "canonicalRunAttempt": run_attempt, + "mergeSha": merge_sha, + "engineSha": engine_sha, + "passed": actual["passed"], + "failed": actual["failed"], + "notExecuted": actual["notExecuted"], + "artifactSha256": hashlib.sha256(payload).hexdigest(), + "requiredTests": required_tests, + } + + +def choose_artifact(payload: dict, run_id: int, *, allow_missing: bool = False) -> dict | None: + artifacts = payload.get("artifacts", []) + if not isinstance(artifacts, list): + raise ProofError("Canonical 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) == run_id + ] + if not matches and allow_missing: + return None + if len(matches) != 1: + raise ProofError("Expected exactly one non-expired canonical test artifact") + return matches[0] + + +class StripCrossOriginAuthorization(urllib.request.HTTPRedirectHandler): + """Do not forward GitHub bearer credentials to artifact blob hosts.""" + + 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 ProofError("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-P2-Canonical-Reuse", + }, + method="GET", + ) + try: + opener = urllib.request.build_opener(StripCrossOriginAuthorization()) + with opener.open(req, timeout=30) as reply: + data = reply.read(MAX_ARCHIVE_BYTES + 1 if binary else 3 * 1024 * 1024) + if binary: + return data + return json.loads(data) + except (urllib.error.HTTPError, urllib.error.URLError, ValueError) as exc: + raise ProofError(f"GitHub read-only evidence request failed: {exc}") from exc + + +def verify_canonical( + api: GitHubReadOnly, *, repository: str, branch: str, + head_sha: str, merge_sha: str, engine_sha: str, + wait_seconds: int = 960, poll_seconds: int = 10, + allow_in_progress_artifact: bool = False, + event_name: str = "pull_request", + required_test_substrings: list[str] | None = None, +) -> dict: + validate_identity( + repository, head_sha, merge_sha, engine_sha, branch, event_name + ) + if wait_seconds < 0 or poll_seconds < 1: + raise ProofError("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] # Never use an older successful attempt over a newer failure. + run_id, attempt = run["id"], run["run_attempt"] + status = run.get("status") + conclusion = run.get("conclusion") + previous = f"{run_id}/{attempt} {status}/{conclusion}" + + # A canonical run that has already finished red is never usable, even + # if an all-pass regression artifact was uploaded before a later + # packaging/smoke failure. + if status == "completed" and conclusion != "success": + raise ProofError( + f"Latest canonical Build ARSAS run failed: {previous}" + ) + + can_read_artifact = ( + status == "completed" + or (allow_in_progress_artifact and status == "in_progress") + ) + if can_read_artifact: + 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_artifact_archive( + blob, merge_sha=merge_sha, engine_sha=engine_sha, + workflow_run_id=run_id, run_attempt=attempt, + expected_event_name=event_name, + required_test_substrings=required_test_substrings, + ) + except ProofError as exc: + if "stale/different source revision" not in str(exc): + raise + # A stale green artifact is not authoritative for the current + # synthetic merge tree; a replacement run can still arrive. + previous = f"{previous} (stale merge tree)" + else: + proof.update({ + "canonicalRunStatus": status, + "canonicalRunConclusion": conclusion, + "workflowCompleted": status == "completed", + "proofStage": ( + "completed-workflow" + if status == "completed" + else "full-regression-artifact-ready" + ), + "packagingSmokeProven": status == "completed", + "eventName": event_name, + "branch": branch, + }) + return proof + + if time.monotonic() >= deadline: + mode = ( + "artifact-ready canonical Build ARSAS evidence" + if allow_in_progress_artifact + else "completed canonical Build ARSAS evidence" + ) + raise ProofError( + f"Timed out waiting for {mode} " + f"for head={head_sha}, merge={merge_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("--merge-sha", required=True) + parser.add_argument("--engine-sha", required=True) + parser.add_argument( + "--event-name", + choices=("pull_request", "push"), + default="pull_request", + ) + parser.add_argument("--wait-seconds", type=int, default=960) + parser.add_argument( + "--require-test-substring", + action="append", + default=[], + help=( + "Require at least one Passed UnitTestResult whose testName contains " + "this substring. Repeat for multiple domain test families." + ), + ) + parser.add_argument( + "--allow-in-progress-artifact", + action="store_true", + help=( + "Accept exact all-pass regression manifest/TRX from the latest matching " + "in-progress Build ARSAS run. Does not prove later packaging/smoke." + ), + ) + parser.add_argument("--output", default="") + args = parser.parse_args() + result = verify_canonical( + GitHubReadOnly(os.environ.get("GITHUB_TOKEN", "")), + repository=args.repository, + branch=args.head_branch, + head_sha=args.head_sha, + merge_sha=args.merge_sha, + engine_sha=args.engine_sha, + wait_seconds=args.wait_seconds, + allow_in_progress_artifact=args.allow_in_progress_artifact, + event_name=args.event_name, + required_test_substrings=args.require_test_substring, + ) + payload = json.dumps(result, sort_keys=True) + print("Canonical full-regression reuse PASS: " + payload) + if args.output: + with open(args.output, "w", encoding="utf-8") as stream: + stream.write(payload + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify-ci-installer-reuse.py b/scripts/verify-ci-installer-reuse.py new file mode 100644 index 000000000..b8683e213 --- /dev/null +++ b/scripts/verify-ci-installer-reuse.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Verify and safely materialize the exact installed-smoke-tested ARSAS installer.""" +from __future__ import annotations + +import argparse, hashlib, io, json, os, re, time, urllib.error, urllib.parse, urllib.request, zipfile +from pathlib import Path, PurePosixPath + +EXPECTED_MANIFEST = "ci-installer-authority.json" +MAX_ARCHIVE_BYTES = 256 * 1024 * 1024 +MAX_FILE_BYTES = 192 * 1024 * 1024 +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +class InstallerProofError(RuntimeError): + pass + + +class GitHubRequestError(InstallerProofError): + def __init__(self, message: str, *, status: int | None = None): + super().__init__(message) + self.status = status + + +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: + if urllib.parse.urlparse(request.full_url).netloc.lower() != urllib.parse.urlparse(newurl).netloc.lower(): + redirected.remove_header("Authorization") + return redirected + + +class GitHubReadOnly: + def __init__(self, token: str): + if not token: + raise InstallerProofError("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-P3D-Installer-Reuse", + }) + try: + with urllib.request.build_opener(StripCrossOriginAuthorization()).open(req, timeout=30) as reply: + return reply.read(MAX_ARCHIVE_BYTES + 1) if binary else json.loads(reply.read(3 * 1024 * 1024)) + except urllib.error.HTTPError as exc: + raise GitHubRequestError(f"GitHub installer evidence request failed: HTTP {exc.code}", status=exc.code) from exc + except (urllib.error.URLError, ValueError) as exc: + raise GitHubRequestError(f"GitHub installer evidence request failed: {exc}") from exc + + +def _sha(value: str, label: str) -> str: + value = (value or "").strip().lower() + if not SHA_RE.fullmatch(value): + raise InstallerProofError(f"{label} must be a lowercase 40-hex SHA") + return value + + +def _safe_name(name: str) -> PurePosixPath: + norm = name.replace("\\", "/") + p = PurePosixPath(norm) + if p.is_absolute() or ".." in p.parts or ":" in norm: + raise InstallerProofError("Installer artifact contains an unsafe path") + return p + + +def validate_archive(blob: bytes, *, version: str, source_sha: str, engine_sha: str, + ardirec_sha: str, package_artifact_sha256: str, + workflow_run_id: int, run_attempt: int, + output_dir: Path | None = None) -> dict: + if not blob or len(blob) > MAX_ARCHIVE_BYTES: + raise InstallerProofError("Installer artifact is missing or oversized") + try: + z = zipfile.ZipFile(io.BytesIO(blob)) + except zipfile.BadZipFile as exc: + raise InstallerProofError("Installer artifact is not a valid ZIP") from exc + + files: dict[str, tuple[zipfile.ZipInfo, bytes]] = {} + seen = set() + with z: + for info in z.infolist(): + if info.is_dir(): + continue + p = _safe_name(info.filename) + key = "/".join(part.rstrip(" .").casefold() for part in p.parts) + if key in seen: + raise InstallerProofError("Installer artifact has a Windows-normalized path collision") + seen.add(key) + if info.file_size < 0 or info.file_size > MAX_FILE_BYTES: + raise InstallerProofError("Installer artifact contains an oversized file") + data = z.read(info) + files[p.name] = (info, data) + + if EXPECTED_MANIFEST not in files: + raise InstallerProofError("Installer authority manifest is missing") + try: + manifest = json.loads(files[EXPECTED_MANIFEST][1]) + except ValueError as exc: + raise InstallerProofError("Installer authority manifest is invalid JSON") from exc + + expected = { + "schemaVersion": 1, + "kind": "arsas-validated-windows-installer", + "version": version, + "sourceSha": _sha(source_sha, "source"), + "engineSha": _sha(engine_sha, "engine"), + "ardirecSha": _sha(ardirec_sha, "ArdIrec"), + "canonicalPackageArtifactSha256": package_artifact_sha256.lower(), + "installerWorkflow": "Validate ARSAS Windows installer", + "workflowRunId": workflow_run_id, + "runAttempt": run_attempt, + "eventName": "push", + "installedSmokePassed": True, + "releasePromotionAuthority": False, + } + for key, value in expected.items(): + if manifest.get(key) != value: + raise InstallerProofError(f"Installer authority mismatch for {key}") + + installer_name = manifest.get("installerFile") + if not isinstance(installer_name, str) or installer_name not in files: + raise InstallerProofError("Validated installer file is missing") + installer = files[installer_name][1] + actual_sha = hashlib.sha256(installer).hexdigest() + if manifest.get("installerSha256") != actual_sha: + raise InstallerProofError("Installer SHA-256 differs from authority manifest") + if manifest.get("installerSizeBytes") != len(installer): + raise InstallerProofError("Installer size differs from authority manifest") + if not SHA256_RE.fullmatch(package_artifact_sha256.lower()): + raise InstallerProofError("Canonical package artifact SHA-256 is invalid") + + if output_dir is not None: + output_dir.mkdir(parents=True, exist_ok=True) + target = output_dir / installer_name + target.write_bytes(installer) + (output_dir / EXPECTED_MANIFEST).write_bytes(files[EXPECTED_MANIFEST][1]) + + return { + "installerFile": installer_name, + "installerSha256": actual_sha, + "installerSizeBytes": len(installer), + "installerRunId": workflow_run_id, + "installerRunAttempt": run_attempt, + "canonicalPackageRun": manifest.get("canonicalPackageRun"), + "canonicalPackageArtifactSha256": manifest.get("canonicalPackageArtifactSha256"), + "artifactSha256": hashlib.sha256(blob).hexdigest(), + "sourceSha": manifest["sourceSha"], + "engineSha": manifest["engineSha"], + "ardirecSha": manifest["ardirecSha"], + "version": manifest["version"], + "installedSmokePassed": True, + "releasePromotionAuthority": False, + } + + +def verify(api: GitHubReadOnly, *, repository: str, source_sha: str, version: str, + engine_sha: str, ardirec_sha: str, package_artifact_sha256: str, + wait_seconds: int, poll_seconds: int, output_dir: Path | None) -> dict: + if repository != "masarray/arsas": + raise InstallerProofError("Installer reuse is restricted to masarray/arsas") + _sha(source_sha, "source"); _sha(engine_sha, "engine"); _sha(ardirec_sha, "ArdIrec") + base = f"https://api.github.com/repos/{repository}" + query = urllib.parse.urlencode({"event": "push", "head_sha": source_sha, "per_page": 100}) + runs_url = f"{base}/actions/workflows/installer-windows.yml/runs?{query}" + deadline = time.monotonic() + wait_seconds + last = None + while True: + payload = api.get(runs_url) + runs = [r for r in payload.get("workflow_runs", []) + if r.get("name") == "Validate ARSAS Windows installer" + and r.get("event") == "push" and r.get("head_branch") == "main" + and r.get("head_sha") == source_sha + and isinstance(r.get("id"), int) and isinstance(r.get("run_attempt"), int)] + runs.sort(key=lambda r: (r["id"], r["run_attempt"]), reverse=True) + if runs: + run = runs[0] + last = f"{run['id']}/{run['run_attempt']} {run.get('status')}/{run.get('conclusion')}" + if run.get("status") == "completed": + if run.get("conclusion") != "success": + raise InstallerProofError(f"Latest exact installer validation failed: {last}") + arts = api.get(f"{base}/actions/runs/{run['id']}/artifacts?per_page=100").get("artifacts", []) + name = f"ARSAS-{version}-win-x64-installer" + matches = [a for a in arts if a.get("name") == name and a.get("expired") is False] + if len(matches) != 1: + raise InstallerProofError("Expected exactly one non-expired validated installer artifact") + try: + blob = api.get(matches[0]["archive_download_url"], binary=True) + except GitHubRequestError as exc: + # Artifact metadata can become visible shortly before GitHub's + # archive redirect is readable. Retry only this transient 404 + # within the existing bounded deadline; all other errors fail. + if exc.status != 404: + raise + last = ( + f"{last} (installer metadata ready; archive download pending)" + ) + else: + proof = validate_archive( + blob, version=version, source_sha=source_sha, engine_sha=engine_sha, + ardirec_sha=ardirec_sha, package_artifact_sha256=package_artifact_sha256, + workflow_run_id=run["id"], run_attempt=run["run_attempt"], output_dir=output_dir) + proof["installerRunStatus"] = "completed" + proof["installerRunConclusion"] = "success" + return proof + if time.monotonic() >= deadline: + raise InstallerProofError(f"Timed out waiting for exact validated installer, last={last}") + time.sleep(min(poll_seconds, max(0.0, deadline - time.monotonic()))) + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--repository", required=True) + p.add_argument("--source-sha", required=True) + p.add_argument("--version", required=True) + p.add_argument("--engine-sha", required=True) + p.add_argument("--ardirec-sha", required=True) + p.add_argument("--package-artifact-sha256", required=True) + p.add_argument("--wait-seconds", type=int, default=1800) + p.add_argument("--poll-seconds", type=int, default=10) + p.add_argument("--output-dir", type=Path, required=True) + p.add_argument("--proof-output", type=Path, default=None) + args = p.parse_args() + proof = verify( + GitHubReadOnly(os.environ.get("GITHUB_TOKEN", "")), + repository=args.repository, source_sha=args.source_sha, version=args.version, + engine_sha=args.engine_sha, ardirec_sha=args.ardirec_sha, + package_artifact_sha256=args.package_artifact_sha256, + wait_seconds=args.wait_seconds, poll_seconds=args.poll_seconds, output_dir=args.output_dir) + payload = json.dumps(proof, sort_keys=True) + print("Validated installer reuse PASS: " + payload) + if args.proof_output: + args.proof_output.write_text(payload + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify-ci-package-reuse.py b/scripts/verify-ci-package-reuse.py new file mode 100644 index 000000000..336739d28 --- /dev/null +++ b/scripts/verify-ci-package-reuse.py @@ -0,0 +1,611 @@ +#!/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 + +_native_spec = importlib.util.spec_from_file_location( + "arsas_ci_native_ctest", _here / "write-ci-native-ctest-proof.py" +) +assert _native_spec and _native_spec.loader +_native = importlib.util.module_from_spec(_native_spec) +_native_spec.loader.exec_module(_native) + +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 + + +class GitHubRequestError(PackageProofError): + """Typed read-only GitHub request failure used for bounded retry decisions.""" + + def __init__(self, message: str, *, status: int | None = None): + super().__init__(message) + self.status = status + + +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 + + +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 + + +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, require_native_ctest_proof: bool = False +) -> 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] = {} + windows_archive_paths: dict[str, str] = {} + 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") + 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: + 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] = {} + windows_record_paths: dict[str, str] = {} + 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") + 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") + 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", "portableIdentity", "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") + + identity_record = manifest["portableIdentity"] + try: + build_identity = json.loads( + archive.read(infos[identity_record["path"]]).decode("utf-8-sig") + ) + except (ValueError, UnicodeDecodeError, KeyError, OSError, RuntimeError) as exc: + raise PackageProofError("Portable build identity cannot be decoded") from exc + if ( + build_identity.get("schemaVersion") != 1 + or build_identity.get("kind") != "arsas-portable-build-identity" + or build_identity.get("sourceCommit") != source_sha + or build_identity.get("engineCommit") != engine_sha + or build_identity.get("ardIrecLockCommit") != ardirec_sha + or build_identity.get("portableSha256") != manifest["portable"]["sha256"] + or build_identity.get("portableSizeBytes") != manifest["portable"]["size"] + or build_identity.get("ardIrecBridgeSha256") != manifest["nativeBridge"]["sha256"] + or build_identity.get("ardIrecBridgeSizeBytes") != manifest["nativeBridge"]["size"] + or build_identity.get("deterministicManagedBuild") is not True + or build_identity.get("reproducibleNativeLinkRequested") is not True + ): + raise PackageProofError( + "Portable build identity does not match canonical package authority" + ) + verification = manifest.get("verification") + if not isinstance(verification, dict): + raise PackageProofError("Canonical package verification references are missing") + for key in ( + "testAssemblyPath", + "comtradeFixturePath", + "locusFixturePath", + "releaseFixturePath", + ): + path = verification.get(key) + if not isinstance(path, str) or path not in recorded: + raise PackageProofError(f"Canonical package verification path is invalid: {key}") + + native_proof = None + junit_path = "verification/native/ctest.xml" + proof_path = "verification/native/ci-native-ctest-authority.json" + has_junit = junit_path in infos + has_proof = proof_path in infos + if has_junit != has_proof: + raise PackageProofError("Native CTest JUnit/proof pair is incomplete") + if require_native_ctest_proof and not has_proof: + raise PackageProofError("Required native CTest archive proof is missing") + if has_proof: + try: + native_proof = _native.verify_sealed_native_proof( + proof_bytes=archive.read(infos[proof_path]), + junit_bytes=archive.read(infos[junit_path]), + bridge_sha256=manifest["nativeBridge"]["sha256"], + bridge_size=manifest["nativeBridge"]["size"], + source_sha=source_sha, + engine_sha=engine_sha, + ardirec_sha=ardirec_sha, + run_id=workflow_run_id, + attempt=run_attempt, + event=event_name, + ) + except (ValueError, KeyError, TypeError) as exc: + raise PackageProofError( + f"Native CTest archive proof is invalid: {exc}" + ) from exc + + 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 { + "nativeCtestProofAvailable": native_proof is not None, + "nativeCtestPassed": native_proof is not None and native_proof["nativeCTestPassed"], + "nativeTestNames": native_proof["testNames"] if native_proof else [], + "nativeCtestJunitSha256": native_proof["ctestJunitSha256"] if native_proof else None, + "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"], + "portableSizeBytes": manifest["portable"]["size"], + "portablePath": manifest["portable"]["path"], + "portableIdentityPath": manifest["portableIdentity"]["path"], + "nativeBridgeSha256": manifest["nativeBridge"]["sha256"], + "installerInputRoot": manifest["installerInputRoot"], + "testAssemblyPath": manifest["verification"]["testAssemblyPath"], + "comtradeFixturePath": manifest["verification"]["comtradeFixturePath"], + "locusFixturePath": manifest["verification"]["locusFixturePath"], + "releaseFixturePath": manifest["verification"]["releaseFixturePath"], + "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 as exc: + raise GitHubRequestError( + f"GitHub package evidence request failed: HTTP {exc.code} {exc.reason}", + status=exc.code, + ) from exc + except (urllib.error.URLError, ValueError) as exc: + raise GitHubRequestError( + 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, require_native_ctest_proof: bool = False +) -> 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: + try: + blob = api.get(artifact["archive_download_url"], binary=True) + except GitHubRequestError as exc: + # GitHub can expose artifact metadata before the archive + # redirect is readable. Retry only the observed transient + # 404, within the existing bounded deadline. + if exc.status != 404: + raise + previous = ( + f"{previous} (artifact metadata ready; archive download pending)" + ) + else: + 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, + require_native_ctest_proof=require_native_ctest_proof, + ) + 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) + parser.add_argument("--require-native-ctest-proof", action="store_true") + 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, + require_native_ctest_proof=args.require_native_ctest_proof, + ) + 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/verify-p10-ge-field-scl.py b/scripts/verify-p10-ge-field-scl.py new file mode 100644 index 000000000..91ba07f3a --- /dev/null +++ b/scripts/verify-p10-ge-field-scl.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""P10.4 GE Edition 1 SCL structural evidence gate (offline, read-only). + +Not a physical IEC 61850 qualification: an operator must separately provide +association/PCAP, InformationReport, GI, continuity and semantic evidence. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from collections import Counter +from pathlib import Path +from xml.etree import ElementTree as ET + +NS = "http://www.iec.ch/61850/2003/SCL" +TAG = lambda local: f"{{{NS}}}{local}" +MAX_BYTES = 16 * 1024 * 1024 +HEX_SHA = re.compile(r"^[0-9a-fA-F]{64}$") + + +class RejectedEvidence(ValueError): + """A malformed or unauthoritative field input.""" + + +def read_bounded(path: Path) -> bytes: + size = path.stat().st_size + if size <= 0 or size > MAX_BYTES: + raise RejectedEvidence(f"{path.name}: file size outside (0, {MAX_BYTES}] bytes") + data = path.read_bytes() + if len(data) != size: + raise RejectedEvidence(f"{path.name}: file changed during read") + return data + + +def scl_tree(data: bytes, label: str) -> ET.Element: + # Do not allow entities/DTD or silently accept namespace-free pseudo SCL. + if re.search(rb" str: + return (element.get(name) or "").strip() + + +def ied(root: ET.Element, name: str, label: str) -> ET.Element: + candidates = [x for x in root.findall(TAG("IED")) if attr(x, "name") == name] + return only(candidates, f"{label}: IED '{name}'") + + +def access_point(ied_node: ET.Element, name: str, label: str) -> ET.Element: + return only([x for x in ied_node.findall(TAG("AccessPoint")) + if attr(x, "name") == name], f"{label}: AccessPoint '{name}'") + + +def endpoint(root: ET.Element, name: str, ap: str, label: str) -> str | None: + comm = root.find(TAG("Communication")) + if comm is None: + return None + matches = [node for node in comm.iter(TAG("ConnectedAP")) + if attr(node, "iedName") == name and attr(node, "apName") == ap] + if len(matches) > 1: + raise RejectedEvidence(f"{label}: ambiguous ConnectedAP '{name}/{ap}'") + if not matches: + return None + addr = matches[0].find(TAG("Address")) + if addr is None: + return None + ips = [str(p.text or "").strip() for p in addr.findall(TAG("P")) + if attr(p, "type").upper() == "IP"] + if len(ips) > 1: + raise RejectedEvidence(f"{label}: multiple IP entries") + return ips[0] if ips else None + + +def snapshot(root: ET.Element, name: str, ap_name: str, label: str) -> dict: + own_ied = ied(root, name, label) + own_ap = access_point(own_ied, ap_name, label) + server = only(own_ap.findall(TAG("Server")), f"{label}: Server") + ld_by_inst = {} + domains = {} + logical_nodes = set() + datasets = {} + reports = [] + goose = [] + + for ld in server.findall(TAG("LDevice")): + inst = attr(ld, "inst") + if not inst or inst in ld_by_inst: + raise RejectedEvidence(f"{label}: missing/duplicate LDevice.inst '{inst}'") + ld_by_inst[inst] = ld + domain = attr(ld, "ldName") or name + inst + if domain in domains: + raise RejectedEvidence(f"{label}: duplicate resolved MMS domain '{domain}'") + domains[domain] = inst + for ln in list(ld): + if ln.tag not in (TAG("LN0"), TAG("LN")): + continue + ln_class = attr(ln, "lnClass") or ("LLN0" if ln.tag == TAG("LN0") else "") + prefix, ln_inst = attr(ln, "prefix"), attr(ln, "inst") + node_key = (inst, prefix, ln_class, ln_inst) + if not ln_class or node_key in logical_nodes: + raise RejectedEvidence(f"{label}: invalid/duplicate logical node {node_key}") + logical_nodes.add(node_key) + for ds in ln.findall(TAG("DataSet")): + ds_name = attr(ds, "name") + key = (inst, prefix, ln_class, ln_inst, ds_name) + if not ds_name or key in datasets: + raise RejectedEvidence(f"{label}: duplicate/empty DataSet {key}") + members = [] + for fcda in ds.findall(TAG("FCDA")): + target_ld = attr(fcda, "ldInst") or inst + fcda_ied = attr(fcda, "iedName") + if fcda_ied and fcda_ied != name: + raise RejectedEvidence(f"{label}: FCDA targets external IED '{fcda_ied}'") + fc = attr(fcda, "fc") + do = attr(fcda, "doName") + ln_cl = attr(fcda, "lnClass") + if not target_ld or not ln_cl or not do or not fc: + raise RejectedEvidence(f"{label}: incomplete FCDA in {key}") + members.append((target_ld, attr(fcda, "prefix"), ln_cl, + attr(fcda, "lnInst"), do, + attr(fcda, "daName"), fc, attr(fcda, "ix"))) + if not members: + raise RejectedEvidence(f"{label}: empty DataSet {key}") + datasets[key] = members + for rc in ln.findall(TAG("ReportControl")): + rc_name = attr(rc, "name") + ds = attr(rc, "datSet") + if not rc_name or not ds: + raise RejectedEvidence(f"{label}: ReportControl missing name/datSet") + key = (inst, prefix, ln_class, ln_inst, ds) + if key not in datasets: + raise RejectedEvidence(f"{label}: RCB '{rc_name}' binds missing DataSet {key}") + reports.append((inst, prefix, ln_class, ln_inst, rc_name, ds, + attr(rc, "buffered"), attr(rc, "indexed"))) + for cb in ln.findall(TAG("GSEControl")): + goose.append((inst, prefix, ln_class, ln_inst, + attr(cb, "name"), attr(cb, "datSet"))) + + if not ld_by_inst: + raise RejectedEvidence(f"{label}: missing LDevice") + for key, members in datasets.items(): + for member in members: + if member[0] not in ld_by_inst: + raise RejectedEvidence(f"{label}: FCDA targets unknown LD '{member[0]}'") + if member[:4] not in logical_nodes: + raise RejectedEvidence(f"{label}: FCDA targets unknown LN {member[:4]}") + return { + "domains": domains, + "logicalNodes": logical_nodes, + "datasets": datasets, + "reports": reports, + "goose": goose, + "endpoint": endpoint(root, name, ap_name, label), + } + + +def readable_key(key: tuple) -> str: + return "/".join(str(x) for x in key) + + +def compare(trusted: dict, saved: dict, domain: str, ip: str, + expected_datasets: int, expected_members: int) -> dict: + failures, review = [], [] + for label, snap in (("trusted", trusted), ("discovery export", saved)): + if snap["domains"] != {domain: "F650"}: + failures.append(f"{label}: MMS wire-domain→LD mapping differs from BCUGEF650→F650") + if snap["endpoint"] is not None and snap["endpoint"] != ip: + failures.append(f"{label}: SCL declared IP '{snap['endpoint']}' differs from '{ip}'") + if snap["endpoint"] is None: + review.append(f"{label}: no authoritative ConnectedAP IP; endpoint must be checked from field capture") + if len(snap["datasets"]) != expected_datasets: + failures.append(f"{label}: DataSets={len(snap['datasets'])}; expected {expected_datasets}") + count = sum(len(m) for m in snap["datasets"].values()) + if count != expected_members: + failures.append(f"{label}: FCDA members={count}; expected {expected_members}") + + if trusted["datasets"] != saved["datasets"]: + missing = trusted["datasets"].keys() - saved["datasets"].keys() + extra = saved["datasets"].keys() - trusted["datasets"].keys() + changed = [key for key in trusted["datasets"].keys() & saved["datasets"].keys() + if trusted["datasets"][key] != saved["datasets"][key]] + failures.append(f"DataSet FCDA ordered semantic mismatch: missing={len(missing)}, " + f"extra={len(extra)}, changed={len(changed)}") + review.extend("DataSet mismatch: " + readable_key(key) + for key in list(sorted(missing | extra | set(changed)))[:12]) + + # Different design/offline vs discoverable live scope is NOT made invisible; + # this is a separate engineering review, not a DataSet/reporting pass. + missing_ln = trusted["logicalNodes"] - saved["logicalNodes"] + added_ln = saved["logicalNodes"] - trusted["logicalNodes"] + if missing_ln or added_ln: + review.append(f"LN design/live scope differs: trusted-only={len(missing_ln)}, " + f"export-only={len(added_ln)}; DO/DA semantic parity NOT VERIFIED") + if Counter(trusted["reports"]) != Counter(saved["reports"]): + review.append("Logical ReportControl configuration differs from trusted IID; " + "RCB semantic parity and buffer continuity NOT VERIFIED") + if Counter(trusted["goose"]) != Counter(saved["goose"]): + review.append("GOOSE control set differs; GOOSE address/cb reference parity NOT VERIFIED") + + return {"structuralVerdict": "FAIL" if failures else "STRUCTURE_PASS_ONLY", + "failures": failures, "review": review, + "dataSets": len(saved["datasets"]), + "staticMembers": sum(len(m) for m in saved["datasets"].values()), + "logicalNodes": {"trusted": len(trusted["logicalNodes"]), + "discoveryExport": len(saved["logicalNodes"])}, + "logicalReportControls": {"trusted": len(trusted["reports"]), + "discoveryExport": len(saved["reports"])}} + + +def assess(trusted_file: Path, generated_file: Path, source_sha: str, + domain: str, ip: str, ap_name: str, datasets: int = 8, + members: int = 124) -> dict: + if not HEX_SHA.fullmatch(source_sha): + raise RejectedEvidence("trusted source SHA-256 must be exactly 64 hex characters") + if trusted_file.resolve() == generated_file.resolve(): + raise RejectedEvidence("source IID and Discovery-export SCL cannot be the same file") + origin = read_bounded(trusted_file) + actual = hashlib.sha256(origin).hexdigest() + if actual.lower() != source_sha.lower(): + raise RejectedEvidence("trusted engineering IID SHA-256 mismatch; fail closed") + output = read_bounded(generated_file) + t = snapshot(scl_tree(origin, "trusted IID"), "BCUGE", ap_name, "trusted IID") + g = snapshot(scl_tree(output, "Discovery SCL"), "BCUGE", ap_name, "Discovery SCL") + result = compare(t, g, domain, ip, datasets, members) + return { + "schema": "arsas-p10-ge-field-check-v1", + "source": {"sha256": actual, "bytes": len(origin)}, + "discoveryExport": {"sha256": hashlib.sha256(output).hexdigest(), "bytes": len(output)}, + "expectedEndpoint": f"{ip}:102", + "observedDomainClaim": domain, + "identity": "BCUGE", + "logicalDevice": "F650", + **result, + "unverifiedPhysicalGates": [ + "actual MMS association/device provenance and raw domain name-list", + "67/67 runtime points and six concrete static RCBs with InformationReport traffic", + "zero cyclic MMS process-value polling and valid GI causality", + "LN/DO/DA semantic closure, 6400 name-list cap and incomplete chains", + "BRCB BufOvfl/sequence/EntryID replay continuity across sessions", + "GOOSE address and wire subscriber semantics", + ], + "physicalQualification": "NOT_VERIFIED", + "r10PromotionAllowed": False, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--trusted-iid", type=Path, required=True) + parser.add_argument("--trusted-sha256", required=True) + parser.add_argument("--discovery-scl", type=Path, required=True) + parser.add_argument("--observed-mms-domain", required=True) + parser.add_argument("--endpoint-ip", default="192.16.1.33") + parser.add_argument("--access-point", required=True) + parser.add_argument("--out", type=Path, help="Optional JSON report path") + args = parser.parse_args(argv) + try: + result = assess(args.trusted_iid, args.discovery_scl, + args.trusted_sha256, args.observed_mms_domain, + args.endpoint_ip, args.access_point) + code = 2 if result["structuralVerdict"] == "FAIL" else 0 + except (OSError, RejectedEvidence) as exc: + result = {"schema": "arsas-p10-ge-field-check-v1", + "structuralVerdict": "REJECTED_INPUT", + "reason": str(exc), "physicalQualification": "NOT_VERIFIED", + "r10PromotionAllowed": False} + code = 2 + report = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + print(report) + if args.out is not None: + try: + args.out.write_text(report + "\n", encoding="utf-8") + except OSError as exc: + print(f"Could not write report: {exc}", file=sys.stderr) + return 2 + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify-rcb-export.ps1 b/scripts/verify-rcb-export.ps1 index a237cda25..91a57a063 100644 --- a/scripts/verify-rcb-export.ps1 +++ b/scripts/verify-rcb-export.ps1 @@ -43,7 +43,8 @@ Require-Text $mainCode 'SclReportControlFilter.FilterLiveModel' "Live-discovery Require-Text $mainCode 'latestAvailability' "The latest read-only availability evidence is not retained for export." Require-Text $mainCode 'LiveRcbDataSetEvidenceMerger.MergeSelectedDataSetDirectory' "Live FCDA directory evidence is not merged before selected-RCB export." Require-Text $mainCode 'AuthoritativeLiveIedSclExporter.WriteFiles' "Live CID export must normalize the physical IED identity separately from MMS Logical Device domains." -Require-Text $mainCode 'IedNameOverride = device.Name' "The IED card identity must be passed as the authoritative SCL IED name." +Require-Text $mainCode 'IedNameOverride = verifiedIdentity?.IedName ?? device.Name' "Verified SCL identity must take precedence over provisional display name at live RCB export." +Require-Text $mainCode 'VerifiedIdentity = verifiedIdentity' "Verified LD mapping must be forwarded to authoritative SCL exporter." Reject-Text $mainCode 'Task.Run(() => LiveIedSclExporter.WriteFiles(' "Live selected-RCB export must not bypass authoritative IED identity normalization." Require-Text $mainXaml 'Adaptive five-slot action bar' "IED-card source action row is no longer protected from clipping." Require-Text $mainXaml ' dict: + 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-P3G-Release-Ready", + }, + method="GET", + ) + try: + with urllib.request.urlopen(req, timeout=30) as reply: + return json.loads(reply.read(4 * 1024 * 1024)) + except (urllib.error.HTTPError, urllib.error.URLError, ValueError) as exc: + raise ReleaseReadyError(f"GitHub release-readiness request failed: {exc}") from exc + + +def _sha(value: str, label: str) -> str: + value = (value or "").strip().lower() + if not SHA_RE.fullmatch(value): + raise ReleaseReadyError(f"{label} must be a lowercase 40-hex SHA") + return value + + +def _version(value: str) -> str: + value = (value or "").strip() + if value.lower().startswith("v"): + value = value[1:] + if not VERSION_RE.fullmatch(value): + raise ReleaseReadyError("Release version is invalid") + return value + + +def _xml_version(path: Path) -> str: + try: + root = ET.fromstring(path.read_text(encoding="utf-8-sig")) + except (OSError, ET.ParseError) as exc: + raise ReleaseReadyError(f"Cannot parse release version metadata: {path}") from exc + for group in root.findall("PropertyGroup"): + node = group.find("Version") + if node is not None and node.text and node.text.strip(): + return node.text.strip() + raise ReleaseReadyError(f"Version metadata is missing from {path}") + + +def resolve_identity(source_dir: Path, *, source_sha: str, ref_type: str, ref_name: str) -> dict: + source_sha = _sha(source_sha, "source") + if ref_type not in {"branch", "tag"}: + raise ReleaseReadyError("Release gate supports only branch or tag push refs") + + version_file = (source_dir / "VERSION").read_text(encoding="utf-8-sig").strip() + props_version = _xml_version(source_dir / "Directory.Build.props") + project_version = _xml_version(source_dir / "ArIED61850Tester.csproj") + + if ref_type == "tag": + requested = _version(ref_name) + else: + if ref_name != "main": + raise ReleaseReadyError("Branch release readiness is restricted to main") + try: + release = json.loads((source_dir / ".release/windows.json").read_text(encoding="utf-8-sig")) + requested = _version(str(release["version"])) + except (OSError, ValueError, KeyError, TypeError) as exc: + raise ReleaseReadyError("Release manifest version is missing or invalid") from exc + + canonical = _version(version_file) + if requested != canonical or props_version != canonical or project_version != canonical: + raise ReleaseReadyError( + f"Release version identity mismatch: requested={requested}, VERSION={canonical}, " + f"props={props_version}, project={project_version}" + ) + + try: + iec = json.loads((source_dir / "engines/ARIEC61850.lock.json").read_text(encoding="utf-8-sig")) + ard = json.loads((source_dir / "engines/ARDIREC.lock.json").read_text(encoding="utf-8-sig")) + except (OSError, ValueError) as exc: + raise ReleaseReadyError("Release engine lock metadata cannot be decoded") from exc + + engine_sha = _sha(str(iec.get("commit", "")), "ARIEC61850") + ardirec_sha = _sha(str(ard.get("commit", "")), "ArdIrec") + if iec.get("repository") != "masarray/ARIEC61850" or iec.get("ref") != "main": + raise ReleaseReadyError("ARIEC61850 release lock identity is invalid") + if str(ard.get("repository", "")).lower() != "masarray/ardirec": + raise ReleaseReadyError("ArdIrec release lock identity is invalid") + bridge = ard.get("bridge") if isinstance(ard.get("bridge"), dict) else {} + if ard.get("schema") != 3 or bridge.get("abi") != 1 or bridge.get("mode") != "native-only": + raise ReleaseReadyError("ArdIrec native bridge release lock is invalid") + + return { + "sourceSha": source_sha, + "version": canonical, + "engineSha": engine_sha, + "ardirecSha": ardirec_sha, + "refType": ref_type, + "refName": ref_name, + } + + +def _candidate_runs(payload: dict, *, workflow_name: str, source_sha: str) -> list[dict]: + runs = payload.get("workflow_runs", []) + if not isinstance(runs, list): + raise ReleaseReadyError("GitHub workflow run listing is malformed") + matches = [ + run for run in runs + if run.get("name") == workflow_name + and run.get("event") == "push" + and run.get("head_branch") == "main" + and run.get("head_sha") == source_sha + and isinstance(run.get("id"), int) + and isinstance(run.get("run_attempt"), int) + ] + return sorted(matches, key=lambda r: (r["id"], r["run_attempt"]), reverse=True) + + +def _one_artifact(payload: dict, *, name: str, run_id: int, allow_missing: bool) -> dict | None: + artifacts = payload.get("artifacts", []) + if not isinstance(artifacts, list): + raise ReleaseReadyError("GitHub artifact listing is malformed") + matches = [ + a for a in artifacts + if a.get("name") == name + and a.get("expired") is False + and isinstance(a.get("id"), int) + and a.get("workflow_run", {}).get("id", run_id) == run_id + ] + if not matches and allow_missing: + return None + if len(matches) != 1: + raise ReleaseReadyError(f"Expected exactly one non-expired artifact named {name}") + return matches[0] + + +def wait_release_ready( + api: GitHubReadOnly, + *, + repository: str, + identity: dict, + wait_seconds: int = 2400, + poll_seconds: int = 10, +) -> dict: + if repository != "masarray/arsas": + raise ReleaseReadyError("Release readiness is restricted to masarray/arsas") + source_sha = _sha(identity.get("sourceSha", ""), "source") + version = _version(identity.get("version", "")) + if wait_seconds < 0 or poll_seconds < 1: + raise ReleaseReadyError("Invalid bounded wait policy") + + base = f"https://api.github.com/repos/{repository}" + query = urllib.parse.urlencode({"event": "push", "head_sha": source_sha, "per_page": 100}) + build_url = f"{base}/actions/workflows/build.yml/runs?{query}" + installer_url = f"{base}/actions/workflows/installer-windows.yml/runs?{query}" + deadline = time.monotonic() + wait_seconds + last_build = "not found" + last_installer = "not found" + + while True: + package = None + installer_artifact = None + + builds = _candidate_runs( + api.get(build_url), workflow_name="Build ARSAS", source_sha=source_sha + ) + build_run = builds[0] if builds else None + if build_run is not None: + last_build = ( + f"{build_run['id']}/{build_run['run_attempt']} " + f"{build_run.get('status')}/{build_run.get('conclusion')}" + ) + if build_run.get("status") == "completed" and build_run.get("conclusion") != "success": + raise ReleaseReadyError(f"Latest exact Build ARSAS run failed: {last_build}") + if build_run.get("status") in {"in_progress", "completed"}: + package = _one_artifact( + api.get(f"{base}/actions/runs/{build_run['id']}/artifacts?per_page=100"), + name=PACKAGE_ARTIFACT, + run_id=build_run["id"], + allow_missing=(build_run.get("status") != "completed"), + ) + + installers = _candidate_runs( + api.get(installer_url), + workflow_name="Validate ARSAS Windows installer", + source_sha=source_sha, + ) + installer_run = installers[0] if installers else None + if installer_run is not None: + last_installer = ( + f"{installer_run['id']}/{installer_run['run_attempt']} " + f"{installer_run.get('status')}/{installer_run.get('conclusion')}" + ) + if installer_run.get("status") == "completed": + if installer_run.get("conclusion") != "success": + raise ReleaseReadyError( + f"Latest exact installer validation failed: {last_installer}" + ) + installer_artifact = _one_artifact( + api.get( + f"{base}/actions/runs/{installer_run['id']}/artifacts?per_page=100" + ), + name=f"ARSAS-{version}-win-x64-installer", + run_id=installer_run["id"], + allow_missing=False, + ) + + if package is not None and installer_artifact is not None: + return { + **identity, + "buildRunId": build_run["id"], + "buildRunAttempt": build_run["run_attempt"], + "packageArtifactId": package["id"], + "installerRunId": installer_run["id"], + "installerRunAttempt": installer_run["run_attempt"], + "installerArtifactId": installer_artifact["id"], + "metadataReady": True, + "releasePromotionAuthority": False, + } + + if time.monotonic() >= deadline: + raise ReleaseReadyError( + "Timed out waiting for exact release dependencies: " + f"Build={last_build}, Installer={last_installer}" + ) + time.sleep(min(poll_seconds, max(0.0, deadline - time.monotonic()))) + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--repository", required=True) + p.add_argument("--source-dir", type=Path, required=True) + p.add_argument("--source-sha", required=True) + p.add_argument("--ref-type", choices=("branch", "tag"), required=True) + p.add_argument("--ref-name", required=True) + p.add_argument("--wait-seconds", type=int, default=2400) + p.add_argument("--poll-seconds", type=int, default=10) + p.add_argument("--output", type=Path) + args = p.parse_args() + + identity = resolve_identity( + args.source_dir, + source_sha=args.source_sha, + ref_type=args.ref_type, + ref_name=args.ref_name, + ) + proof = wait_release_ready( + GitHubReadOnly(os.environ.get("GITHUB_TOKEN", "")), + repository=args.repository, + identity=identity, + wait_seconds=args.wait_seconds, + poll_seconds=args.poll_seconds, + ) + payload = json.dumps(proof, sort_keys=True) + print("Release dependency metadata ready: " + payload) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(payload + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/write-ci-canonical-proof.py b/scripts/write-ci-canonical-proof.py new file mode 100644 index 000000000..7ac652b78 --- /dev/null +++ b/scripts/write-ci-canonical-proof.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Produce exact-SHA canonical Build ARSAS regression proof from verified TRX. + +This script runs only after the canonical full test step succeeded. It writes +into the already-uploaded ARSAS-test-evidence artifact; there is no new artifact +publication surface and no release authority change. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import xml.etree.ElementTree as ET +from pathlib import Path + + +def sha(value: str, label: str) -> str: + value = (value or "").lower() + if len(value) != 40 or any(ch not in "0123456789abcdef" for ch in value): + raise ValueError(f"{label} must be an immutable lowercase 40-hex SHA") + return value + + +def git_head(path: Path) -> str: + return sha( + subprocess.check_output( + ["git", "-C", str(path), "rev-parse", "HEAD"], stderr=subprocess.PIPE + ).decode("ascii").strip(), + "verified checkout", + ) + + +def counters_from_trx(data: bytes) -> dict[str, int]: + root = ET.fromstring(data) + counters = root.find(".//{*}Counters") + if counters is None: + raise ValueError("TRX has no test-result Counters element") + + result = {} + for name in ("total", "passed", "failed", "notExecuted", "executed"): + value = int(counters.attrib.get(name, -1)) + if value < 0: + raise ValueError(f"TRX Counters missing valid {name}") + result[name] = value + if ( + result["total"] <= 0 + or result["failed"] != 0 + or result["notExecuted"] != 0 + or result["passed"] != result["total"] + or result["executed"] != result["total"] + ): + raise ValueError( + f"Canonical full suite is not entirely green: {result}" + ) + return result + + +def create_manifest( + source_sha: str, + checkout_sha: str, + engine_expected: str, + engine_actual: str, + workflow_run_id: int, + run_attempt: int, + event_name: str, + trx: bytes, + trx_name: str, +) -> dict: + for label, value in ( + ("source", source_sha), + ("checkout", checkout_sha), + ("engine", engine_expected), + ("engine checkout", engine_actual), + ): + sha(value, label) + + if source_sha != checkout_sha or engine_expected != engine_actual: + raise ValueError("Source or engine checkout is not the exact expected SHA") + if workflow_run_id <= 0 or run_attempt <= 0: + raise ValueError("Invalid GitHub workflow run identity") + if event_name not in {"push", "pull_request", "workflow_dispatch"}: + raise ValueError("Unexpected canonical workflow event") + if Path(trx_name).name != trx_name or not trx_name.endswith(".trx"): + raise ValueError("TRX filename is invalid") + + test_counters = counters_from_trx(trx) + return { + "schemaVersion": 1, + "kind": "arsas-canonical-regression-evidence", + "canonicalWorkflow": "Build ARSAS", + "workflowRunId": workflow_run_id, + "runAttempt": run_attempt, + "eventName": event_name, + "exactSourceSha": checkout_sha, + "exactEngineSha": engine_actual, + "testFile": trx_name, + "testTrxSha256": hashlib.sha256(trx).hexdigest(), + "testCounters": test_counters, + "fullRegressionPassed": True, + "releasePromotionAuthority": False, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--app-dir", type=Path, required=True) + parser.add_argument("--engine-dir", type=Path, required=True) + parser.add_argument("--test-results-dir", type=Path, required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--engine-sha", required=True) + parser.add_argument("--run-id", type=int, required=True) + parser.add_argument("--attempt", type=int, required=True) + parser.add_argument("--event-name", required=True) + args = parser.parse_args() + + trx_file = args.test_results_dir / "arsas-tests.trx" + if not trx_file.is_file(): + raise FileNotFoundError(f"Canonical test evidence is missing: {trx_file}") + blob = trx_file.read_bytes() + manifest = create_manifest( + source_sha=sha(args.source_sha, "source"), + checkout_sha=git_head(args.app_dir), + engine_expected=sha(args.engine_sha, "expected engine"), + engine_actual=git_head(args.engine_dir), + workflow_run_id=args.run_id, + run_attempt=args.attempt, + event_name=args.event_name, + trx=blob, + trx_name=trx_file.name, + ) + output = args.test_results_dir / "ci-canonical-authority.json" + output.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n") + print( + f"Canonical SHA proof: source={manifest['exactSourceSha']} " + f"engine={manifest['exactEngineSha']}, " + f"tests={manifest['testCounters']['passed']} PASS; " + f"run={manifest['workflowRunId']}/{manifest['runAttempt']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/write-ci-installer-manifest.py b/scripts/write-ci-installer-manifest.py new file mode 100644 index 000000000..d3e64dd42 --- /dev/null +++ b/scripts/write-ci-installer-manifest.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Write a fail-closed authority manifest for an installed-smoke-tested Windows installer.""" +from __future__ import annotations + +import argparse, hashlib, json, re +from pathlib import Path + +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +def sha(value: str, label: str) -> str: + value = (value or "").strip().lower() + if not SHA_RE.fullmatch(value): + raise ValueError(f"{label} must be a lowercase 40-hex SHA") + return value + + +def file_sha256(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 main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--installer", type=Path, required=True) + p.add_argument("--output", type=Path, required=True) + p.add_argument("--version", required=True) + p.add_argument("--source-sha", required=True) + p.add_argument("--engine-sha", required=True) + p.add_argument("--ardirec-sha", required=True) + p.add_argument("--package-run", required=True) + p.add_argument("--package-artifact-sha256", required=True) + p.add_argument("--workflow-run-id", type=int, required=True) + p.add_argument("--run-attempt", type=int, required=True) + p.add_argument("--event-name", required=True) + args = p.parse_args() + + if not args.installer.is_file(): + raise FileNotFoundError(args.installer) + if args.workflow_run_id <= 0 or args.run_attempt <= 0: + raise ValueError("Invalid installer workflow identity") + if args.event_name not in {"push", "pull_request", "workflow_dispatch"}: + raise ValueError("Unexpected installer workflow event") + if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?", args.version): + raise ValueError("Invalid installer version") + + package_digest = args.package_artifact_sha256.strip().lower() + if args.event_name == "workflow_dispatch": + if package_digest != "manual-local-build": + raise ValueError("Manual installer must record manual-local-build package authority") + elif not SHA256_RE.fullmatch(package_digest): + raise ValueError("Canonical package artifact SHA-256 is invalid") + + size = args.installer.stat().st_size + if size <= 0: + raise ValueError("Installer is empty") + + payload = { + "schemaVersion": 1, + "kind": "arsas-validated-windows-installer", + "version": args.version, + "sourceSha": sha(args.source_sha, "source"), + "engineSha": sha(args.engine_sha, "engine"), + "ardirecSha": sha(args.ardirec_sha, "ArdIrec"), + "canonicalPackageRun": args.package_run, + "canonicalPackageArtifactSha256": package_digest, + "installerWorkflow": "Validate ARSAS Windows installer", + "workflowRunId": args.workflow_run_id, + "runAttempt": args.run_attempt, + "eventName": args.event_name, + "installerFile": args.installer.name, + "installerSha256": file_sha256(args.installer), + "installerSizeBytes": size, + "installedSmokePassed": True, + "releasePromotionAuthority": False, + } + args.output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(payload, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/write-ci-native-ctest-proof.py b/scripts/write-ci-native-ctest-proof.py new file mode 100644 index 000000000..5fa2e61b2 --- /dev/null +++ b/scripts/write-ci-native-ctest-proof.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Seal and reverify canonical ArdIrec CTest/JUnit evidence (CI-P3J-A). + +Evidence capture is NOT permission to skip the independent COMTRADE lane. +The existing Windows package remains the authority for exact tested bytes. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +KIND = "arsas-canonical-ardirec-native-ctest" +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +MAX_JUNIT_BYTES = 2 * 1024 * 1024 +MAX_TESTS = 5000 +PROFILE = "windows-x64-release-native-only-cmake-ctest" + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def parse_ctest_junit_bytes(payload: bytes) -> list[str]: + if not payload or len(payload) > MAX_JUNIT_BYTES: + raise ValueError("CTest JUnit evidence missing or exceeds bounded size") + if b" MAX_TESTS or len(names) != len(set(names)): + raise ValueError("CTest JUnit has empty, duplicate, or excessive test cases") + return sorted(names) + + +def parse_ctest_junit(path: Path) -> list[str]: + return parse_ctest_junit_bytes(path.read_bytes()) + + +def record_from_bytes( + *, junit_bytes: bytes, bridge_sha256: str, bridge_size: int, + source_sha: str, engine_sha: str, ardirec_sha: str, + run_id: int, attempt: int, event: str, +) -> dict: + for label, value in ( + ("source", source_sha), ("engine", engine_sha), ("ArdIrec", ardirec_sha) + ): + if not SHA_RE.fullmatch(value): + raise ValueError(f"Invalid {label} SHA") + if not isinstance(bridge_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", bridge_sha256): + raise ValueError("Invalid native bridge digest") + if not isinstance(bridge_size, int) or bridge_size <= 0: + raise ValueError("Invalid native bridge size") + if run_id < 1 or attempt < 1 or event not in { + "push", "pull_request", "workflow_dispatch" + }: + raise ValueError("Invalid canonical GitHub Actions run identity") + names = parse_ctest_junit_bytes(junit_bytes) + return { + "schemaVersion": 1, + "kind": KIND, + "canonicalWorkflow": "Build ARSAS", + "workflowRunId": run_id, + "runAttempt": attempt, + "eventName": event, + "sourceSha": source_sha, + "engineSha": engine_sha, + "ardirecSha": ardirec_sha, + "buildProfile": PROFILE, + "ctestJunitSha256": hashlib.sha256(junit_bytes).hexdigest(), + "nativeBridgeSha256": bridge_sha256, + "nativeBridgeSizeBytes": bridge_size, + "testNames": names, + "nativeTestsPassed": len(names), + "nativeTestsFailed": 0, + "nativeTestsSkipped": 0, + "nativeCTestPassed": True, + "independentComtradeLaneReplaced": False, + "releasePromotionAuthority": False, + } + + +def verify_sealed_native_proof( + *, proof_bytes: bytes, junit_bytes: bytes, bridge_sha256: str, bridge_size: int, + source_sha: str, engine_sha: str, ardirec_sha: str, + run_id: int, attempt: int, event: str, +) -> dict: + """Verify sealed archive native evidence independently of its producer.""" + if len(proof_bytes) > 64 * 1024: + raise ValueError("Native CTest proof JSON exceeds bounded size") + try: + actual = json.loads(proof_bytes.decode("utf-8-sig")) + except (UnicodeError, ValueError) as exc: + raise ValueError("Native CTest proof JSON is not valid") from exc + expected = record_from_bytes( + junit_bytes=junit_bytes, bridge_sha256=bridge_sha256, + bridge_size=bridge_size, source_sha=source_sha, engine_sha=engine_sha, + ardirec_sha=ardirec_sha, run_id=run_id, attempt=attempt, event=event, + ) + if actual != expected: + raise ValueError("Native CTest archive proof identity/coverage/digests disagree") + return expected + + +def expected_proof( + *, junit: Path, bridge: Path, source_sha: str, engine_sha: str, + ardirec_sha: str, run_id: int, attempt: int, event: str, +) -> dict: + if not bridge.is_file() or bridge.stat().st_size < 1: + raise ValueError("Native bridge is missing or empty") + return record_from_bytes( + junit_bytes=junit.read_bytes(), bridge_sha256=digest(bridge), + bridge_size=bridge.stat().st_size, source_sha=source_sha, + engine_sha=engine_sha, ardirec_sha=ardirec_sha, + run_id=run_id, attempt=attempt, event=event, + ) + + +def write_or_verify(proof_path: Path, *, verify_only: bool, **kwargs) -> dict: + expected = expected_proof(**kwargs) + if verify_only: + try: + actual = json.loads(proof_path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeError, ValueError) as exc: + raise ValueError("Native CTest proof is missing or invalid") from exc + if actual != expected: + raise ValueError("Native CTest proof does not match exact tested bytes/identity") + else: + proof_path.parent.mkdir(parents=True, exist_ok=True) + proof_path.write_text( + json.dumps(expected, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return expected + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--junit", type=Path, required=True) + parser.add_argument("--bridge", 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("--run-id", type=int, required=True) + parser.add_argument("--attempt", type=int, required=True) + parser.add_argument("--event", required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--verify-only", action="store_true") + args = parser.parse_args() + proof = write_or_verify( + args.output, verify_only=args.verify_only, junit=args.junit, + bridge=args.bridge, source_sha=args.source_sha, + engine_sha=args.engine_sha, ardirec_sha=args.ardirec_sha, + run_id=args.run_id, attempt=args.attempt, event=args.event, + ) + print( + f"Native CTest proof PASS: {proof['nativeTestsPassed']} tests; " + f"bridge={proof['nativeBridgeSha256']}; " + "independent COMTRADE authority unchanged" + ) + 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..b396defa9 --- /dev/null +++ b/scripts/write-ci-package-manifest.py @@ -0,0 +1,319 @@ +#!/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, + portable_identity: 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, + release_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 portable_identity.is_file() or not installer_input.is_dir(): + raise FileNotFoundError("Portable, build identity, 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) + portable_identity_target = artifact_root / "portable" / portable_identity.name + shutil.copy2(portable_identity, portable_identity_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) + release_fixture = verification_target / PurePosixPath(release_fixture_relative) + for label, path in ( + ("native bridge", bridge), + ("test assembly", test_assembly), + ("COMTRADE fixture", comtrade_fixture), + ("locus fixture", locus_fixture), + ("release COMTRADE fixture", release_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) + portable_identity_rel = safe_relative(portable_identity_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) + release_rel = safe_relative(release_fixture, artifact_root) + + try: + build_identity = json.loads(portable_identity_target.read_text(encoding="utf-8-sig")) + except (OSError, ValueError) as exc: + raise ValueError("Portable build identity cannot be decoded") from exc + + if ( + build_identity.get("schemaVersion") != 1 + or build_identity.get("kind") != "arsas-portable-build-identity" + or build_identity.get("sourceCommit") != source_sha + or build_identity.get("engineCommit") != engine_sha + or build_identity.get("ardIrecLockCommit") != ardirec_sha + or build_identity.get("portableSha256") != by_path[portable_rel]["sha256"] + or build_identity.get("portableSizeBytes") != by_path[portable_rel]["size"] + or build_identity.get("ardIrecBridgeSha256") != by_path[bridge_rel]["sha256"] + or build_identity.get("ardIrecBridgeSizeBytes") != by_path[bridge_rel]["size"] + or build_identity.get("deterministicManagedBuild") is not True + or build_identity.get("reproducibleNativeLinkRequested") is not True + ): + raise ValueError("Portable build identity does not match sealed package authority") + + 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], + "portableIdentity": by_path[portable_identity_rel], + "nativeBridge": by_path[bridge_rel], + "verification": { + "testAssemblyPath": test_rel, + "comtradeFixturePath": comtrade_rel, + "locusFixturePath": locus_rel, + "releaseFixturePath": release_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("--portable-identity", 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", + ) + parser.add_argument( + "--release-fixture-relative", + default="fixtures/p1-release-smoke.cfg", + ) + args = parser.parse_args() + + manifest = stage_and_create_manifest( + artifact_root=args.artifact_root, + portable=args.portable, + portable_identity=args.portable_identity, + 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, + release_fixture_relative=args.release_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()) diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index ce96afb75..f2e110668 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -126,7 +126,8 @@ public void LiveSave_UsesPureWorkspaceReloadValidatorBeforeSuccess() Assert.Contains("File.Delete(path)", cleanup, StringComparison.Ordinal); Assert.Contains("workspaceService.Open(", validator, StringComparison.Ordinal); - Assert.Contains("IedName = canonical.IedName", validator, StringComparison.Ordinal); + Assert.Contains("IedName = effectiveIedName", validator, StringComparison.Ordinal); + Assert.Contains("verifiedIedName",validator,StringComparison.Ordinal); Assert.Contains("AccessPointName = canonical.AccessPointName", validator, StringComparison.Ordinal); Assert.Contains("endpoint.Port != 102", validator, StringComparison.Ordinal); Assert.Contains("reloadCoverage.LogicalDeviceCount != result.LogicalDeviceCount", validator, StringComparison.Ordinal); @@ -416,9 +417,63 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter Assert.Equal(495, closure.GetProperty("issue").GetInt32()); Assert.Equal("352c81e6a798635c6addcee0683235ca87ad416d", closure.GetProperty("baseEngineCommit").GetString()); + var p10 = root.GetProperty("p10IdentityTrial"); Assert.Equal(closure.GetProperty("exactCommit").GetString(), + p10.GetProperty("baseEngineCommit").GetString()); + Assert.Equal("00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1", p10.GetProperty("exactCommit").GetString()); + var coreTrial = root.GetProperty("coreReportRoutingTrial"); + Assert.Equal(p10.GetProperty("exactCommit").GetString(), + coreTrial.GetProperty("baseEngineCommit").GetString()); + var subscriptionTrial = root.GetProperty("reportSubscriberIntegrityTrial"); + Assert.Equal(coreTrial.GetProperty("exactCommit").GetString(), + subscriptionTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal("code-verified-not-physical", subscriptionTrial.GetProperty("status").GetString()); + Assert.True(subscriptionTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(subscriptionTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + // P10.9G remains the immutable CodeVerified ancestor of the current + // P10.9I consumer pin; do not treat the previous SHA as live head. + var canonicalTrial = root.GetProperty("canonicalReportIngressIntegrityTrial"); + Assert.Equal(subscriptionTrial.GetProperty("exactCommit").GetString(), + canonicalTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal("9bd01908ebd6fdf50a74f0a421b4e79613b878d4", + canonicalTrial.GetProperty("exactCommit").GetString()); + var zivTrial = root.GetProperty("zivSclBscInteroperabilityTrial"); + Assert.Equal(canonicalTrial.GetProperty("exactCommit").GetString(), + zivTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal("67c6635b7e16139c0661e559a2e6946527c1442a", + zivTrial.GetProperty("exactCommit").GetString()); + var genericTrial = root.GetProperty("genericControlInteroperabilityTrial"); + Assert.Equal(zivTrial.GetProperty("exactCommit").GetString(), + genericTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal(genericTrial.GetProperty("exactCommit").GetString(), root.GetProperty("commit").GetString()); - Assert.Equal(157, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("a43b9b4864a9565c02f544d4135fe157c2fac577", + genericTrial.GetProperty("exactCommit").GetString()); + Assert.Equal("code-verified-not-physical", canonicalTrial.GetProperty("status").GetString()); + Assert.Equal(166, canonicalTrial.GetProperty("enginePullRequest").GetInt32()); + Assert.Equal(536, canonicalTrial.GetProperty("issue").GetInt32()); + Assert.Equal(38214165576L, canonicalTrial.GetProperty("engineCiRun").GetInt64()); + Assert.True(canonicalTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(canonicalTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.True(canonicalTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean()); + Assert.True(canonicalTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); + Assert.Equal("code-verified-not-physical", zivTrial.GetProperty("status").GetString()); + Assert.Equal(538, zivTrial.GetProperty("issue").GetInt32()); + Assert.Equal(167, zivTrial.GetProperty("enginePullRequest").GetInt32()); + Assert.Equal(38219429896L, zivTrial.GetProperty("engineCiRun").GetInt64()); + Assert.True(zivTrial.GetProperty("sclApTitlePackedPrefixOnly").GetBoolean()); + Assert.True(zivTrial.GetProperty("bscLiveSemanticGateRequired").GetBoolean()); + Assert.True(zivTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(zivTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.True(zivTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean()); + Assert.True(zivTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); + Assert.Equal(168, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.True(genericTrial.GetProperty("typedSemanticGateRequired").GetBoolean()); + Assert.True(genericTrial.GetProperty("associationScopedInventoryRequired").GetBoolean()); + Assert.True(genericTrial.GetProperty("vendorDispatchForbidden").GetBoolean()); + Assert.True(genericTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); + Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); + Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(closure.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(closure.GetProperty("fullNameListFallbackForbidden").GetBoolean()); Assert.True(closure.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); diff --git a/tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs b/tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs new file mode 100644 index 000000000..2c256ae77 --- /dev/null +++ b/tests/ARSAS.Tests/ContextAwareIec61850DiagnosisTests.cs @@ -0,0 +1,143 @@ +using System.Net; +using System.Text; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// Deterministic IEC-aware decisions: no live NIC, IED, sockets or simulator. +/// Hardware/network routing and physical SOE completeness are separate tests. +/// +public sealed class ContextAwareIec61850DiagnosisTests +{ + private static readonly IPAddress Bcuge = IPAddress.Parse("192.16.1.33"); + + [Fact] + public void SamePhysicalInterfaceWithFiveAliases_DoesNotClaimFiveCompetingNics() + { + var addresses = Enumerable.Range(1, 5) + .Select(_ => (InterfaceId: "KM-TEST-GUID", AdapterName: "KM_Test")).ToArray(); + var description = DiagnosticReportBuilder.ExplainIpv4Route(Bcuge, addresses, false); + Assert.Contains("One network interface (KM_Test)", description); + Assert.Contains("5 IPv4 address(es)", description); + Assert.Contains("not competing adapters", description); + Assert.DoesNotContain("distinct network interfaces", description); + } + + [Fact] + public void DeviceIpIsLocalSimulatorEndpoint_DoesNotRecommendDisablingInterfaces() + { + var description = DiagnosticReportBuilder.ExplainIpv4Route( + Bcuge, + new[] { (InterfaceId: "KM-TEST-GUID", AdapterName: "KM_Test") }, + targetAssignedLocally: true); + Assert.Contains("assigned to this PC", description); + Assert.Contains("same-host IED simulator", description); + Assert.DoesNotContain("Disable", description, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("physically connected", description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void DifferentInterfacesWithSameFriendlyName_AreStillARealRoutingAmbiguity() + { + var description = DiagnosticReportBuilder.ExplainIpv4Route( + Bcuge, + new[] + { + (InterfaceId: "guid-1", AdapterName: "Ethernet"), + (InterfaceId: "guid-2", AdapterName: "Ethernet"), + (InterfaceId: "GUID-1", AdapterName: "Ethernet") + }, + targetAssignedLocally: false); + Assert.Contains("2 distinct network interfaces", description); + Assert.Contains("routing table", description); + } + + [Fact] + public void NoDirectSubnetMatch_DoesNotClaimGatewayRoutingImpossible() + { + var description = DiagnosticReportBuilder.ExplainIpv4Route( + Bcuge, Array.Empty<(string InterfaceId, string AdapterName)>(), false); + Assert.Contains("gateway or static route can still provide connectivity", description); + Assert.DoesNotContain("unreachable", description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BrcbLateRecurrence_IsNotWronglyExplainedAsStartupOnlyOrGiReset() + { + var activation = new DateTimeOffset(2026, 10, 12, 0, 26, 4, TimeSpan.Zero); + var stream = new Iec61850ReportContinuityStreamSnapshot + { + Rcb = "BCUGEF650/LLN0.BR.BRB_CSWI_GW010101", + Buffered = true, + Frames = 55, + Sequenced = 55, + Findings = 22, + SequenceDiscontinuities = 21, + SequenceWithGiReason = 1, + Overflow = 1, + ActivationReturnedAtUtc = activation, + LastAnomalyReceivedAtUtc = activation.AddSeconds(48.575) + }; + var output = DiagnosticReportBuilder.ExplainBrcbContinuity(stream); + Assert.Contains("GI-tagged=1, withoutConfirmedGI=20", output); + Assert.Contains("BufOvflAdvertised=1", output); + Assert.Contains("lastAnomalyAfterStartMs=48575", output); + Assert.Contains("late recurrence observed beyond 5s", output); + Assert.Contains("not TCP wire order", output); + Assert.DoesNotContain("events were lost", output); + + // Must also surface in ordinary user diagnostic, not only in helpers. + var builder = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(builder, + new Iec61850ReportContinuitySnapshot + { + Frames = 55, Sequenced = 55, Findings = 22, Streams = new[] { stream } + }); + Assert.Contains("BRCB context :", builder.ToString()); + Assert.Contains("NOT startup-only", builder.ToString()); + } + + [Fact] + public void StartupOnlyObservedFrames_AreNotPromotedToCausalCertainty() + { + var activation = DateTimeOffset.UtcNow; + var stream = new Iec61850ReportContinuityStreamSnapshot + { + Buffered = true, + Findings = 2, + SequenceDiscontinuities = 2, + SequenceWithGiReason = 0, + ActivationReturnedAtUtc = activation, + LastAnomalyReceivedAtUtc = activation.AddMilliseconds(250) + }; + var description = DiagnosticReportBuilder.ExplainBrcbContinuity(stream); + Assert.Contains("lastAnomalyAfterStartMs=250", description); + Assert.DoesNotContain("late recurrence", description); + Assert.Contains("not TCP wire order", description); + Assert.Contains("withoutConfirmedGI=2", description); + } + + [Fact] + public void UnknownOrReversedTimestamps_DoNotFabricateLateRecurrence() + { + var now = DateTimeOffset.UtcNow; + var noTiming = DiagnosticReportBuilder.ExplainBrcbContinuity( + new Iec61850ReportContinuityStreamSnapshot + { + Buffered = true, + SequenceDiscontinuities = 1 + }); + Assert.Contains("timing=UNKNOWN", noTiming); + var reversed = DiagnosticReportBuilder.ExplainBrcbContinuity( + new Iec61850ReportContinuityStreamSnapshot + { + Buffered = true, + SequenceDiscontinuities = 1, + ActivationReturnedAtUtc = now, + LastAnomalyReceivedAtUtc = now.AddSeconds(-1) + }); + Assert.Contains("timing=UNKNOWN", reversed); + Assert.DoesNotContain("late recurrence", reversed); + } +} diff --git a/tests/ARSAS.Tests/DiscoveryEndpointPreferenceRegressionTests.cs b/tests/ARSAS.Tests/DiscoveryEndpointPreferenceRegressionTests.cs new file mode 100644 index 000000000..0b079d1d2 --- /dev/null +++ b/tests/ARSAS.Tests/DiscoveryEndpointPreferenceRegressionTests.cs @@ -0,0 +1,144 @@ +using System.Text.Json; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// Test the actual persistence code using isolated temporary files, not the +/// operator's AppData. No MMS sockets, report controls, or process polling. +/// +public sealed class DiscoveryEndpointPreferenceRegressionTests +{ + [Fact] + public void LastSubmittedIpAndCustomPort_SurviveReload_AndPreserveExistingProfiles() + { + WithTemporaryPreferences(path => + { + File.WriteAllText(path, """ + {"SuccessfulRelays":[{"IpAddress":"10.0.0.44","MmsPort":102,"IedName":"OLD"}], + "SignalSelectionProfiles":[{"IedName":"IED1","IpAddress":"10.0.0.44", + "Port":102,"SelectedObjectReferences":["LD0/LLN0.Mod.stVal"]}], + "RecentRelayIps":["10.0.0.50"]} + """); + + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, " 192.16.1.33 ", 1102); + + Assert.True(UserPreferenceStore.TryLoadLastDiscoveryEndpoint( + path, out var storedIp, out var storedPort)); + Assert.Equal("192.16.1.33", storedIp); + Assert.Equal(1102, storedPort); + + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var prefs = document.RootElement; + Assert.Equal("OLD", prefs.GetProperty("SuccessfulRelays")[0].GetProperty("IedName").GetString()); + Assert.Equal("LD0/LLN0.Mod.stVal", + prefs.GetProperty("SignalSelectionProfiles")[0] + .GetProperty("SelectedObjectReferences")[0].GetString()); + Assert.Equal("10.0.0.50", prefs.GetProperty("RecentRelayIps")[0].GetString()); + }); + } + + [Fact] + public void SecondValidSubmissionWins_EvenIfNoSuccessfulConnectionWasRecorded() + { + WithTemporaryPreferences(path => + { + Assert.False(UserPreferenceStore.TryLoadLastDiscoveryEndpoint(path, out _, out _)); + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, "192.168.81.17", 102); + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, "192.16.1.33", 102); + + Assert.True(UserPreferenceStore.TryLoadLastDiscoveryEndpoint( + path, out var ip, out var port)); + Assert.Equal("192.16.1.33", ip); + Assert.Equal(102, port); + }); + } + + [Fact] + public void InvalidIpOrPort_DoesNotOverwriteLastValidEndpoint() + { + WithTemporaryPreferences(path => + { + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, "192.16.1.33", 102); + var expectedJson = File.ReadAllText(path); + + Assert.Throws(() => + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, "not-an-ip", 102)); + Assert.Throws(() => + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, "192.16.1.99", 0)); + Assert.Throws(() => + UserPreferenceStore.RecordLastDiscoveryEndpoint(path, "192.16.1.99", 65536)); + Assert.Equal(expectedJson, File.ReadAllText(path)); + }); + } + + [Fact] + public void CorruptOrInvalidPreferenceData_FailsClosedWithoutFabricatingIp() + { + WithTemporaryPreferences(path => + { + File.WriteAllText(path, "not-json"); + Assert.False(UserPreferenceStore.TryLoadLastDiscoveryEndpoint(path, out _, out _)); + + File.WriteAllText(path, """{"LastDiscoveryIpAddress":"not-an-ip","LastDiscoveryMmsPort":102}"""); + Assert.False(UserPreferenceStore.TryLoadLastDiscoveryEndpoint(path, out _, out _)); + + File.WriteAllText(path, """{"LastDiscoveryIpAddress":"192.16.1.33","LastDiscoveryMmsPort":65536}"""); + Assert.False(UserPreferenceStore.TryLoadLastDiscoveryEndpoint(path, out _, out _)); + }); + } + + [Fact] + public void Dialog_UsesPersistedEndpointAheadOfCallerSelection_AndCommitsOnlyValidSubmit() + { + var wizard = File.ReadAllText(Find("IpConnectWizardWindow.xaml.cs")); + var xaml = File.ReadAllText(Find("IpConnectWizardWindow.xaml")); + var mainWindow = File.ReadAllText(Find("MainWindow.xaml.cs")); + Assert.Contains("new IpConnectWizardWindow(initialIp, initialPort, preferLastDiscoveryEndpoint: true)", mainWindow); + Assert.Contains("new IpConnectWizardWindow(string.Empty, device.Port <= 0 ? 102 : device.Port)", mainWindow); + Assert.DoesNotContain("private string _newDeviceIp = \"192.168.1.10\";", mainWindow); + Assert.Contains("private string _newDeviceIp = string.Empty;", mainWindow); + Assert.Contains("bool preferLastDiscoveryEndpoint = false", wizard); + Assert.Contains("_rememberDiscoveryEndpoint = preferLastDiscoveryEndpoint;", wizard); + Assert.Contains("if (_rememberDiscoveryEndpoint &&", wizard); + Assert.Contains("if (_rememberDiscoveryEndpoint)", wizard); + Assert.Contains("TryLoadLastDiscoveryEndpoint(out var lastIp, out var lastPort)", wizard); + Assert.Contains("RelayIpAddress = lastIp;", wizard); + Assert.Contains("MmsPortText = lastPort.ToString(CultureInfo.InvariantCulture);", wizard); + Assert.Contains("RelayIpAddress = RelayIpBox.Text.Trim();", wizard); + Assert.Contains("RecordLastDiscoveryEndpoint(RelayIpAddress, MmsPort)", wizard); + var validateIp = wizard.IndexOf("System.Net.IPAddress.TryParse", StringComparison.Ordinal); + var validatePort = wizard.IndexOf("port is <= 0 or > 65535", StringComparison.Ordinal); + var commit = wizard.IndexOf("RecordLastDiscoveryEndpoint(RelayIpAddress, MmsPort)", StringComparison.Ordinal); + Assert.True(validateIp >= 0 && validateIp < validatePort && validatePort < commit); + Assert.Contains("IsEditable=\"True\" IsTextSearchEnabled=\"False\"", xaml); + Assert.Contains("UpdateSourceTrigger=LostFocus", xaml); + } + + private static void WithTemporaryPreferences(Action action) + { + var folder = Path.Combine(Path.GetTempPath(), "arsas-last-discovery-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + try + { + action(Path.Combine(folder, "preferences.json")); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + + private static string Find(string relative) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var file = Path.Combine(directory.FullName, relative); + if (File.Exists(file)) + return file; + directory = directory.Parent; + } + throw new FileNotFoundException(relative); + } +} diff --git a/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs b/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs index c4ba351e7..8565339f5 100644 --- a/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs +++ b/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs @@ -14,6 +14,9 @@ private static SignalDefinition Pos(string dataset, string reference) DataSetReference = dataset, IsControlSignal = true, IsSelected = true }; signal.ControlModelText = "Direct Operate (DO) • Normal security"; + // F650 tests canonical identity of already-qualified command objects. + // Production authority is granted only after ARIEC live semantic verification. + signal.ControlSemanticVerified = true; return signal; } diff --git a/tests/ARSAS.Tests/GooseEventHeaderLayoutTests.cs b/tests/ARSAS.Tests/GooseEventHeaderLayoutTests.cs new file mode 100644 index 000000000..d01900043 --- /dev/null +++ b/tests/ARSAS.Tests/GooseEventHeaderLayoutTests.cs @@ -0,0 +1,69 @@ +using System.Xml.Linq; + +namespace ARSAS.Tests; + +public sealed class GooseEventHeaderLayoutTests +{ + [Fact] + public void ActiveGooseEventToolbarUsesOneAlignedCompactRow() + { + var xaml = XDocument.Parse(Read("Views/GooseSubscriberLiteView.xaml")); + XNamespace w = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var header = xaml.Descendants(w + "Grid") + .Single(e => (string?)e.Attribute(x + "Name") == "GooseEventHeader"); + + Assert.Equal("30", (string?)header.Attribute("MinHeight")); + var columns = header.Element(w + "Grid.ColumnDefinitions")!.Elements(w + "ColumnDefinition").ToArray(); + Assert.Equal(new[] { "Auto", "*", "Auto" }, + columns.Select(c => (string?)c.Attribute("Width")).ToArray()); + var count = header.Descendants(w + "Border").Single(e => + (string?)e.Attribute(x + "Name") == "GooseEventCountBadge"); + Assert.Equal("21", (string?)count.Attribute("Height")); + Assert.Equal("Center", (string?)count.Attribute("VerticalAlignment")); + Assert.Equal("NoWrap", (string?)count.Descendants(w + "TextBlock").Single() + .Attribute("TextWrapping")); + Assert.Contains(header.Descendants(w + "TextBlock"), + e => (string?)e.Attribute("Text") == "GOOSE Events"); + } + + [Fact] + public void EventScopeFollowAndRetransmissionControlsRetainExistingContracts() + { + var xaml = XDocument.Parse(Read("Views/GooseSubscriberLiteView.xaml")); + XNamespace w = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + var header = xaml.Descendants(w + "Grid").Single(e => + (string?)e.Attribute(x + "Name") == "GooseEventHeader"); + var scope = header.Descendants(w + "ComboBox").Single(); + Assert.Equal("GooseEventScope", (string?)scope.Attribute(x + "Name")); + Assert.Equal("{Binding GooseIedScopeIndex, Mode=TwoWay}", + (string?)scope.Attribute("SelectedIndex")); + Assert.Equal("{StaticResource ModernComboBox}", (string?)scope.Attribute("Style")); + Assert.Equal(new[] { "Selected IED only", "All IED GOOSE signals" }, + scope.Elements(w + "ComboBoxItem").Select(i => (string?)i.Attribute("Content")).ToArray()); + var boxes = header.Descendants(w + "CheckBox").ToArray(); + Assert.Equal(2,boxes.Length); + Assert.Equal(new[] { + "{Binding FollowLatestGooseEvents, Mode=TwoWay}", + "{Binding ShowGooseRetransmissions, Mode=TwoWay}" + },boxes.Select(b => (string?)b.Attribute("IsChecked")).ToArray()); + Assert.All(boxes,b=>Assert.Equal("28",(string?)b.Attribute("Height"))); + var events = xaml.Descendants(w + "DataGrid").Single(g => + (string?)g.Attribute("ItemsSource") == "{Binding GooseVisibleEvents}"); + Assert.Equal("{Binding SelectedGooseEvent, Mode=TwoWay}", + (string?)events.Attribute("SelectedItem")); + } + + private static string Read(string path) + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir is not null) + { + var file = Path.Combine(dir.FullName,path.Replace('/',Path.DirectorySeparatorChar)); + if (File.Exists(file)) return File.ReadAllText(file); + dir = dir.Parent; + } + throw new FileNotFoundException(path); + } +} diff --git a/tests/ARSAS.Tests/P105StaticParityUniqueCoverageTests.cs b/tests/ARSAS.Tests/P105StaticParityUniqueCoverageTests.cs new file mode 100644 index 000000000..96c66b03e --- /dev/null +++ b/tests/ARSAS.Tests/P105StaticParityUniqueCoverageTests.cs @@ -0,0 +1,160 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// GE F650 field capture: 6 configured RCB plans had 166 binding incidences, +/// but only 67 unique requested runtime signals. A duplicate RCB binding must +/// neither be counted as an uncovered signal nor mask a genuinely missing point. +/// +public sealed class P105StaticParityUniqueCoverageTests +{ + [Theory] + [InlineData(3, true)] + [InlineData(4, false)] + public void OverlappingBrcbAndUrcb_UsesUniqueReferencesAndFc(int requested, bool expectedComparable) + { + var device = new Iec61850MonitorDevice + { + Name = "BCUGE", + IpAddress = "192.16.1.33", + LiveDiscoveryModel = new LiveIedModelDiscoveryDocument + { + IedName = "BCUGE", + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "BCUGEF650/LLN0.Events", + Domain = "BCUGEF650", + LogicalNode = "LLN0", + Name = "Events", + MemberCount = 3, + Members = + [ + new LiveIedDataSetMemberModel { Index = 0, Reference = "BCUGEF650/XCBR1.Pos.stVal", FunctionalConstraint = "ST" }, + new LiveIedDataSetMemberModel { Index = 1, Reference = "BCUGEF650/XCBR2.Pos.stVal", FunctionalConstraint = "ST" }, + new LiveIedDataSetMemberModel { Index = 2, Reference = "BCUGEF650/XCBR3.Pos.stVal", FunctionalConstraint = "ST" } + ] + } + ] + } + }; + static Iec61850MonitorPoint Point(string reference, string fc = "ST") => new() + { + DeviceId = "ge-f650", + IecReference = reference, + FunctionalConstraint = fc + }; + var first = "BCUGEF650/XCBR1.Pos.stVal"; + var second = "BCUGEF650/XCBR2.Pos.stVal"; + var third = "BCUGEF650/XCBR3.Pos.stVal"; + + var planning = new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + RequestedPointCount = requested, + CatalogMappedPointCount = requested, + StaticBrcbSignalCount = 2, + StaticUrcbSignalCount = 2, + UncoveredSignalCount = 0, + ReportPlans = + [ + new ReportControlPlan + { + IsEngineAuthoritative = true, + EngineAcquisitionKind = "StaticBrcb", + Buffered = true, + DataSetReference = "BCUGEF650/LLN0.Events", + ReportControlReference = "BCUGEF650/LLN0.BR.Rpt01", + Bindings = [Point(first), Point(second)] + }, + new ReportControlPlan + { + IsEngineAuthoritative = true, + EngineAcquisitionKind = "StaticUrcb", + Buffered = false, + DataSetReference = "BCUGEF650/LLN0.Events", + ReportControlReference = "BCUGEF650/LLN0.RP.Rpt01", + Bindings = [Point(first), Point(third)] + } + ] + }; + + var evidence = StaticAcquisitionParityTracker.BuildEvidence( + device, planning, StaticAcquisitionIngressKind.LiveDiscovery); + + Assert.Equal(expectedComparable, evidence.IsComparable); + if (expectedComparable) + { + Assert.DoesNotContain("coverage", evidence.IncomparableReason, StringComparison.Ordinal); + Assert.Equal(StaticAcquisitionParityStatus.Equivalent, + StaticAcquisitionParityTracker.Compare(evidence, evidence).Status); + } + else + { + // Raw 2+2 binding counters superficially equal requested=4, but + // unique referenced points are only three; never award a false MATCH. + Assert.Contains("unique=3", evidence.IncomparableReason, StringComparison.Ordinal); + Assert.Equal(StaticAcquisitionParityStatus.InsufficientEvidence, + StaticAcquisitionParityTracker.Compare(evidence, evidence).Status); + } + } + + [Theory] + [InlineData("BCUGEF650/CSWI1.Pos.stVal", "CSWI Pos", "CSWI1 Pos")] + [InlineData("BCUGEF650/CSWI16.Pos.stVal", "CSWI Pos", "CSWI16 Pos")] + [InlineData("BCUGEF650/MMXU1.A.phsA.cVal.mag.f", "MMXU A", "MMXU1 A")] + [InlineData("BCUGEF650/CSWI1.Pos.stVal", "CSWI1 Pos", "CSWI1 Pos")] + [InlineData("not-a-logical-node", "Equipment status", "Equipment status")] + public void ExplorerSignalLabel_UsesExactLnInstanceWithoutChangingMmsReference( + string reference, string signalName, string expected) + { + var point = new Iec61850MonitorPoint + { + DeviceId = "ge", + DeviceName = "BCUGE", + IecReference = reference, + SignalName = signalName + }; + Assert.Equal(expected, point.ExplorerSignalName); + Assert.Equal(reference, point.IecReference); + Assert.Equal(signalName, point.SignalName); + } + + [Fact] + public void ExplorerGrid_BindsQualifiedLabelNotBareSignalName() + { + var xaml = File.ReadAllText(FindRepoFile("MainWindow.xaml")); + Assert.Contains("Header=\"Signal\" Binding=\"{Binding ExplorerSignalName}\"", xaml, StringComparison.Ordinal); + } + + [Fact] + public void MalformedBindingFc_RemainsIncomparableDespiteExactIncidenceCounters() + { + var source = File.ReadAllText(FindRepoFile("Services/StaticAcquisitionParityTracker.cs")); + Assert.Contains("uniqueSelectedPoints.UnionWith(selected)", source, StringComparison.Ordinal); + Assert.Contains("incomplete selected signal identity or FC evidence", source, StringComparison.Ordinal); + Assert.Contains("static plan bindings do not match engine coverage counters", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + var candidate = Path.Combine(current.FullName, relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(candidate)) + return candidate; + var nested = Path.Combine(current.FullName, "ArIED61850Tester", + relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(nested)) + return nested; + current = current.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/P106RememberedTrustedDiscoveryIdentityTests.cs b/tests/ARSAS.Tests/P106RememberedTrustedDiscoveryIdentityTests.cs new file mode 100644 index 000000000..681c24ee2 --- /dev/null +++ b/tests/ARSAS.Tests/P106RememberedTrustedDiscoveryIdentityTests.cs @@ -0,0 +1,227 @@ +using System.Security.Cryptography; +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// IP-only Discovery identity MUST be proved by a live, exact-domain, +/// SHA-verified engineering IID previously accepted on a successful SCL/MMS association. +public sealed class P106RememberedTrustedDiscoveryIdentityTests +{ + [Fact] + public async Task GE_F650_PriorSuccessfulSclAssociation_PromotesIpDiscoveryToBCUGE() + { + using var fixture = new Fixture(); + var source = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33"); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, source.Sha, + "BCUGE", "S1", "192.16.1.33", 102, source.Path); + + var device = Device("192.16.1.33"); + var original = Model("BCUGEF650"); + var result = await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + device, original, bindingStorePath: fixture.Store); + + Assert.NotNull(result); + Assert.Equal("BCUGE", result.Identity.IedName); + Assert.Equal("S1", result.AccessPointName); + Assert.Equal(source.Sha, result.SourceSha256, ignoreCase: true); + var consumer = TrustedSclIdentityAuthority.ToConsumerIdentity( + result.Identity, ["BCUGEF650"]); + Assert.Equal("BCUGE", consumer.IedName); + Assert.Equal("F650", Assert.Single(consumer.LogicalDevices).Instance); + Assert.Equal("BCUGEF650", Assert.Single(consumer.LogicalDevices).Domain); + Assert.Equal("IED_192_16_1_33", original.IedName); // engine model not rewritten + Assert.Null(device.SclWorkspace); // Discovery reporting remains MMS-owned + } + + [Fact] + public async Task NameIsNeverGuessedFromIpOrLoneMmsDomain() + { + using var fixture = new Fixture(); + Assert.Null(await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + Device("192.16.1.33"), Model("BCUGEF650"), bindingStorePath: fixture.Store)); + } + + [Fact] + public async Task LegacySuccessfulEndpointWithoutSourcePath_IsNotNameAuthority() + { + using var fixture = new Fixture(); + var source = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33"); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, source.Sha, + "BCUGE", "S1", "192.16.1.33", 102); + Assert.Null(await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + Device("192.16.1.33"), Model("BCUGEF650"), bindingStorePath: fixture.Store)); + } + + [Theory] + [InlineData("192.16.1.34", "BCUGEF650")] + [InlineData("192.16.1.33", "ANOTHERDOMAIN")] + public async Task WrongEndpointOrDomain_FailsClosed(string ip, string domain) + { + using var fixture = new Fixture(); + var source = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33"); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, source.Sha, + "BCUGE", "S1", "192.16.1.33", 102, source.Path); + Assert.Null(await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + Device(ip), Model(domain), bindingStorePath: fixture.Store)); + } + + [Fact] + public async Task ModifiedTrustedIid_RejectsSourceEvenIfXmlTopologyStillMatches() + { + using var fixture = new Fixture(); + var source = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33"); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, source.Sha, + "BCUGE", "S1", "192.16.1.33", 102, source.Path); + File.AppendAllText(source.Path, " "); + Assert.Null(await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + Device("192.16.1.33"), Model("BCUGEF650"), bindingStorePath: fixture.Store)); + } + + [Fact] + public async Task MultipleMatchingEngineeringSources_CannotSilentlySelectMostRecent() + { + using var fixture = new Fixture(); + var first = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33"); + var other = fixture.NewIid("OTHER", "S1", "F650", "192.16.1.33", + explicitDomain: "BCUGEF650"); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, first.Sha, + "BCUGE", "S1", "192.16.1.33", 102, first.Path); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, other.Sha, + "OTHER", "S1", "192.16.1.33", 102, other.Path); + Assert.Null(await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + Device("192.16.1.33"), Model("BCUGEF650"), bindingStorePath: fixture.Store)); + } + + [Fact] + public async Task MultiAccessPointIid_IsNotGuessedFromTheRememberedAp() + { + using var fixture = new Fixture(); + var source = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33", + extraAccessPoint: true); + SclEndpointBindingStore.RecordVerifiedAtPath(fixture.Store, source.Sha, + "BCUGE", "S1", "192.16.1.33", 102, source.Path); + Assert.Null(await TrustedSclIdentityAuthority.TryMatchRememberedAsync( + Device("192.16.1.33"), Model("BCUGEF650"), bindingStorePath: fixture.Store)); + } + + [Fact] + public void VerifiedIdentityAllowsCanonicalParityWithoutMutatingIpFallbackModel() + { + using var fixture = new Fixture(); + var source = fixture.NewIid("BCUGE", "S1", "F650", "192.16.1.33"); + var device = Device("192.16.1.33"); + device.Name = "BCUGE"; + device.LiveDiscoveryModel = new LiveIedModelDiscoveryDocument + { + IedName = "IED_192_16_1_33", + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "BCUGEF650" }], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "BCUGEF650/LLN0.Events", + Domain = "BCUGEF650", + LogicalNode = "LLN0", + Name = "Events", + MemberCount = 1, + Members = [new LiveIedDataSetMemberModel { + Index = 0, Reference = "BCUGEF650/XCBR1.Pos.stVal", FunctionalConstraint = "ST" }] + } + ] + }; + var plan = new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + RequestedPointCount = 1, + StaticBrcbSignalCount = 1, + ReportPlans = [new ReportControlPlan + { + IsEngineAuthoritative = true, EngineAcquisitionKind = "StaticBrcb", + Buffered = true, DataSetReference = "BCUGEF650/LLN0.Events", + ReportControlReference = "BCUGEF650/LLN0.BR.Events1", + Bindings = [new Iec61850MonitorPoint { + DeviceId = "ge", IecReference = "BCUGEF650/XCBR1.Pos.stVal", + FunctionalConstraint = "ST" }] + }] + }; + + var noProof = StaticAcquisitionParityTracker.BuildEvidence( + device, plan, StaticAcquisitionIngressKind.LiveDiscovery); + Assert.False(noProof.IsComparable); + Assert.Contains("IED identity", noProof.IncomparableReason, StringComparison.Ordinal); + + // Identity evidence is set only by the trusted matcher at successful Discovery. + var xml = System.Xml.Linq.XDocument.Load(source.Path); + var proof = TrustedSclIedIdentityMatcher.TryMatch( + xml, ["BCUGEF650"], "192.16.1.33", "BCUGE"); + Assert.NotNull(proof); + device.TrustedLiveIdentity = proof; + var verified = StaticAcquisitionParityTracker.BuildEvidence( + device, plan, StaticAcquisitionIngressKind.LiveDiscovery); + Assert.True(verified.IsComparable, verified.IncomparableReason); + Assert.Equal("IED_192_16_1_33", device.LiveDiscoveryModel.IedName); + + // A verified IED name is semantic metadata only; the exact same selected + // DataSet plan must now fingerprint identically to Open SCL. + device.SclWorkspace = new SclIedWorkspace + { + IedName = "BCUGE", + AccessPointName = "S1", + DesignModel = new LiveIedModelDiscoveryDocument + { + IedName = "BCUGE", + LogicalDevices = device.LiveDiscoveryModel.LogicalDevices, + DataSets = device.LiveDiscoveryModel.DataSets + } + }; + var openScl = StaticAcquisitionParityTracker.BuildEvidence( + device, plan, StaticAcquisitionIngressKind.OpenScl); + Assert.True(openScl.IsComparable, openScl.IncomparableReason); + Assert.Equal(verified.SemanticFingerprint, openScl.SemanticFingerprint); + Assert.Equal(StaticAcquisitionParityStatus.Equivalent, + StaticAcquisitionParityTracker.Compare(verified, openScl).Status); + } + + private static Iec61850MonitorDevice Device(string ip) => new() + { + Name = "IED_192_16_1_33", IpAddress = ip, Port = 102 + }; + + private static LiveIedModelDiscoveryDocument Model(string domain) => new() + { + Host = "192.16.1.33", + IedName = "IED_192_16_1_33", + LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = domain, Inst = domain }] + }; + + private sealed class Fixture : IDisposable + { + private readonly string _directory = Path.Combine(Path.GetTempPath(), + "arsas-p106-" + Guid.NewGuid().ToString("N")); + public Fixture() => Directory.CreateDirectory(_directory); + public string Store => Path.Combine(_directory, "bindings.json"); + + public (string Path, string Sha) NewIid( + string ied, string ap, string ld, string ip, + string? explicitDomain = null, bool extraAccessPoint = false) + { + var path = Path.Combine(_directory, Guid.NewGuid().ToString("N") + ".iid"); + var ldName = explicitDomain is null ? "" : " ldName=\"" + explicitDomain + "\""; + var extra = extraAccessPoint + ? "" + : ""; + var xml = $""" + +

{ip}

+ {extra} +
+ """; + File.WriteAllText(path, xml); + return (path, Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant()); + } + public void Dispose() => Directory.Delete(_directory, recursive: true); + } +} diff --git a/tests/ARSAS.Tests/P107BrcbEvidenceTriageTests.cs b/tests/ARSAS.Tests/P107BrcbEvidenceTriageTests.cs new file mode 100644 index 000000000..5fd64c9a5 --- /dev/null +++ b/tests/ARSAS.Tests/P107BrcbEvidenceTriageTests.cs @@ -0,0 +1,210 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// Deterministic, lab-origin protocol-fact fixtures derived from GE simulator +/// observations. Counts are evidence only; they do not certify lossless SOE. +/// +public sealed class P107BrcbEvidenceTriageTests +{ + private static NativeReportFrameMetadata Frame( + ulong? sq, string entry = "", bool? giReason = null, + bool? overflow = null, ulong? sub = null, bool? more = null) + => new() + { + SequenceNumber = sq, + EntryIdHex = entry, + // Synthetic wire-grounded metadata: both decoded OptionFields and + // reported values must be present to compare EntryID cursors. + OptFldsRawHex = "7F80", + OptFldsSequenceNumber = true, + OptFldsEntryId = true, + OptFldsBufferOverflow = true, + OptFldsConfRev = true, + GeneralInterrogationReasonSeen = giReason, + BufferOverflow = overflow, + SubSequenceNumber = sub, + MoreSegmentsFollow = more, + ReceivedAt = DateTimeOffset.Parse("2026-10-11T04:45:43+00:00") + }; + + [Fact] + public void Brcb_BackwardSqWithNewEntryIdAndGIAndOverflow_StaysTwoFindingsOneBadFrame() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + Assert.Empty(Iec61850ReportContinuityInspector.Observe(state, Frame(12, "AABB"), true)); + var findings = Iec61850ReportContinuityInspector.Observe( + state, Frame(1, "CCDD", giReason: true, overflow: true), true); + + Assert.Equal(2, findings.Count); // BufOvfl and backward/reset/replay remain visible + Assert.Contains(findings, f => f.Contains("BufOvfl=true", StringComparison.Ordinal)); + Assert.Contains(findings, f => f.Contains("no event-loss conclusion", StringComparison.Ordinal)); + Assert.Equal(1, state.AnomalousFrames); + Assert.Equal(2, state.AnomalyFindings); + Assert.Equal(1, state.SequenceDiscontinuities); + Assert.Equal(1, state.SequenceWithChangedEntryId); + Assert.Equal(0, state.SequenceWithRepeatedEntryId); + Assert.Equal(0, state.SequenceWithoutComparableEntryId); + Assert.Equal(1, state.SequenceWithGiReason); + Assert.Equal(1, state.SequenceWithOverflow); + Assert.Equal(1, state.BufferOverflows); + } + + [Fact] + public void RepeatedSqAndIdenticalOpaqueEntryID_RemainsAmbiguousNotCertifiedReplay() + { + var state = new Iec61850ReportContinuityState(); + Assert.Empty(Iec61850ReportContinuityInspector.Observe(state, Frame(0, "CAFE"), true)); + var findings = Iec61850ReportContinuityInspector.Observe(state, Frame(0, "CAFE"), true); + Assert.Single(findings); + Assert.Contains("duplicate/replay", findings[0], StringComparison.Ordinal); + Assert.Contains("changed=False", findings[0], StringComparison.Ordinal); + Assert.Equal(1, state.SequenceWithRepeatedEntryId); + Assert.Equal(0, state.SequenceWithChangedEntryId); + Assert.Equal(0, state.SequenceWithGiReason); + } + + [Fact] + public void MissingEntryID_IsCountedSeparatelyWithoutInferringMissingEvents() + { + var state = new Iec61850ReportContinuityState(); + Iec61850ReportContinuityInspector.Observe(state, Frame(4, "CAFE"), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(1), true); + Assert.Equal(1, state.SequenceDiscontinuities); + Assert.Equal(1, state.SequenceWithoutComparableEntryId); + Assert.Equal(0, state.SequenceWithChangedEntryId); + } + + [Fact] + public void DecodedEntryIdWithoutRawOptionFields_StaysUncomparable() + { + var noWireProvenance = new NativeReportFrameMetadata + { + SequenceNumber = 4, + EntryIdHex = "CAFE" + }; + var state = new Iec61850ReportContinuityState(); + Iec61850ReportContinuityInspector.Observe(state, noWireProvenance, true); + Iec61850ReportContinuityInspector.Observe(state, Frame(1, "BEEF"), true); + Assert.Equal(1, state.SequenceDiscontinuities); + Assert.Equal(1, state.SequenceWithoutComparableEntryId); + Assert.Equal(0, state.SequenceWithChangedEntryId); + + var next = new NativeReportFrameMetadata + { + SequenceNumber = 0, + EntryIdHex = "DEAD" + }; + Iec61850ReportContinuityInspector.Observe(state, next, true); + Assert.Equal(2, state.SequenceWithoutComparableEntryId); + Assert.Equal(0, state.SequenceWithChangedEntryId); + } + + [Fact] + public void MissingIntermediateEntryId_CannotBridgeNonAdjacentResumeCursors() + { + var state = new Iec61850ReportContinuityState(); + Iec61850ReportContinuityInspector.Observe(state, Frame(5, "AAAA"), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(6), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(1, "BBBB"), true); + Assert.Equal(1, state.SequenceDiscontinuities); + Assert.Equal(1, state.SequenceWithoutComparableEntryId); + Assert.Equal(0, state.SequenceWithChangedEntryId); + } + + [Fact] + public void GiWriteWithoutDecodedGiReason_DoesNotCountAsFrameGiCorrelation() + { + var state = new Iec61850ReportContinuityState { GiWriteAccepted = true }; + Iec61850ReportContinuityInspector.Observe(state, Frame(4, "AA"), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(0, "BB"), true); + Assert.Equal(1, state.SequenceDiscontinuities); + Assert.Equal(0, state.SequenceWithGiReason); + } + + [Fact] + public void HealthyIncrementAndRollover_AreNotManufacturedAsSequenceAnomalies() + { + var state = new Iec61850ReportContinuityState(); + foreach (var sq in new ulong[] { 65535, 0, 1 }) + Assert.Empty(Iec61850ReportContinuityInspector.Observe( + state, Frame(sq, sq.ToString("X4")), true)); + Assert.Equal(0, state.AnomalousFrames); + Assert.Equal(0, state.SequenceDiscontinuities); + } + + [Fact] + public void SegmentMetadataFault_IncrementsBadFrameButNotSqDiscontinuity() + { + var state = new Iec61850ReportContinuityState(); + var warnings = Iec61850ReportContinuityInspector.Observe( + state, Frame(12, sub: 2, more: false), true); + Assert.Contains(warnings, w => w.Contains("starts at SubSqNum=2", StringComparison.Ordinal)); + Assert.Equal(1, state.AnomalousFrames); + Assert.Equal(0, state.SequenceDiscontinuities); + } + + [Fact] + public void SnapshotAndDiagnostics_SeparateFindingsFromFramesAndOpaqueEntryIdKinds() + { + var state = new Iec61850ReportContinuityState + { + ReportControlReference = "BCUGEF650/LLN0.BR.rpt01", + DataSetReference = "BCUGEF650/LLN0.CSWI", + Buffered = true + }; + Iec61850ReportContinuityInspector.Observe(state, Frame(3, "A0"), true); + Iec61850ReportContinuityInspector.Observe( + state, Frame(1, "B0", giReason: true, overflow: true), true); + var report = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["rpt"] = state }, 67, 0); + Assert.Equal(2, report.Findings); + Assert.Equal(1, report.AnomalousFrames); + Assert.Equal(1, report.SequenceDiscontinuities); + Assert.Equal(1, report.Streams[0].SequenceWithChangedEntryId); + Assert.Equal(1, report.Streams[0].SequenceWithGiReason); + Assert.Equal(1, report.Streams[0].SequenceWithOverflow); + + var sb = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(sb, report); + var text = sb.ToString(); + Assert.Contains("sequenceDiscontinuities=1", text, StringComparison.Ordinal); + Assert.Contains("sqChangedEntryID=1", text, StringComparison.Ordinal); + Assert.Contains("sqWithGIreason=1", text, StringComparison.Ordinal); + Assert.Contains("sqWithBufOvfl=1", text, StringComparison.Ordinal); + Assert.Contains("not an event-loss", text, StringComparison.Ordinal); + + // A published copy must not follow later mutation of the live association. + Iec61850ReportContinuityInspector.Observe(state, Frame(1, "B0"), true); + Assert.Equal(1, report.SequenceDiscontinuities); + Assert.Equal(2, report.Findings); + } + + [Fact] + public void DiagnosticOnly_LeavesMmsReportAndControlWireContractsUntouched() + { + var source = ReadRepoFile("Services/Iec61850ReportContinuityInspector.cs"); + Assert.DoesNotContain("ReadMms", source, StringComparison.Ordinal); + Assert.DoesNotContain("WriteMms", source, StringComparison.Ordinal); + Assert.DoesNotContain("StartReport", source, StringComparison.Ordinal); + Assert.Contains("RecordSequenceCorrelation();", source, StringComparison.Ordinal); + var runtime = ReadRepoFile("Services/Iec61850MonitorRuntime.cs"); + Assert.Contains("session.ReportStreams.Clear()", runtime, StringComparison.Ordinal); + Assert.Contains("maxTrackedStreams = 64", runtime, StringComparison.Ordinal); + } + + private static string ReadRepoFile(string path) + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir is not null) + { + var found = Path.Combine(dir.FullName, path); + if (File.Exists(found)) return File.ReadAllText(found); + dir = dir.Parent; + } + throw new FileNotFoundException(path); + } +} diff --git a/tests/ARSAS.Tests/P108BBoundedReportChronologyTests.cs b/tests/ARSAS.Tests/P108BBoundedReportChronologyTests.cs new file mode 100644 index 000000000..37f228a45 --- /dev/null +++ b/tests/ARSAS.Tests/P108BBoundedReportChronologyTests.cs @@ -0,0 +1,214 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// P10.8B: fixed-capacity BRCB receive-order metadata for offline correlation. +/// This is not a packet sniffer or proof that the IED delivered every SOE event. +/// +public sealed class P108BBoundedReportChronologyTests +{ + private static readonly DateTimeOffset Epoch = + DateTimeOffset.Parse("2026-10-11T06:25:51.000+00:00"); + + private static NativeReportFrameMetadata Frame( + ulong sq, string entryId, int ms, + bool? gi = false, bool? overflow = false, bool rawProven = true) => new() + { + SequenceNumber = sq, + EntryIdHex = entryId, + ReceivedAt = Epoch.AddMilliseconds(ms), + OptFldsRawHex = rawProven ? "7F80" : "", + OptFldsSequenceNumber = rawProven ? true : null, + OptFldsEntryId = rawProven ? true : null, + OptFldsBufferOverflow = rawProven ? true : null, + OptFldsConfRev = rawProven ? true : null, + BufferOverflow = overflow, + GeneralInterrogationReasonSeen = gi, + ConfRev = 5 + }; + + private static Iec61850ReportContinuityStreamSnapshot Only( + Iec61850ReportContinuityState state) + => Assert.Single(Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["r"] = state }, 0, 0).Streams); + + [Fact] + public void SixtyReports_PreservesFirst32AndLast8InReceiveOrderOnly() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + for (var index = 0; index < 60; index++) + Assert.Empty(Iec61850ReportContinuityInspector.Observe( + state, Frame((ulong)index, index.ToString("X4"), index * 100), true)); + var evidence = Only(state); + Assert.Equal(32, evidence.ChronologyInitialCount); + Assert.Equal(8, evidence.ChronologyRecentCount); + Assert.Equal(20, evidence.ChronologyOmittedFrames); + Assert.Equal(40, evidence.Chronology.Count); + Assert.Equal(Enumerable.Range(1, 32).Concat(Enumerable.Range(53, 8)) + .Select(index => (long)index), + evidence.Chronology.Select(entry => entry.FrameOrdinal)); + Assert.Equal("unverified", evidence.Chronology[0].EntryIdRelation); + Assert.Equal("changed", evidence.Chronology[1].EntryIdRelation); + Assert.Equal("changed", evidence.Chronology[^1].EntryIdRelation); + Assert.Equal(Epoch.AddMilliseconds(5900), evidence.Chronology[^1].ReceivedAtUtc); + + var text = Format(state); + Assert.Contains("first=32, recent=8, omittedMiddle=20", text, StringComparison.Ordinal); + Assert.Contains("frame#60:", text, StringComparison.Ordinal); + Assert.DoesNotContain("frame#40:", text, StringComparison.Ordinal); + Assert.Contains("receive-order metadata only", text, StringComparison.Ordinal); + } + + [Fact] + public void FirstAndLastRangesDoNotDuplicateFramesAtCapacityBoundary() + { + foreach (var count in new[] { 1, 31, 32, 33, 39, 40, 41 }) + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + for (var index = 0; index < count; index++) + Iec61850ReportContinuityInspector.Observe( + state, Frame((ulong)index, index.ToString("X4"), index), true); + + var evidence = Only(state); + Assert.Equal(Math.Min(32, count), evidence.ChronologyInitialCount); + Assert.Equal(Math.Min(8, Math.Max(0, count - 32)), evidence.ChronologyRecentCount); + Assert.Equal(Math.Max(0, count - 40), evidence.ChronologyOmittedFrames); + Assert.Equal(Math.Min(count, 40), evidence.Chronology.Count); + Assert.Equal(evidence.Chronology.Count, + evidence.Chronology.Select(f => f.FrameOrdinal).Distinct().Count()); + } + } + + [Fact] + public void DuplicateSqWithRepeatedEntryId_StillRaisesWarningAndRecordsOneFrame() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + Iec61850ReportContinuityInspector.Observe(state, Frame(4, "AABB", 0), true); + var findings = Iec61850ReportContinuityInspector.Observe( + state, Frame(4, "AABB", 100, gi: true, overflow: true), true); + Assert.Equal(2, findings.Count); + var evidence = Only(state); + Assert.Equal(2, evidence.Chronology.Count); + Assert.Equal("repeated", evidence.Chronology[1].EntryIdRelation); + Assert.Equal(2, evidence.Chronology[1].FindingCount); + Assert.True(evidence.Chronology[1].GiReason); + Assert.True(evidence.Chronology[1].BufferOverflow); + Assert.Equal(1, evidence.Overflow); + Assert.Equal(1, evidence.SequenceDiscontinuities); + } + + [Fact] + public void MissingRawOptionFieldsCannotInventComparableEntryId() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + Iec61850ReportContinuityInspector.Observe( + state, Frame(4, "ABCD", 0, rawProven: false), true); + Iec61850ReportContinuityInspector.Observe( + state, Frame(1, "1234", 100), true); + Iec61850ReportContinuityInspector.Observe( + state, Frame(2, "1234", 200), true); + var entries = Only(state).Chronology; + Assert.Equal("unverified", entries[0].EntryIdRelation); + Assert.Equal("unverified", entries[1].EntryIdRelation); + Assert.Equal("repeated", entries[2].EntryIdRelation); + Assert.Equal(1, Only(state).SequenceWithoutComparableEntryId); + } + + [Fact] + public void ChronologyNeverContainsTheRawBrcbEntryIdOrProcessValues() + { + var state = new Iec61850ReportContinuityState + { + Buffered = true, + ReportControlReference = "BCUGEF650/LLN0.BR.BRB_CSWI_GW010101" + }; + Iec61850ReportContinuityInspector.Observe( + state, Frame(4, "DEADBEEF0123456789", 0), true); + Iec61850ReportContinuityInspector.Observe( + state, Frame(1, "CAFED00DFEED000011", 100), true); + var text = Format(state); + Assert.Contains("EntryIDrelation=unverified", text, StringComparison.Ordinal); + Assert.DoesNotContain("DEADBEEF0123456789", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("CAFED00DFEED000011", text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void UnbufferedReportingDoesNotAllocateChronologyHistory() + { + var state = new Iec61850ReportContinuityState { Buffered = false }; + for (var index = 0; index < 50; index++) + Iec61850ReportContinuityInspector.Observe( + state, Frame((ulong)index, "", index), false); + var snap = Only(state); + Assert.Empty(snap.Chronology); + Assert.Null(state.InitialChronology); + Assert.Null(state.RecentChronology); + Assert.DoesNotContain("BRCB chronology", Format(state), StringComparison.Ordinal); + } + + [Fact] + public void SnapshotIsAnImmutableCopyDespiteLaterRingWrap() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + for (var index = 0; index < 50; index++) + Iec61850ReportContinuityInspector.Observe( + state, Frame((ulong)index, index.ToString("X4"), index * 100), true); + var frozen = Only(state); + Assert.Equal(10, frozen.ChronologyOmittedFrames); + var tail = frozen.Chronology[^1]; + for (var index = 50; index < 90; index++) + Iec61850ReportContinuityInspector.Observe( + state, Frame((ulong)index, index.ToString("X4"), index * 100), true); + Assert.Equal(tail, frozen.Chronology[^1]); + Assert.Equal(50, frozen.Chronology[^1].FrameOrdinal); + Assert.Equal(90, Only(state).Chronology[^1].FrameOrdinal); + Assert.Equal(50, frozen.Frames); + } + + [Fact] + public void UnknownDecoderTimestampIsNotSubstitutedByWallClock() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + Iec61850ReportContinuityInspector.Observe(state, + new NativeReportFrameMetadata + { + SequenceNumber = 10, + EntryIdHex = "000A", + ReceivedAt = default + }, true); + var evidence = Only(state); + Assert.Null(evidence.Chronology[0].ReceivedAtUtc); + Assert.Contains("receivedUtc=unknown", Format(state), StringComparison.Ordinal); + } + + [Fact] + public void DistinctBrcbStreamsRetainIndependentBoundedChronologies() + { + var a = new Iec61850ReportContinuityState { Buffered = true, ReportControlReference = "A" }; + var b = new Iec61850ReportContinuityState { Buffered = true, ReportControlReference = "B" }; + Iec61850ReportContinuityInspector.Observe(a, Frame(0, "00", 0), true); + for (var index = 0; index < 60; index++) + Iec61850ReportContinuityInspector.Observe( + b, Frame((ulong)index, index.ToString("X4"), index), true); + var snapshot = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["a"] = a, ["b"] = b }, + 0, 0, maxShown: 1); + Assert.Equal(2, snapshot.StreamCount); + Assert.Single(snapshot.Streams); + Assert.Single(snapshot.Streams[0].Chronology); + Assert.Equal("A", snapshot.Streams[0].Rcb); + } + + private static string Format(Iec61850ReportContinuityState state) + { + var sb = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(sb, + Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["one"] = state }, + 0, 0)); + return sb.ToString(); + } +} diff --git a/tests/ARSAS.Tests/P108CAnomalyChronologyTests.cs b/tests/ARSAS.Tests/P108CAnomalyChronologyTests.cs new file mode 100644 index 000000000..a8cdb8dc3 --- /dev/null +++ b/tests/ARSAS.Tests/P108CAnomalyChronologyTests.cs @@ -0,0 +1,117 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class P108CAnomalyChronologyTests +{ + private static NativeReportFrameMetadata Frame(int sq, int ordinal, bool overflow = false) => new() + { + SequenceNumber = (ulong)sq, + EntryIdHex = ordinal.ToString("X8"), + ReceivedAt = DateTimeOffset.Parse("2026-10-11T06:25:51Z").AddMilliseconds(ordinal), + OptFldsRawHex = "7F80", + OptFldsSequenceNumber = true, + OptFldsEntryId = true, + OptFldsBufferOverflow = true, + BufferOverflow = overflow + }; + + private static Iec61850ReportContinuityStreamSnapshot Snapshot(Iec61850ReportContinuityState state) + => Assert.Single(Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["r"] = state }, 0, 0).Streams); + + [Fact] + public void AnomalyInOmittedMiddleSurvivesHeadTailTruncation() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + for (var i = 0; i < 70; i++) + Iec61850ReportContinuityInspector.Observe(state, Frame(i, i + 1, overflow: i == 45), true); + + var s = Snapshot(state); + Assert.Equal(32, s.ChronologyInitialCount); + Assert.Equal(8, s.ChronologyRecentCount); + Assert.Equal(30, s.ChronologyOmittedFrames); + Assert.DoesNotContain(s.Chronology, f => f.FrameOrdinal == 46); + Assert.Single(s.RecentAnomalies); + Assert.Equal(46, s.RecentAnomalies[0].FrameOrdinal); + Assert.True(s.RecentAnomalies[0].BufferOverflow); + Assert.Equal(1, s.Overflow); + Assert.Equal(1, s.AnomalousFrames); + var report = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(report, + Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["r"] = state }, 0, 0)); + Assert.Contains("anomalyFrame#46:", report.ToString(), StringComparison.Ordinal); + Assert.Contains("BRCB anomaly history", report.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void LastEightAnomaliesStayInReceiveOrderAcrossRingWrap() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + for (var i = 0; i < 52; i++) + Iec61850ReportContinuityInspector.Observe(state, Frame(i, i + 1, overflow: i is >= 35 and <= 46), true); + var s = Snapshot(state); + Assert.Equal(12, s.AnomalousFrames); + Assert.Equal(8, s.RecentAnomalies.Count); + Assert.Equal(Enumerable.Range(40, 8).Select(x => (long)x), s.RecentAnomalies.Select(x => x.FrameOrdinal)); + Assert.Equal(12, s.Overflow); + } + + [Fact] + public void SnapshotDoesNotMutateWhenLaterFramesOverwriteAnomalyRing() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + for (var i = 0; i < 45; i++) + Iec61850ReportContinuityInspector.Observe(state, Frame(i, i + 1, overflow: i is >= 34 and <= 41), true); + var snap = Snapshot(state); + var original = snap.RecentAnomalies.Select(x => x.FrameOrdinal).ToArray(); + for (var i = 45; i < 75; i++) + Iec61850ReportContinuityInspector.Observe(state, Frame(i, i + 1, overflow: true), true); + Assert.Equal(original, snap.RecentAnomalies.Select(x => x.FrameOrdinal)); + Assert.Equal(8, snap.RecentAnomalies.Count); + Assert.Equal(75, Snapshot(state).RecentAnomalies[^1].FrameOrdinal); + } + + [Fact] + public void NoWarningsOrUnbufferedReportsDoNotAllocateAnomalyHistory() + { + var healthy = new Iec61850ReportContinuityState { Buffered = true }; + var unbuffered = new Iec61850ReportContinuityState(); + for (var i = 0; i < 50; i++) + { + Iec61850ReportContinuityInspector.Observe(healthy, Frame(i, i + 1), true); + Iec61850ReportContinuityInspector.Observe(unbuffered, Frame(i, i + 1, overflow: true), false); + } + Assert.Null(healthy.AnomalyChronology); + Assert.Null(unbuffered.AnomalyChronology); + Assert.Empty(Snapshot(healthy).RecentAnomalies); + Assert.Empty(Snapshot(unbuffered).RecentAnomalies); + } + + [Fact] + public void OnlyBoundedMetadataIsSavedNeverRawEntryIdOrProcessValues() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + var frame = new NativeReportFrameMetadata + { + SequenceNumber = 0, + EntryIdHex = "DEADBEEF12345678", + ReceivedAt = DateTimeOffset.Parse("2026-10-11T06:25:51Z"), + OptFldsRawHex = "7F80", + OptFldsSequenceNumber = true, + OptFldsEntryId = true, + OptFldsBufferOverflow = true, + BufferOverflow = true + }; + Iec61850ReportContinuityInspector.Observe(state, frame, true); + var sb = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(sb, + Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["r"] = state }, 0, 0)); + Assert.DoesNotContain("DEADBEEF12345678", sb.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("anomalyFrame#1:", sb.ToString(), StringComparison.Ordinal); + } +} diff --git a/tests/ARSAS.Tests/P108PostAnomalyFollowUpEvidenceTests.cs b/tests/ARSAS.Tests/P108PostAnomalyFollowUpEvidenceTests.cs new file mode 100644 index 000000000..121e0996e --- /dev/null +++ b/tests/ARSAS.Tests/P108PostAnomalyFollowUpEvidenceTests.cs @@ -0,0 +1,163 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// P10.8 bounded receive-order evidence. A clean suffix does not clear historical +/// BufOvfl or prove lossless SOE. No network operation is performed here. +/// +public sealed class P108PostAnomalyFollowUpEvidenceTests +{ + private static readonly DateTimeOffset Start = + DateTimeOffset.Parse("2026-10-11T06:25:51.000+00:00"); + + private static NativeReportFrameMetadata Frame( + ulong sequence, int elapsedMilliseconds, bool overflow = false, + bool missingTime = false) => new() + { + ReceivedAt = missingTime ? default : Start.AddMilliseconds(elapsedMilliseconds), + SequenceNumber = sequence, + EntryIdHex = sequence.ToString("X4"), + OptFldsRawHex = "7F80", + OptFldsSequenceNumber = true, + OptFldsEntryId = true, + OptFldsBufferOverflow = true, + OptFldsConfRev = true, + GeneralInterrogationReasonSeen = false, + BufferOverflow = overflow, + ConfRev = 1 + }; + + [Fact] + public void StartupFindingsRemainVisibleWhileCleanFollowUpFramesAccumulate() + { + var state = new Iec61850ReportContinuityState + { + ReportControlReference = "BCUGEF650/LLN0.BR.BRB_CSWI_GW010101", + Buffered = true + }; + Assert.Empty(Iec61850ReportContinuityInspector.Observe(state, Frame(4, 0), true)); + var anomaly = Iec61850ReportContinuityInspector.Observe( + state, Frame(1, 120, overflow: true), true); + Assert.Equal(2, anomaly.Count); + + foreach (var (sq, elapsed) in new[] { (2UL, 2000), (3UL, 3000), (4UL, 4000) }) + Assert.Empty(Iec61850ReportContinuityInspector.Observe( + state, Frame(sq, elapsed), true)); + + Assert.Equal(5, state.FramesSeen); + Assert.Equal(2, state.AnomalyFindings); + Assert.Equal(1, state.AnomalousFrames); + Assert.Equal(1, state.BufferOverflows); + Assert.Equal(3, state.FramesAfterLastFinding); + Assert.Equal(Start, state.FirstFrameReceivedAtUtc); + Assert.Equal(Start.AddMilliseconds(120), state.LastFindingReceivedAtUtc); + Assert.Equal(Start.AddMilliseconds(4000), state.LastFrameReceivedAtUtc); + + var snap = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["CSWI"] = state }, 5, 0); + Assert.Equal(3, Assert.Single(snap.Streams).FramesAfterLastFinding); + + var sb = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(sb, snap); + Assert.Contains("receivedAfterLastFinding=3", sb.ToString(), StringComparison.Ordinal); + Assert.Contains("later clean frames", sb.ToString(), StringComparison.Ordinal); + Assert.Contains("BufOvfl=1", sb.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void NewLateFindingResetsFollowUpCounterButKeepsAllHistoricalWarnings() + { + var state = new Iec61850ReportContinuityState { Buffered = true }; + Iec61850ReportContinuityInspector.Observe(state, Frame(4, 0), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(1, 200, overflow: true), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(2, 2000), true); + Assert.Equal(1, state.FramesAfterLastFinding); + + var late = Iec61850ReportContinuityInspector.Observe(state, Frame(1, 5000), true); + Assert.Single(late); + Assert.Equal(0, state.FramesAfterLastFinding); + Assert.Equal(Start.AddMilliseconds(5000), state.LastFindingReceivedAtUtc); + Assert.Equal(2, state.AnomalousFrames); + Assert.Equal(3, state.AnomalyFindings); + Assert.Equal(1, state.BufferOverflows); + } + + [Fact] + public void NoFindingNeverClaimsAFollowUpRecovery() + { + var state = new Iec61850ReportContinuityState(); + Iec61850ReportContinuityInspector.Observe(state, Frame(0, 0), false); + Iec61850ReportContinuityInspector.Observe(state, Frame(1, 60000), false); + Assert.Equal(0, state.FramesAfterLastFinding); + Assert.Null(state.LastFindingReceivedAtUtc); + Assert.Equal(Start.AddMinutes(1), state.LastFrameReceivedAtUtc); + var snap = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["URCB"] = state }, 2, 0); + var sb = new StringBuilder(); + DiagnosticReportBuilder.AppendReportContinuity(sb, snap); + Assert.DoesNotContain("RCB follow-up", sb.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void MissingDecoderReceiveTimestampRemainsUnknownAndNeverInventsAge() + { + var state = new Iec61850ReportContinuityState(); + Iec61850ReportContinuityInspector.Observe(state, Frame(10, 0), true); + Iec61850ReportContinuityInspector.Observe( + state, Frame(0, 100, missingTime: true), true); + Assert.Equal(0, state.FramesAfterLastFinding); + Assert.Null(state.LastFindingReceivedAtUtc); + Assert.Equal(Start, state.LastFrameReceivedAtUtc); + Iec61850ReportContinuityInspector.Observe(state, Frame(1, 5000), true); + Assert.Equal(1, state.FramesAfterLastFinding); + Assert.Null(state.LastFindingReceivedAtUtc); + } + + [Fact] + public void PostFindingCounterSaturatesAndSnapshotDoesNotFollowMutableState() + { + var state = new Iec61850ReportContinuityState + { + AnomalousFrames = 1, + FramesAfterLastFinding = long.MaxValue - 1 + }; + Iec61850ReportContinuityInspector.Observe(state, Frame(2, 100), true); + Iec61850ReportContinuityInspector.Observe(state, Frame(3, 200), true); + Assert.Equal(long.MaxValue, state.FramesAfterLastFinding); + var snap = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["R"] = state }, 1, 0); + Assert.Equal(long.MaxValue, snap.Streams[0].FramesAfterLastFinding); + Iec61850ReportContinuityInspector.Observe(state, Frame(1, 300), true); + Assert.Equal(0, state.FramesAfterLastFinding); + Assert.Equal(long.MaxValue, snap.Streams[0].FramesAfterLastFinding); + } + + [Fact] + public void MultipleStreamsHaveIndependentFollowUpEvidenceAndBoundedSnapshots() + { + var br = new Iec61850ReportContinuityState + { + Buffered = true, + ReportControlReference = "BCUGEF650/LLN0.BR.CSWI" + }; + var ur = new Iec61850ReportContinuityState + { + ReportControlReference = "BCUGEF650/LLN0.RP.MMXU" + }; + Iec61850ReportContinuityInspector.Observe(br, Frame(1, 0, overflow: true), true); + Iec61850ReportContinuityInspector.Observe(br, Frame(2, 5000), true); + Iec61850ReportContinuityInspector.Observe(ur, Frame(0, 0), false); + Iec61850ReportContinuityInspector.Observe(ur, Frame(1, 5000), false); + Assert.Equal(1, br.FramesAfterLastFinding); + Assert.Equal(0, ur.FramesAfterLastFinding); + var snapshot = Iec61850ReportContinuityInspector.Snapshot( + new Dictionary { ["a"] = br, ["b"] = ur }, + 5, 0, maxShown: 1); + Assert.Equal(2, snapshot.StreamCount); + Assert.Single(snapshot.Streams); + Assert.Equal("BCUGEF650/LLN0.BR.CSWI", snapshot.Streams[0].Rcb); + } +} diff --git a/tests/ARSAS.Tests/P109KGenericControlSafetyTests.cs b/tests/ARSAS.Tests/P109KGenericControlSafetyTests.cs new file mode 100644 index 000000000..09cc7f204 --- /dev/null +++ b/tests/ARSAS.Tests/P109KGenericControlSafetyTests.cs @@ -0,0 +1,42 @@ +using AR.Iec61850.Control; +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class P109KGenericControlSafetyTests +{ + [Fact] + public void ControlModelAloneNeverEnablesUnverifiedCommand() + { + var signal = new SignalDefinition { IsControlSignal = true, ObjectReference = "LD/CSWI1.Pos" }; + Assert.False(signal.ControlSupportsOperate); + Assert.False(signal.ControlSemanticVerified); + } + + [Theory] + [InlineData("BSC", true, false)] + [InlineData("INC", false, true)] + [InlineData("ISC", false, true)] + public void DistinctCdcOperatorActions(string cdc, bool directional, bool numeric) + { + var signal = new SignalDefinition { + Name = "Control", ObjectReference = "LD/ATCC1.Command", + FunctionalConstraint = "CO", IsControlSignal = true, ControlCdc = cdc + }; + Assert.Equal(directional, signal.IsRaiseLowerControl); + Assert.Equal(numeric, signal.IsSetPointControl); + } + + [Fact] + public void ClaimedBscCannotOverrideLiveDpc() + { + var descriptor = new Iec61850ControlObjectDescriptor { + Cdc = "DPC", ObjectReference = "LD/ATCC1.TapChg", + StatusReference = "LD/ATCC1.TapChg.stVal", + ControlModel = Iec61850ControlModel.DirectNormal, + CtlValSpecification = new MmsTypeSpecificationNode { MmsType = "bit-string", Size = 2 } + }; + Assert.False(Iec61850ControlSemanticResolver.Resolve(descriptor, "BSC").IsCommandSafe); + } +} diff --git a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs new file mode 100644 index 000000000..5c34abf70 --- /dev/null +++ b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs @@ -0,0 +1,237 @@ +using System.Diagnostics; +using System.Text.Json.Nodes; + +namespace ARSAS.Tests; + +/// +/// Executes the actual embedded CI PowerShell gate using disposable lock/evidence +/// fixtures: accepted trials are never interpreted as mainline physical proof. +/// +public sealed class P10MainlinePolicyExecutionTests +{ + [Theory] + [InlineData("p10-stacked-valid", true)] + [InlineData("core-trial-wrong-exact-sha-denied", false)] + [InlineData("core-trial-wrong-base-denied", false)] + [InlineData("core-trial-wrong-ci-denied", false)] + [InlineData("core-trial-physical-flag-denied", false)] + [InlineData("subscriber-trial-sha-denied", false)] + [InlineData("subscriber-trial-parent-denied", false)] + [InlineData("subscriber-trial-ci-denied", false)] + [InlineData("subscriber-trial-physical-flag-denied", false)] + [InlineData("subscriber-trial-mainline-flag-denied", false)] + [InlineData("canonical-trial-sha-denied", false)] + [InlineData("canonical-trial-parent-denied", false)] + [InlineData("canonical-trial-ci-denied", false)] + [InlineData("canonical-trial-physical-flag-denied", false)] + [InlineData("canonical-trial-mainline-flag-denied", false)] + [InlineData("canonical-trial-polling-flag-denied", false)] + [InlineData("ziv-trial-sha-denied", false)] + [InlineData("ziv-trial-base-denied", false)] + [InlineData("ziv-trial-ci-denied", false)] + [InlineData("ziv-trial-physical-denied", false)] + [InlineData("ziv-trial-mainline-denied", false)] + [InlineData("ziv-trial-polling-denied", false)] + [InlineData("ziv-trial-bsc-gate-denied", false)] + [InlineData("generic-trial-sha-denied", false)] + [InlineData("generic-trial-parent-denied", false)] + [InlineData("generic-trial-ci-denied", false)] + [InlineData("generic-trial-semantic-gate-denied", false)] + [InlineData("generic-trial-inventory-gate-denied", false)] + [InlineData("generic-trial-vendor-denied", false)] + [InlineData("p10-pr-main-denied", false)] + [InlineData("p10-source-main-denied", false)] + [InlineData("p10-wrong-sha-denied", false)] + [InlineData("p10-wrong-parent-denied", false)] + [InlineData("p10-wrong-source-pr-denied", false)] + [InlineData("p10-wrong-identity-source-denied", false)] + [InlineData("p10-cyclic-fallback-denied", false)] + [InlineData("p10-history-tampered-denied", false)] + [InlineData("p10-dataset-lineage-tampered-denied", false)] + [InlineData("accepted-physical-baseline", true)] + public void Exact_CI_Gate_Fails_Closed_Without_False_Physical_Promotion( + string scenario, bool shouldPass) + { + if (!OperatingSystem.IsWindows()) + return; // Windows Actions runners use built-in Windows PowerShell 5.1. + + var root = FindRepositoryRoot(); + var workflow = File.ReadAllText(Path.Combine(root, + ".github", "workflows", "smart-discovery-mainline-readiness.yml")) + .Replace("\r\n", "\n", StringComparison.Ordinal); + const string step = " - name: Verify R10 physical convergence authority\n"; + const string run = " run: |\n"; + const string next = "\n - name: Checkout exact accepted ARIEC61850 integration authority"; + var stepStart = workflow.IndexOf(step, StringComparison.Ordinal); + Assert.True(stepStart >= 0, "R10 physical verification step must exist."); + var scriptStart = workflow.IndexOf(run, stepStart, StringComparison.Ordinal); + Assert.True(scriptStart >= 0, "R10 verification step must have a run script."); + scriptStart += run.Length; + var scriptEnd = workflow.IndexOf(next, scriptStart, StringComparison.Ordinal); + Assert.True(scriptEnd > scriptStart, "R10 script boundary must be explicit."); + var script = string.Join("\n", workflow[scriptStart..scriptEnd].Split('\n') + .Select(line => line.StartsWith(" ", StringComparison.Ordinal) + ? line[10..] : line)); + + var folder = Path.Combine(Path.GetTempPath(), + "arsas-p10-mainline-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(Path.Combine(folder, "engines")); + Directory.CreateDirectory(Path.Combine(folder, "evidence")); + Directory.CreateDirectory(Path.Combine(folder, "scripts")); + File.Copy(Path.Combine(root, "scripts", "test-p10-9f-core-routing-trial.ps1"), + Path.Combine(folder, "scripts", "test-p10-9f-core-routing-trial.ps1")); + var lockDoc = JsonNode.Parse(File.ReadAllText(Path.Combine(root, + "engines", "ARIEC61850.lock.json")))!.AsObject(); + + string baseRef = "fix/512-goose-event-header-alignment"; + string sourceRef = "512/merge"; + switch (scenario) + { + case "p10-pr-main-denied": baseRef = "main"; break; + case "core-trial-wrong-exact-sha-denied": + lockDoc["coreReportRoutingTrial"]!["exactCommit"] = new string('0', 40); break; + case "core-trial-wrong-base-denied": + lockDoc["coreReportRoutingTrial"]!["baseEngineCommit"] = new string('0', 40); break; + case "core-trial-wrong-ci-denied": + lockDoc["coreReportRoutingTrial"]!["coreCiRun"] = 0; break; + case "core-trial-physical-flag-denied": + lockDoc["coreReportRoutingTrial"]!["physicalQualificationRequired"] = false; break; + case "subscriber-trial-sha-denied": + lockDoc["reportSubscriberIntegrityTrial"]!["exactCommit"] = new string('0', 40); break; + case "subscriber-trial-parent-denied": + lockDoc["reportSubscriberIntegrityTrial"]!["baseEngineCommit"] = new string('0', 40); break; + case "subscriber-trial-ci-denied": + lockDoc["reportSubscriberIntegrityTrial"]!["engineCiRun"] = 0; break; + case "subscriber-trial-physical-flag-denied": + lockDoc["reportSubscriberIntegrityTrial"]!["physicalQualificationRequired"] = false; break; + case "subscriber-trial-mainline-flag-denied": + lockDoc["reportSubscriberIntegrityTrial"]!["mainlinePromotionForbidden"] = false; break; + case "canonical-trial-sha-denied": + lockDoc["canonicalReportIngressIntegrityTrial"]!["exactCommit"] = new string('0', 40); break; + case "canonical-trial-parent-denied": + lockDoc["canonicalReportIngressIntegrityTrial"]!["baseEngineCommit"] = new string('0', 40); break; + case "canonical-trial-ci-denied": + lockDoc["canonicalReportIngressIntegrityTrial"]!["engineCiRun"] = 0; break; + case "canonical-trial-physical-flag-denied": + lockDoc["canonicalReportIngressIntegrityTrial"]!["physicalQualificationRequired"] = false; break; + case "canonical-trial-mainline-flag-denied": + lockDoc["canonicalReportIngressIntegrityTrial"]!["mainlinePromotionForbidden"] = false; break; + case "canonical-trial-polling-flag-denied": + lockDoc["canonicalReportIngressIntegrityTrial"]!["cyclicMmsProcessPollingForbidden"] = false; break; + case "ziv-trial-sha-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["exactCommit"] = new string('0', 40); break; + case "ziv-trial-base-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["baseEngineCommit"] = new string('0', 40); break; + case "ziv-trial-ci-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["engineCiRun"] = 0; break; + case "ziv-trial-physical-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["physicalQualificationRequired"] = false; break; + case "ziv-trial-mainline-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["mainlinePromotionForbidden"] = false; break; + case "ziv-trial-polling-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["cyclicMmsProcessPollingForbidden"] = false; break; + case "ziv-trial-bsc-gate-denied": + lockDoc["zivSclBscInteroperabilityTrial"]!["bscLiveSemanticGateRequired"] = false; break; + case "generic-trial-sha-denied": + lockDoc["genericControlInteroperabilityTrial"]!["exactCommit"] = new string('0', 40); break; + case "generic-trial-parent-denied": + lockDoc["genericControlInteroperabilityTrial"]!["baseEngineCommit"] = new string('0', 40); break; + case "generic-trial-ci-denied": + lockDoc["genericControlInteroperabilityTrial"]!["engineCiRun"] = 0; break; + case "generic-trial-semantic-gate-denied": + lockDoc["genericControlInteroperabilityTrial"]!["typedSemanticGateRequired"] = false; break; + case "generic-trial-inventory-gate-denied": + lockDoc["genericControlInteroperabilityTrial"]!["associationScopedInventoryRequired"] = false; break; + case "generic-trial-vendor-denied": + lockDoc["genericControlInteroperabilityTrial"]!["vendorDispatchForbidden"] = false; break; + case "p10-source-main-denied": sourceRef = "main"; break; + case "p10-wrong-sha-denied": + lockDoc["p10IdentityTrial"]!["exactCommit"] = new string('0', 40); break; + case "p10-wrong-parent-denied": + lockDoc["p10IdentityTrial"]!["baseEngineCommit"] = new string('0', 40); break; + case "p10-wrong-source-pr-denied": + lockDoc["sourcePullRequest"] = 160; break; + case "p10-wrong-identity-source-denied": + lockDoc["p10IdentityTrial"]!["identitySource"] = "HostFallback"; break; + case "p10-cyclic-fallback-denied": + lockDoc["p10IdentityTrial"]!["cyclicMmsProcessPollingForbidden"] = false; break; + case "p10-history-tampered-denied": + lockDoc["mergedMainCommit"] = new string('0', 40); break; + case "p10-dataset-lineage-tampered-denied": + lockDoc["datasetFirstDiscoveryTrial"]!["exactCommit"] = new string('0', 40); break; + case "accepted-physical-baseline": + lockDoc["commit"] = "84e9820e5a32690475960e49d5ef74e6637847fd"; + lockDoc["sourcePullRequest"] = 143; + baseRef = "main"; + break; + } + + File.WriteAllText(Path.Combine(folder, "engines", "ARIEC61850.lock.json"), + lockDoc.ToJsonString()); + File.Copy(Path.Combine(root, "evidence", "interoperability-reference-target.json"), + Path.Combine(folder, "evidence", "interoperability-reference-target.json")); + var scriptPath = Path.Combine(folder, "verify-r10.ps1"); + File.WriteAllText(scriptPath, script); + var environment = Path.Combine(folder, "github-env.txt"); + File.WriteAllText(environment, ""); + var info = new ProcessStartInfo("powershell.exe") + { + WorkingDirectory = folder, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + foreach (var arg in new[] { "-NoProfile", "-NonInteractive", + "-ExecutionPolicy", "Bypass", "-File", scriptPath }) + info.ArgumentList.Add(arg); + info.Environment["PR_BASE_REF"] = baseRef; + info.Environment["SOURCE_REF_NAME"] = sourceRef; + info.Environment["GITHUB_ENV"] = environment; + + using var process = Process.Start(info)!; + var output = process.StandardOutput.ReadToEnd() + + process.StandardError.ReadToEnd(); + if (!process.WaitForExit(30000)) + { + process.Kill(entireProcessTree: true); + throw new TimeoutException("CI gate fixture exceeded 30 seconds."); + } + + if (shouldPass) + { + Assert.True(process.ExitCode == 0, + $"Expected acceptance for {scenario}, exit {process.ExitCode}:\n{output}"); + Assert.Contains("ENGINE_COMMIT=", File.ReadAllText(environment)); + } + else + { + Assert.NotEqual(0, process.ExitCode); + Assert.DoesNotContain("ENGINE_COMMIT=", File.ReadAllText(environment)); + } + } + finally + { + if (Directory.Exists(folder)) + Directory.Delete(folder, recursive: true); + } + } + + private static string FindRepositoryRoot() + { + DirectoryInfo? current = new(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, + "engines", "ARIEC61850.lock.json")) && + File.Exists(Path.Combine(current.FullName, ".github", + "workflows", "smart-discovery-mainline-readiness.yml"))) + return current.FullName; + current = current.Parent; + } + + throw new DirectoryNotFoundException("ARSAS repository root not found."); + } +} diff --git a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs index 690c1df39..62218d30b 100644 --- a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs @@ -82,7 +82,27 @@ public void R10_DedicatedMainlineGateRequiresPhysicalAuthorityAndExactMergedEngi Assert.Contains("physicalTestedCommit", workflow, StringComparison.Ordinal); Assert.Contains("mergedMainTree", workflow, StringComparison.Ordinal); Assert.Contains("Checkout exact accepted ARIEC61850 integration authority", workflow, StringComparison.Ordinal); - Assert.Contains("Build and test ARSAS candidate", workflow, StringComparison.Ordinal); + Assert.Contains("Validate and test accepted engine authority", workflow, StringComparison.Ordinal); + Assert.Contains(@"dotnet restore ..\ARIEC61850\ARIEC61850.sln", workflow, StringComparison.Ordinal); + Assert.Contains(@"dotnet build ..\ARIEC61850\ARIEC61850.sln", workflow, StringComparison.Ordinal); + Assert.Contains(@"dotnet test ..\ARIEC61850\tests\AR.Iec61850.Tests\AR.Iec61850.Tests.csproj", workflow, StringComparison.Ordinal); + + // The ARSAS suite is no longer compiled a second time here. Instead, + // this independent R10 gate fails closed unless the same PR synthetic + // merge tree and pinned engine have an all-pass canonical Build ARSAS + // run with matching TRX identity. Manual dispatch retains a reusable + // exact-SHA full regression job. + Assert.Contains("Require successful canonical Build ARSAS for same PR merge and engine", workflow, StringComparison.Ordinal); + Assert.Contains("scripts\\verify-ci-canonical-reuse.py", workflow, StringComparison.Ordinal); + Assert.Contains("--head-sha $env:PR_HEAD_SHA", workflow, StringComparison.Ordinal); + Assert.Contains("--merge-sha $env:PR_MERGE_SHA", workflow, StringComparison.Ordinal); + Assert.Contains("--engine-sha $env:ENGINE_COMMIT", workflow, StringComparison.Ordinal); + Assert.Contains("--wait-seconds 960", workflow, StringComparison.Ordinal); + Assert.Contains("manual-regression:", workflow, StringComparison.Ordinal); + Assert.Contains("_ci-p1-windows-build-test.yml", workflow, StringComparison.Ordinal); + Assert.DoesNotContain(@"dotnet restore .\ArIED61850Tester.sln", workflow, StringComparison.Ordinal); + Assert.DoesNotContain(@"dotnet build .\ArIED61850Tester.sln", workflow, StringComparison.Ordinal); + Assert.DoesNotContain(@"dotnet test .\tests\ARSAS.Tests\ARSAS.Tests.csproj", workflow, StringComparison.Ordinal); Assert.DoesNotContain("perf/smart-ied-discovery", workflow, StringComparison.Ordinal); Assert.DoesNotContain("EvidenceEngineBaselineCommit", workflow, StringComparison.Ordinal); } diff --git a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs index bbf37e854..96e1487dc 100644 --- a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs @@ -67,8 +67,65 @@ public void TrialLock_PreservesHistoricalPhysicalAuthorityAndExplicitlyRequiresP Assert.Equal(157, closure.GetProperty("enginePullRequest").GetInt32()); Assert.Equal(serverAt.GetProperty("exactCommit").GetString(), closure.GetProperty("baseEngineCommit").GetString()); + var p10 = root.GetProperty("p10IdentityTrial"); + Assert.Equal(closure.GetProperty("exactCommit").GetString(), + p10.GetProperty("baseEngineCommit").GetString()); + var codeTrial = root.GetProperty("coreReportRoutingTrial"); + Assert.Equal(p10.GetProperty("exactCommit").GetString(), + codeTrial.GetProperty("baseEngineCommit").GetString()); + var subscriberTrial = root.GetProperty("reportSubscriberIntegrityTrial"); + Assert.Equal(codeTrial.GetProperty("exactCommit").GetString(), + subscriberTrial.GetProperty("baseEngineCommit").GetString()); + var canonicalTrial = root.GetProperty("canonicalReportIngressIntegrityTrial"); + Assert.Equal(subscriberTrial.GetProperty("exactCommit").GetString(), + canonicalTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal("9bd01908ebd6fdf50a74f0a421b4e79613b878d4", + canonicalTrial.GetProperty("exactCommit").GetString()); + var zivTrial = root.GetProperty("zivSclBscInteroperabilityTrial"); + Assert.Equal(canonicalTrial.GetProperty("exactCommit").GetString(), + zivTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal("67c6635b7e16139c0661e559a2e6946527c1442a", + zivTrial.GetProperty("exactCommit").GetString()); + var genericTrial = root.GetProperty("genericControlInteroperabilityTrial"); + Assert.Equal(zivTrial.GetProperty("exactCommit").GetString(), + genericTrial.GetProperty("baseEngineCommit").GetString()); Assert.Equal(root.GetProperty("commit").GetString(), - closure.GetProperty("exactCommit").GetString()); + genericTrial.GetProperty("exactCommit").GetString()); + Assert.Equal("code-verified-not-physical", codeTrial.GetProperty("status").GetString()); + Assert.Equal("code-verified-not-physical", subscriberTrial.GetProperty("status").GetString()); + Assert.True(subscriberTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(subscriberTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.Equal("code-verified-not-physical", canonicalTrial.GetProperty("status").GetString()); + Assert.Equal(166, canonicalTrial.GetProperty("enginePullRequest").GetInt32()); + Assert.Equal(536, canonicalTrial.GetProperty("issue").GetInt32()); + Assert.Equal(38214165576L, canonicalTrial.GetProperty("engineCiRun").GetInt64()); + Assert.True(canonicalTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(canonicalTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.True(canonicalTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean()); + Assert.True(canonicalTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); + Assert.True(codeTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(codeTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.Equal("code-verified-not-physical", zivTrial.GetProperty("status").GetString()); + Assert.Equal(538, zivTrial.GetProperty("issue").GetInt32()); + Assert.Equal(167, zivTrial.GetProperty("enginePullRequest").GetInt32()); + Assert.Equal(38219429896L, zivTrial.GetProperty("engineCiRun").GetInt64()); + Assert.True(zivTrial.GetProperty("sclApTitlePackedPrefixOnly").GetBoolean()); + Assert.True(zivTrial.GetProperty("bscLiveSemanticGateRequired").GetBoolean()); + Assert.True(zivTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(zivTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.True(zivTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean()); + Assert.True(zivTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); + Assert.Equal(168,root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal(540, genericTrial.GetProperty("issue").GetInt32()); + Assert.Equal(38231327892L, genericTrial.GetProperty("engineCiRun").GetInt64()); + Assert.True(genericTrial.GetProperty("typedSemanticGateRequired").GetBoolean()); + Assert.True(genericTrial.GetProperty("associationScopedInventoryRequired").GetBoolean()); + Assert.True(genericTrial.GetProperty("vendorDispatchForbidden").GetBoolean()); + Assert.True(genericTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(genericTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.Equal("code-trial-not-physical",p10.GetProperty("status").GetString()); + Assert.True(p10.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(p10.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); Assert.True(closure.GetProperty("physicalQualificationRequired").GetBoolean()); Assert.True(closure.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); Assert.True(serverAt.GetProperty("physicalQualificationRequired").GetBoolean()); diff --git a/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs index 9b835b863..ecc12faf0 100644 --- a/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetDualRoleControlRegressionTests.cs @@ -46,11 +46,11 @@ public void PositionControlAndStatusRemainSeparateRuntimeFacets() [Theory] [InlineData("SPC", "IEDCTRL/GGIO1.Enable", "boolean")] [InlineData("DPC", "IEDQ0/CSWI1.Pos", "position")] - [InlineData("INC", "IEDCTRL/ATCC1.TapCmd", "regulating")] - [InlineData("ISC", "IEDCTRL/ATCC1.StepCmd", "regulating")] + [InlineData("INC", "IEDCTRL/ATCC1.TapCmd", "setpoint")] + [InlineData("ISC", "IEDCTRL/ATCC1.StepCmd", "setpoint")] [InlineData("APC", "IEDCTRL/AVCO1.VRef", "setpoint")] [InlineData("BAC", "IEDCTRL/GAPC1.AnCtl", "setpoint")] - [InlineData("BSC", "IEDCTRL/ATCC1.TapPos", "setpoint")] + [InlineData("BSC", "IEDCTRL/ATCC1.TapPos", "regulating")] public void ExistingCommandSurface_CoversStandardControlFamilies( string cdc, string reference, diff --git a/tests/ARSAS.Tests/TrustedSclIdentityConsumerTests.cs b/tests/ARSAS.Tests/TrustedSclIdentityConsumerTests.cs new file mode 100644 index 000000000..f2476d095 --- /dev/null +++ b/tests/ARSAS.Tests/TrustedSclIdentityConsumerTests.cs @@ -0,0 +1,113 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; +using System.Security.Cryptography; + +namespace ARSAS.Tests; + +public sealed class TrustedSclIdentityConsumerTests +{ + [Fact] + public async Task GE_F650_SHA_Pinned_Engineering_Source_Promotes_IED_And_LD_Without_Touching_MMS_Domain() + { + using var temp=TempScl.Create("BCUGE","F650","192.16.1.33"); + var device=temp.Device; + var model=Model("BCUGEF650"); + var identity=await TrustedSclIdentityAuthority.TryMatchAsync(device,model); + Assert.NotNull(identity); + Assert.Equal("BCUGE",identity.IedName); + Assert.Equal("F650",identity.LogicalDeviceAliases["BCUGEF650"]); + var projected=TrustedSclIdentityAuthority.ToConsumerIdentity(identity,["BCUGEF650"]); + Assert.Equal("BCUGE",projected.IedName); + Assert.Equal("BCUGEF650",Assert.Single(projected.LogicalDevices).Domain); + Assert.Equal("F650",Assert.Single(projected.LogicalDevices).Instance); + } + + [Fact] + public async Task Changed_SCL_SHA_Must_Fail_Closed_And_Never_Rename() + { + using var temp=TempScl.Create("BCUGE","F650","192.16.1.33"); + var device=temp.Device; + device.SclSourceSha256=new string('0',64); + await Assert.ThrowsAsync(async () => + await TrustedSclIdentityAuthority.TryMatchAsync(device,Model("BCUGEF650"))); + } + + [Fact] + public async Task Missing_Trusted_SCL_And_Wrong_MMS_Endpoint_Does_Not_Guess() + { + var plain=new Iec61850MonitorDevice{IpAddress="192.16.1.33"}; + Assert.Null(await TrustedSclIdentityAuthority.TryMatchAsync(plain,Model("BCUGEF650"))); + using var temp=TempScl.Create("BCUGE","F650","192.16.1.33"); + temp.Device.IpAddress="192.16.1.34"; + Assert.Null(await TrustedSclIdentityAuthority.TryMatchAsync( + temp.Device,Model("BCUGEF650"))); + } + + [Fact] + public void Canonical_Save_And_RCB_Export_Must_Consume_Verified_Identity() + { + var save=Read("MainWindow.xaml.cs"); + Assert.Contains("verifiedIdentity: verifiedIdentity",save); + Assert.Contains("verifiedIdentity?.IedName",save); + Assert.Contains("TrustedSclIdentityAuthority.TryMatchAsync",save); + var validator=Read("Services/CanonicalSclReloadValidator.cs"); + Assert.Contains("effectiveIedName",validator); + var export=Read("MainWindow.RcbExport.cs"); + Assert.Contains("VerifiedIdentity = verifiedIdentity",export); + Assert.Contains("IedNameOverride = verifiedIdentity?.IedName ?? device.Name",export); + var runtime=Read("Services/Iec61850MonitorRuntime.cs"); + Assert.Contains("TrustedSclIdentityAuthority.ToConsumerIdentity",runtime); + } + + private static LiveIedModelDiscoveryDocument Model(string domain) => new() + { + Host="192.16.1.33",IedName="IED_192_16_1_33", + LogicalDevices=[new LiveIedLogicalDeviceModel{MmsDomain=domain,Inst=domain}] + }; + + private sealed class TempScl : IDisposable + { + private readonly string _folder; + public Iec61850MonitorDevice Device { get; } + private TempScl(string folder,Iec61850MonitorDevice device) + { + _folder=folder;Device=device; + } + public static TempScl Create(string ied,string ld,string ip) + { + var dir=Path.Combine(Path.GetTempPath(),"arsas-ied-p10-"+Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var path=Path.Combine(dir,"engineering.iid"); + var xml=$""" + +

{ip}

+ +
+ """; + File.WriteAllText(path,xml); + var hash=Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); + return new TempScl(dir,new Iec61850MonitorDevice + { + IpAddress=ip, + SclSourcePath=path, + SclSourceSha256=hash, + SclIedName=ied, + SclAccessPointName="S1" + }); + } + public void Dispose()=>Directory.Delete(_folder,recursive:true); + } + + private static string Read(string path) + { + DirectoryInfo? dir=new(AppContext.BaseDirectory); + while(dir is not null) + { + var file=Path.Combine(dir.FullName,path.Replace('/',Path.DirectorySeparatorChar)); + if(File.Exists(file))return File.ReadAllText(file); + dir=dir.Parent; + } + throw new FileNotFoundException(path); + } +} diff --git a/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs b/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs new file mode 100644 index 000000000..926ee98e9 --- /dev/null +++ b/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs @@ -0,0 +1,111 @@ +namespace ARSAS.Tests; + +public sealed class WindowsPackagingReproducibilityContractTests +{ + [Fact] + public void NativeBridge_RequestsReproducibleMsvcLinkOutput() + { + var bridge = File.ReadAllText( + FindRepositoryFile("scripts/build-ardirec-bridge.ps1")); + + Assert.Contains( + "-DCMAKE_SHARED_LINKER_FLAGS_RELEASE=/Brepro", + bridge, + StringComparison.Ordinal); + Assert.Contains( + "-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded", + bridge, + StringComparison.Ordinal); + Assert.Contains( + "ctest --test-dir", + bridge, + StringComparison.Ordinal); + } + + [Fact] + public void PortablePublish_IsDeterministicAndEmitsHashBoundBuildIdentity() + { + var publish = File.ReadAllText( + FindRepositoryFile("scripts/publish-windows-portable.ps1")); + + Assert.Contains("-p:Deterministic=true", publish, StringComparison.Ordinal); + Assert.Contains("-p:ContinuousIntegrationBuild=true", publish, StringComparison.Ordinal); + Assert.Contains("arsas-portable-build-identity", publish, StringComparison.Ordinal); + Assert.Contains("ardIrecBridgeSha256", publish, StringComparison.Ordinal); + Assert.Contains("portableSha256", publish, StringComparison.Ordinal); + Assert.Contains("sourceCommit", publish, StringComparison.Ordinal); + Assert.Contains("engineCommit", publish, StringComparison.Ordinal); + Assert.Contains("ardIrecLockCommit", publish, StringComparison.Ordinal); + Assert.Contains("reproducibleNativeLinkRequested = $true", publish, StringComparison.Ordinal); + } + + [Fact] + public void CanonicalAndFieldCaptureArtifacts_BothCarryBuildIdentity() + { + var canonical = File.ReadAllText( + FindRepositoryFile(".github/workflows/build.yml")); + var capture = File.ReadAllText( + FindRepositoryFile(".github/workflows/smart-discovery-capture-build.yml")); + + const string identity = + "ArIED61850Tester\\dist\\ARSAS-*-win-x64-portable-build-identity.json"; + + Assert.Contains(identity, canonical, StringComparison.Ordinal); + Assert.Contains(identity, capture, StringComparison.Ordinal); + Assert.Contains( + "--portable-identity", + canonical, + StringComparison.Ordinal); + Assert.Contains( + "Promote exact sealed canonical portable", + capture, + StringComparison.Ordinal); + Assert.Contains( + "verify-ci-package-reuse.py", + capture, + StringComparison.Ordinal); + Assert.Contains( + "--allow-in-progress-artifact", + capture, + StringComparison.Ordinal); + Assert.Contains( + "proof.portablePath", + capture, + StringComparison.Ordinal); + Assert.Contains( + "proof.portableIdentityPath", + capture, + StringComparison.Ordinal); + Assert.Contains( + "SMART_CAPTURE_PACKAGING_AUTHORITY=sealed-build-arsas:", + capture, + StringComparison.Ordinal); + Assert.Contains( + "SMART_CAPTURE_PACKAGING_AUTHORITY=independent-manual-field-capture-publish", + capture, + StringComparison.Ordinal); + Assert.Contains( + "if: github.event_name == 'workflow_dispatch'", + capture, + StringComparison.Ordinal); + Assert.Contains( + "Packaging authority: $env:SMART_CAPTURE_PACKAGING_AUTHORITY", + capture, + StringComparison.Ordinal); + } + + private static string FindRepositoryFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Repository file not found: {relativePath}"); + } +} diff --git a/tests/ARSAS.Tests/ZivBscStepControlRegressionTests.cs b/tests/ARSAS.Tests/ZivBscStepControlRegressionTests.cs new file mode 100644 index 000000000..602afeaaf --- /dev/null +++ b/tests/ARSAS.Tests/ZivBscStepControlRegressionTests.cs @@ -0,0 +1,81 @@ +using AR.Iec61850.Control; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class ZivBscStepControlRegressionTests +{ + [Theory] + [InlineData("raise", 2L)] + [InlineData("higher", 2L)] + [InlineData("up", 2L)] + [InlineData("lower", 1L)] + [InlineData("down", 1L)] + public void StandardBscDirection_ProducesExactTcmd_NotDpc(string input, long expectedCode) + { + Assert.True(Iec61850BscStepControlPolicy.TryBind( + "bit-string", 2, input, out var command, out var expected, out var error), error); + Assert.Equal(Iec61850ControlValueKind.Integer, command!.Kind); + Assert.Equal(expectedCode, command.Value); + Assert.Equal(expectedCode == 2 ? "Raise" : "Lower", expected); + } + + [Theory] + [InlineData("open")] + [InlineData("close")] + [InlineData("12")] + [InlineData("-40")] + [InlineData("stop")] + [InlineData("3")] + [InlineData("reserved")] + public void RejectsUnqualifiedBscCommands_BeforeWire(string input) + { + Assert.False(Iec61850BscStepControlPolicy.TryBind( + "bit-string", 2, input, out var value, out _, out var error)); + Assert.Null(value); + Assert.Contains("only Raise or Lower", error); + } + + [Theory] + [InlineData("bit-string", 3)] + [InlineData("structure", 2)] + [InlineData("boolean", 0)] + public void UnsupportedLiveShape_FailsClosed(string type, int bits) + { + Assert.False(Iec61850BscStepControlPolicy.TryBind( + type, bits, "raise", out _, out _, out var error)); + Assert.Contains("not safely supported", error); + } + + [Fact] + public void FeedbackRequiresExactDirectionalNumericMovement() + { + Assert.True(Iec61850BscStepControlPolicy.MovementConfirmed("Raise", "12", "13")); + Assert.True(Iec61850BscStepControlPolicy.MovementConfirmed("Lower", "12", "11")); + Assert.False(Iec61850BscStepControlPolicy.MovementConfirmed("Raise", "12", "12")); + Assert.False(Iec61850BscStepControlPolicy.MovementConfirmed("Raise", "Unknown", "13")); + Assert.False(Iec61850BscStepControlPolicy.MovementConfirmed("Lower", "12", "bad")); + Assert.False(Iec61850BscStepControlPolicy.MovementConfirmed("Lower", "12", "13")); + } + + [Fact] + public void BscShowsRaiseLowerNotSwitchNorSetpoint() + { + var signal = new SignalDefinition + { + Name = "TapChg", + ObjectReference = "ZIVAVRLD1/ATCC1.TapChg", + FunctionalConstraint = "CO", + DataType = "BSC", + Category = "Control", + ControlCdc = "BSC", + ControlStatusReference = "ZIVAVRLD1/ATCC1.TapChg.valWTr.posVal", + IsControlSignal = true + }; + Assert.True(signal.IsRaiseLowerControl); + Assert.False(signal.IsPositionControl); + Assert.False(signal.IsSetPointControl); + Assert.Contains("Raise / Lower", signal.ControlActionLabel); + } +}