diff --git a/.github/workflows/ied-real-device-f4.yml b/.github/workflows/ied-real-device-f4.yml index fb35771a0..a99dceb23 100644 --- a/.github/workflows/ied-real-device-f4.yml +++ b/.github/workflows/ied-real-device-f4.yml @@ -1,35 +1,44 @@ name: F4 Real IED Acceptance RC +# The field kit is intentionally generated for explicitly requested source +# revisions and packaging changes. Any protocol commit can use workflow_dispatch. on: workflow_dispatch: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - '.github/workflows/ied-real-device-f4.yml' + - 'apps/ied_simulator/field_acceptance/**' + - 'apps/ied_simulator/packaging/windows/**' + - 'apps/ied_simulator/test_f4_release_contract.py' push: - branches: - - feat/ied-sim-ux-performance-foundation + branches: [main] paths: - - 'apps/ied_simulator/**' - - 'embedded/**' - - 'tests/fixtures/scl/**' - - 'tools/static_ied_server.cpp' - - 'tools/static_file_service_backend.hpp' - - 'tools/static_file_service_backend_test.cpp' - - 'tools/file_transfer.cpp' - - 'tools/mms_*probe.cpp' - - 'tools/control_block_read_probe.cpp' - - 'tools/live_discover.cpp' - - 'tools/static_ied_server_iedsim/**' - - 'src/mms/**' - - 'include/ariec61850/mms/**' - '.github/workflows/ied-real-device-f4.yml' + - 'apps/ied_simulator/field_acceptance/**' + - 'apps/ied_simulator/packaging/windows/**' + - 'apps/ied_simulator/test_f4_release_contract.py' permissions: contents: read +# Never label a synthetic pull_request merge SHA as the shipped build head. +# Pull-request CI checks out the actual requested source commit. Push/dispatch +# events use their own exact triggering commit. +env: + F4_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + jobs: protocol-gate: name: Linux protocol gate runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 + with: + ref: ${{ env.F4_SOURCE_SHA }} + + - name: Verify F4 Windows packaging contract + run: python3 apps/ied_simulator/test_f4_release_contract.py - name: Configure current IEC 61850 interoperability server run: cmake -S tools/static_ied_server_iedsim -B build-f4-protocol -DCMAKE_BUILD_TYPE=Release @@ -45,6 +54,28 @@ jobs: runs-on: windows-2025 steps: - uses: actions/checkout@v4 + with: + ref: ${{ env.F4_SOURCE_SHA }} + + - name: Parse field-collector PowerShell before expensive Windows build + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $scripts = @( + 'apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1', + 'apps/ied_simulator/packaging/windows/verify-release.ps1' + ) + foreach ($script in $scripts) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path -LiteralPath $script).Path, + [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + throw "Invalid PowerShell syntax in ${script}: $($errors | Out-String)" + } + } + Write-Host 'F4_POWERSHELL_PARSE_PASS' - name: Install Qt uses: jurplel/install-qt-action@v4 @@ -53,9 +84,30 @@ jobs: arch: 'win64_msvc2022_64' cache: true - - name: Install NSIS + - name: Install and verify NSIS shell: pwsh - run: choco install nsis --yes --no-progress + run: | + $ErrorActionPreference = 'Stop' + $candidates = @( + (Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe'), + (Join-Path ${env:ProgramFiles} 'NSIS\makensis.exe'), + 'C:\ProgramData\chocolatey\bin\makensis.exe' + ) + $compiler = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-not $compiler) { + for ($attempt = 1; $attempt -le 2; $attempt++) { + choco install nsis --yes --no-progress + $exitStatus = $LASTEXITCODE + $compiler = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if ($compiler) { break } + Write-Warning "NSIS installation failed (attempt $attempt/2, Chocolatey exit $exitStatus)" + if ($attempt -lt 2) { Start-Sleep -Seconds 12 } + } + } + if (-not $compiler) { throw 'NSIS unavailable; abort field-kit build before compilation.' } + "MAKENSIS_PATH=$compiler" >> $env:GITHUB_ENV + & $compiler /VERSION + if ($LASTEXITCODE -ne 0) { throw 'makensis version preflight failed.' } - name: Configure Workbench release build shell: pwsh @@ -125,6 +177,15 @@ jobs: Copy-Item 'build-f4-windows\Release\arstack_ied_simulator.exe' $stage Copy-Item 'build-f4-windows\Release\ariec61850_ied_simulator_server.exe' $stage windeployqt --release --qmldir apps/ied_simulator/qml --dir $stage (Join-Path $stage 'arstack_ied_simulator.exe') + $sourceSha = (& git rev-parse --verify HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $sourceSha -notmatch '^[0-9a-f]{40}$' -or + $sourceSha -cne $env:F4_SOURCE_SHA) { + throw "F4 source mismatch: checkout=$sourceSha action=$env:F4_SOURCE_SHA" + } + [System.IO.File]::WriteAllText( + (Join-Path $stage 'BUILD_HEAD.txt'), + ($sourceSha + [System.Environment]::NewLine), + [System.Text.Encoding]::ASCII) - name: Build latest portable ZIP and NSIS installer shell: pwsh @@ -136,9 +197,12 @@ jobs: $zip = Join-Path $release 'ARStack-IEC61850-Workbench-portable-win64.zip' Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip -Force $installer = Join-Path $release 'ARStack-IEC61850-Workbench-Setup-win64.exe' - $makensis = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe' - if (-not (Test-Path -LiteralPath $makensis -PathType Leaf)) { throw "makensis not found: $makensis" } - & $makensis "/DSTAGE_DIR=$stage" "/DOUT_FILE=$installer" 'apps\ied_simulator\packaging\windows\ARStackIEC61850Workbench.nsi' + $makensis = $env:MAKENSIS_PATH + $icon = (Resolve-Path 'assets\favicon.ico').Path + if (-not $makensis -or -not (Test-Path -LiteralPath $makensis -PathType Leaf)) { + throw 'NSIS preflight did not supply a usable makensis.exe' + } + & $makensis "/DSTAGE_DIR=$stage" "/DOUT_FILE=$installer" "/DAPP_ICON=$icon" 'apps\ied_simulator\packaging\windows\ARStackIEC61850Workbench.nsi' if ($LASTEXITCODE -ne 0) { throw "makensis failed with exit code $LASTEXITCODE" } - name: Verify staged and installed release @@ -148,6 +212,8 @@ jobs: -StageDir ./release-stage/ARStack-IEC61850-Workbench -BuildDir ./build-f4-windows/Release -InstallerPath ./release-artifacts/ARStack-IEC61850-Workbench-Setup-win64.exe + -PortablePath ./release-artifacts/ARStack-IEC61850-Workbench-portable-win64.zip + -ExpectedSourceSha ${{ env.F4_SOURCE_SHA }} -FixturePath ./tests/fixtures/scl/client-workbench.scd - name: Assemble F4 external-acceptance kit @@ -166,7 +232,12 @@ jobs: Copy-Item 'apps\ied_simulator\field_acceptance\ACCEPTANCE_RESULT_TEMPLATE.md' $kit Copy-Item 'apps\ied_simulator\field_acceptance\collect-f4-evidence.ps1' $kit Copy-Item 'tests\fixtures\scl\interop-golden-synthetic.cid' $fixtures - Set-Content -LiteralPath (Join-Path $kit 'BUILD_HEAD.txt') -Value $env:GITHUB_SHA -Encoding ascii + $headPath = Join-Path $PWD 'release-stage\ARStack-IEC61850-Workbench\BUILD_HEAD.txt' + $stageHead = (Get-Content -LiteralPath $headPath -Raw).Trim() + if ($stageHead -cne $env:F4_SOURCE_SHA) { + throw "F4 kit source mismatch: staged=$stageHead expected=$env:F4_SOURCE_SHA" + } + Copy-Item -LiteralPath $headPath -Destination (Join-Path $kit 'BUILD_HEAD.txt') $server = Get-ChildItem -Path 'build-f4-windows' -Recurse -File -Filter 'ariec61850_ied_simulator_server.exe' | Where-Object { $_.FullName -match '[\\/]Release[\\/]' } | @@ -193,7 +264,7 @@ jobs: $manifest = [ordered]@{ schemaVersion = 'arstack-f4-field-kit-v2-interop-golden' - headSha = $env:GITHUB_SHA + headSha = $env:F4_SOURCE_SHA generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') syntheticGolden = 'passed_in_windows_preflight' externalAcceptance = 'pending_external_client' diff --git a/apps/ied_simulator/field_acceptance/ACCEPTANCE_RESULT_TEMPLATE.md b/apps/ied_simulator/field_acceptance/ACCEPTANCE_RESULT_TEMPLATE.md index f0d02b80a..bc7761add 100644 --- a/apps/ied_simulator/field_acceptance/ACCEPTANCE_RESULT_TEMPLATE.md +++ b/apps/ied_simulator/field_acceptance/ACCEPTANCE_RESULT_TEMPLATE.md @@ -1,6 +1,15 @@ # F4 External Acceptance Result -Build head: `______________________________` +Build head (kit `BUILD_HEAD.txt`): `______________________________` + +Installed/portable Workbench `BUILD_HEAD.txt` (must match for ARStack -> IED): `______________________________` + +Package provenance: [ ] verified same 40-char SHA [ ] blocked/missing evidence + +Target-bound TCP frames (from `evidence.json`): `__________` + +GOOSE PCAP and visual side-by-side evidence are separate requirements; this MMS +collector alone cannot mark complete physical IED or GUI acceptance. Date/time: `______________________________` diff --git a/apps/ied_simulator/field_acceptance/F4_REAL_IED_ACCEPTANCE.md b/apps/ied_simulator/field_acceptance/F4_REAL_IED_ACCEPTANCE.md index c63837f2e..23e2ee1bf 100644 --- a/apps/ied_simulator/field_acceptance/F4_REAL_IED_ACCEPTANCE.md +++ b/apps/ied_simulator/field_acceptance/F4_REAL_IED_ACCEPTANCE.md @@ -36,6 +36,8 @@ Required evidence: ## Windows field kit +The **F4 Real IED Acceptance RC** workflow can be dispatched for any chosen source revision. Changes to this F4 workflow, its field kit, or the Windows packaging contract also run exact-PR-head CI and post-merge main packaging. Do not use a field kit whose embedded `BUILD_HEAD.txt` differs from the exact source revision being accepted. + The F4 workflow produces a Windows artifact containing: - current Workbench installer and portable ZIP; @@ -47,7 +49,9 @@ The F4 workflow produces a Windows artifact containing: ### Capture preparation -Install Wireshark/Npcap so `dumpcap.exe` is available. List capture interfaces: +Install Wireshark/Npcap with both `dumpcap.exe` and `tshark.exe`. The capture is intentionally **bound to the exact target IPv4 address and MMS port**; unrelated traffic on TCP/102 does not qualify as acceptance. The collector refuses a missing/invalid kit SHA, missing tshark, invalid NIC index, zero observed target packets, or an installed/extracted Workbench whose SHA differs from the kit. + +List capture interfaces: ```powershell & "C:\Program Files\Wireshark\dumpcap.exe" -D @@ -72,10 +76,13 @@ Example, ARStack testing a real IED: -Mode ARStackToIED ` -TargetIp 192.168.1.10 ` -InterfaceId 4 ` + -WorkbenchDirectory 'C:\Program Files\ARStack IEC 61850 Workbench' ` -DurationSeconds 180 ``` -During the capture, execute the acceptance steps above. The collector writes a timestamped folder containing the PCAPNG, environment/preflight information, SHA-256, optional tshark packet/reset counts, and a manual-result template. +For the real-IED lane, `-WorkbenchDirectory` is **mandatory** and may reference either the installed app or the extracted portable ZIP root (both contain `BUILD_HEAD.txt` from the same release). The directory must contain `arstack_ied_simulator.exe`. The external-client-to-ARStack lane may optionally supply it, but the collector cannot independently prove which server process is active: attach server diagnostics to close that gap. + +During capture, perform the authorized steps above. The collector writes a timestamped folder containing `evidence.json`, a target-specific PCAPNG, exact source SHA, TCP preflight, SHA-256 and tshark-verified target packet/reset counts, and a **manual** result template. Capturing one TCP packet is not proof of MMS association, report reception, or hardware interoperability; inspect service-level frames and screenshots separately. Missing network visibility is **INCOMPLETE**, never PASS. ## Pass / close rule diff --git a/apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 b/apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 index a8204260a..40fa64636 100644 --- a/apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 +++ b/apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 @@ -15,7 +15,11 @@ param( [ValidateRange(30, 1800)] [int]$DurationSeconds = 180, - [string]$OutputRoot = (Join-Path $PWD 'f4-evidence') + [string]$OutputRoot = (Join-Path $PWD 'f4-evidence'), + + # Mandatory for Workbench -> IED: provenance of the ACTUAL installed + # or extracted application, not only the field-kit wrapper. + [string]$WorkbenchDirectory = '' ) $ErrorActionPreference = 'Stop' @@ -41,8 +45,50 @@ if ($Port -lt 1 -or $Port -gt 65535) { throw 'Port must be in the range 1..65535.' } +# A capture host expression and Wireshark display filter require a literal +# IPv4 endpoint. Never pass arbitrary user text through BPF/tshark filters. +[System.Net.IPAddress]$parsedIp = $null +if (-not [System.Net.IPAddress]::TryParse($TargetIp, [ref]$parsedIp) -or + $parsedIp.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork -or + $parsedIp.Equals([System.Net.IPAddress]::Any) -or + $parsedIp.Equals([System.Net.IPAddress]::Broadcast)) { + throw 'TargetIp must be a concrete IPv4 address; DNS/filter expressions are not accepted.' +} +$TargetIp = $parsedIp.ToString() + +$kitHeadFile = Join-Path $PSScriptRoot 'BUILD_HEAD.txt' +if (-not (Test-Path -LiteralPath $kitHeadFile -PathType Leaf)) { + throw 'F4 kit BUILD_HEAD.txt is missing; package provenance cannot be established.' +} +$buildHead = (Get-Content -LiteralPath $kitHeadFile -Raw).Trim() +if ($buildHead -cnotmatch '^[0-9a-f]{40}$') { + throw 'F4 kit BUILD_HEAD.txt is not an exact 40-character source SHA.' +} +$workbenchBuildHead = $null +$workbenchVerified = $false +if ($Mode -eq 'ARStackToIED' -and [string]::IsNullOrWhiteSpace($WorkbenchDirectory)) { + throw 'ARStackToIED requires -WorkbenchDirectory pointing to the installed/extracted Workbench.' +} +if (-not [string]::IsNullOrWhiteSpace($WorkbenchDirectory)) { + $verifiedDirectory = (Resolve-Path -LiteralPath $WorkbenchDirectory -ErrorAction Stop).Path + $app = Join-Path $verifiedDirectory 'arstack_ied_simulator.exe' + $installedHeadFile = Join-Path $verifiedDirectory 'BUILD_HEAD.txt' + if (-not (Test-Path -LiteralPath $app -PathType Leaf) -or + -not (Test-Path -LiteralPath $installedHeadFile -PathType Leaf)) { + throw 'Workbench executable or BUILD_HEAD.txt missing in selected installed/portable directory.' + } + $workbenchBuildHead = (Get-Content -LiteralPath $installedHeadFile -Raw).Trim() + if ($workbenchBuildHead -cne $buildHead) { + throw "Field kit/Workbench source SHA mismatch: kit=$buildHead app=$workbenchBuildHead" + } + $workbenchVerified = $true +} + $dumpcap = Resolve-Dumpcap $tshark = Resolve-Tshark +if (-not $tshark) { + throw 'tshark.exe required to verify target IPv4 MMS packet evidence (install Wireshark).' +} $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmssZ') $evidenceDir = Join-Path $OutputRoot ("F4-{0}-{1}" -f $Mode, $stamp) New-Item -ItemType Directory -Force -Path $evidenceDir | Out-Null @@ -57,13 +103,14 @@ try { Write-Host "Duration: $DurationSeconds seconds" $interfacesPath = Join-Path $evidenceDir 'capture-interfaces.txt' - & $dumpcap -D | Tee-Object -FilePath $interfacesPath | Out-Host - - $buildHead = 'unknown' - $headFile = Join-Path $PSScriptRoot 'BUILD_HEAD.txt' - if (Test-Path -LiteralPath $headFile -PathType Leaf) { - $buildHead = (Get-Content -LiteralPath $headFile -Raw).Trim() + $interfaces = @(& $dumpcap -D) + if ($LASTEXITCODE -ne 0) { throw 'dumpcap interface enumeration failed.' } + $interfaces | Set-Content -LiteralPath $interfacesPath -Encoding UTF8 + $interfaces | Out-Host + if (-not ($interfaces | Where-Object { $_ -match "^\s*$InterfaceId\." })) { + throw "Capture interface id $InterfaceId is not present in dumpcap -D." } + $buildHead | Set-Content -LiteralPath (Join-Path $evidenceDir 'workbench_build_sha.txt') -Encoding ascii $preflight = Test-NetConnection -ComputerName $TargetIp -Port $Port -WarningAction SilentlyContinue $preflight | Format-List * | Out-String | Set-Content -LiteralPath (Join-Path $evidenceDir 'tcp-preflight.txt') @@ -72,7 +119,7 @@ try { } $pcapPath = Join-Path $evidenceDir 'mms-session.pcapng' - $filter = "tcp port $Port" + $filter = "host $TargetIp and tcp port $Port" Write-Host "Starting dumpcap. Execute the F4 acceptance sequence now." Write-Host "Capture filter: $filter" @@ -92,18 +139,22 @@ try { } $pcapHash = (Get-FileHash -LiteralPath $pcapPath -Algorithm SHA256).Hash.ToLowerInvariant() - $packetCount = $null - $tcpResetCount = $null - if ($tshark) { - $packetLines = & $tshark -r $pcapPath -Y "tcp.port==$Port" -T fields -e frame.number 2>$null - $packetCount = @($packetLines | Where-Object { $_ -ne '' }).Count - $resetLines = & $tshark -r $pcapPath -Y "tcp.port==$Port && tcp.flags.reset==1" -T fields -e frame.number 2>$null - $tcpResetCount = @($resetLines | Where-Object { $_ -ne '' }).Count - } + $displayFilter = "ip.addr==$TargetIp && tcp.port==$Port" + $packetLines = @(& $tshark -r $pcapPath -Y $displayFilter -T fields -e frame.number 2>$null) + if ($LASTEXITCODE -ne 0) { throw 'tshark could not decode/verify target MMS capture.' } + $packetCount = @($packetLines | Where-Object { $_ -ne '' }).Count + $resetFilter = "$displayFilter && tcp.flags.reset==1" + $resetLines = @(& $tshark -r $pcapPath -Y $resetFilter -T fields -e frame.number 2>$null) + if ($LASTEXITCODE -ne 0) { throw 'tshark could not inspect target TCP reset evidence.' } + $tcpResetCount = @($resetLines | Where-Object { $_ -ne '' }).Count $metadata = [ordered]@{ - schemaVersion = 'arstack-f4-evidence-v1' + schemaVersion = 'arstack-f4-evidence-v2-target-bound' buildHead = $buildHead + workbenchBuildHead = $workbenchBuildHead + workbenchProvenanceVerified = $workbenchVerified + captureFilter = $filter + targetPacketsVerified = ($packetCount -gt 0) mode = $Mode targetIp = $TargetIp port = $Port @@ -119,6 +170,9 @@ try { externalAcceptance = 'pending_manual_review' } $metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $evidenceDir 'evidence.json') -Encoding UTF8 + if ($packetCount -eq 0) { + throw 'No on-wire MMS TCP packets from the target were captured; verdict must be INCOMPLETE.' + } $template = Join-Path $PSScriptRoot 'ACCEPTANCE_RESULT_TEMPLATE.md' if (Test-Path -LiteralPath $template -PathType Leaf) { diff --git a/apps/ied_simulator/test_f4_release_contract.py b/apps/ied_simulator/test_f4_release_contract.py new file mode 100644 index 000000000..addeb3592 --- /dev/null +++ b/apps/ied_simulator/test_f4_release_contract.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Fail-closed regression for F4's staged/portable/installed source contract. + +This is a cheap guard against build-workflow drift; the F4 Windows job still +must perform the actual NSIS install, ZIP extraction, and smoke tests. +""" +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +F4 = ROOT / ".github/workflows/ied-real-device-f4.yml" +VERIFY = ROOT / "apps/ied_simulator/packaging/windows/verify-release.ps1" +NSI = ROOT / "apps/ied_simulator/packaging/windows/ARStackIEC61850Workbench.nsi" +COLLECTOR = ROOT / "apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1" + + +def check(workflow: str, verifier: str, installer: str, collector: str) -> None: + failures: list[str] = [] + + def require(haystack: str, needle: str, label: str) -> None: + if needle not in haystack: + failures.append(label) + + require(workflow, "pull_request:", "exact-head PR trigger") + require(workflow, "branches: [main]", "post-merge main trigger") + require(workflow, "workflow_dispatch:", "manual on-demand kit rebuild") + require(workflow, "test_f4_release_contract.py", "static contract gate") + require(workflow, "-PortablePath ./release-artifacts/ARStack-IEC61850-Workbench-portable-win64.zip", + "real portable ZIP verified") + require(workflow, "F4_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}", + "PR event must select true head SHA") + if workflow.count("ref: ${{ env.F4_SOURCE_SHA }}") != 2: + failures.append("both Linux and Windows checkouts must use real PR head") + require(workflow, "-ExpectedSourceSha ${{ env.F4_SOURCE_SHA }}", "exact source SHA passed to verifier") + require(workflow, "'BUILD_HEAD.txt'", "source SHA staged and copied into kit") + require(workflow, "git rev-parse --verify HEAD", "git checkout SHA checked") + require(workflow, "$sourceSha -cne $env:F4_SOURCE_SHA", "fail closed on wrong checkout") + require(workflow, "$stageHead -cne $env:F4_SOURCE_SHA", "fail closed on stale kit SHA") + require(workflow, "headSha = $env:F4_SOURCE_SHA", "manifest uses source SHA") + require(workflow, "/DAPP_ICON=$icon", "mandatory NSIS icon supplied") + require(workflow, "MAKENSIS_PATH", "NSIS preflight result reused") + require(workflow, "F4_BUILD_MANIFEST.json", "kit manifest retained") + require(workflow, "SHA256SUMS.txt", "kit hash manifest retained") + require(workflow, "externalAcceptance = 'pending_external_client'", + "physical/vendor acceptance never auto-passed") + + require(verifier, "[string]$PortablePath", "verifier ZIP argument") + require(verifier, "[string]$ExpectedSourceSha", "verifier commit argument") + require(verifier, "Assert-BuildHead $stage", "staged build-head checked") + require(verifier, "Assert-BuildHead $portableDir", "actual ZIP build-head checked") + require(verifier, "Assert-BuildHead $installDir", "installed build-head checked") + require(verifier, "Portable ZIP smoke test", "actual ZIP binary smoke-launched") + require(verifier, "Installed package smoke test", "NSIS-installed binary smoke-launched") + require(installer, "!ifndef APP_ICON", "NSIS rejects omitted icon") + require(collector, "[string]$WorkbenchDirectory", "installed Workbench path provided") + require(collector, "$workbenchBuildHead -cne $buildHead", "installed source SHA mismatch fails") + require(collector, "if ($buildHead -cnotmatch", "invalid kit source SHA fails") + require(collector, 'throw \'F4 kit BUILD_HEAD.txt is missing', "unknown kit SHA fails") + require(collector, '$filter = "host $TargetIp and tcp port $Port"', "on-wire capture is target bound") + require(collector, "$displayFilter = \"ip.addr==$TargetIp && tcp.port==$Port\"", "verified packets belong to target") + require(collector, "if ($packetCount -eq 0)", "empty target trace fails") + require(collector, "tshark.exe required", "tshark evidence is mandatory") + require(collector, '$_ -match "^\\s*$InterfaceId\\."', "valid numbered dumpcap interface regex") + + if failures: + raise RuntimeError("F4_RELEASE_CONTRACT_FAIL " + ", ".join(failures)) + + +def main() -> None: + f4 = F4.read_text(encoding="utf-8") + verify = VERIFY.read_text(encoding="utf-8") + nsi = NSI.read_text(encoding="utf-8") + collector = COLLECTOR.read_text(encoding="utf-8") + check(f4, verify, nsi, collector) + + # Mutation controls prove that omissions in either source are rejected. + for source, needle in ( + ("f4", "-ExpectedSourceSha ${{ env.F4_SOURCE_SHA }}"), + ("f4", "F4_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}"), + ("f4", "ref: ${{ env.F4_SOURCE_SHA }}"), + ("f4", "/DAPP_ICON=$icon"), + ("f4", "$stageHead -cne $env:F4_SOURCE_SHA"), + ("verify", "Assert-BuildHead $installDir"), + ("verify", "Portable ZIP smoke test"), + ("nsi", "!ifndef APP_ICON"), + ("collector", "$workbenchBuildHead -cne $buildHead"), + ("collector", 'if ($packetCount -eq 0)'), + ): + original = {"f4": f4, "verify": verify, "nsi": nsi, "collector": collector} + original[source] = original[source].replace(needle, "BROKEN_CONTRACT", 1) + try: + check(original["f4"], original["verify"], original["nsi"], original["collector"]) + except RuntimeError as exc: + if "F4_RELEASE_CONTRACT_FAIL" not in str(exc): + raise + else: + raise AssertionError("Contract regression failed to detect " + needle) + print("F4_RELEASE_CONTRACT_PASS source=verified zip=verified installed=verified target_pcap=guarded") + + +if __name__ == "__main__": + main()