From 39628536336270b7f7c46b2e081fb38892e9ef38 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:23:43 +0700 Subject: [PATCH 01/10] fix(f4): restore source-verified Windows field-kit package and CI routing --- .github/workflows/ied-real-device-f4.yml | 83 ++++++++++++++++++------ 1 file changed, 62 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ied-real-device-f4.yml b/.github/workflows/ied-real-device-f4.yml index fb35771a0..28c186e29 100644 --- a/.github/workflows/ied-real-device-f4.yml +++ b/.github/workflows/ied-real-device-f4.yml @@ -1,25 +1,23 @@ 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 @@ -31,6 +29,9 @@ jobs: steps: - uses: actions/checkout@v4 + - 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 @@ -53,9 +54,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 +147,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:GITHUB_SHA) { + throw "F4 source mismatch: checkout=$sourceSha action=$env:GITHUB_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 +167,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 +182,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 ${{ github.sha }} -FixturePath ./tests/fixtures/scl/client-workbench.scd - name: Assemble F4 external-acceptance kit @@ -166,7 +202,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:GITHUB_SHA) { + throw "F4 kit source mismatch: staged=$stageHead expected=$env:GITHUB_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[\\/]' } | From ff78c2a3f3e75628c1048fea3f0da3f1d8a4a75a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:24:22 +0700 Subject: [PATCH 02/10] test(f4): guard exact-head provenance across field-kit packaging --- .../ied_simulator/test_f4_release_contract.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 apps/ied_simulator/test_f4_release_contract.py 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..c6ce466e2 --- /dev/null +++ b/apps/ied_simulator/test_f4_release_contract.py @@ -0,0 +1,83 @@ +#!/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" + + +def check(workflow: str, verifier: str, installer: 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, "-ExpectedSourceSha ${{ github.sha }}", "exact GitHub SHA bound") + 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:GITHUB_SHA", "fail closed on wrong checkout") + require(workflow, "$stageHead -cne $env:GITHUB_SHA", "fail closed on stale kit 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") + + 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") + check(f4, verify, nsi) + + # Mutation controls prove that omissions in either source are rejected. + for source, needle in ( + ("f4", "-ExpectedSourceSha ${{ github.sha }}"), + ("f4", "/DAPP_ICON=$icon"), + ("f4", "$stageHead -cne $env:GITHUB_SHA"), + ("verify", "Assert-BuildHead $installDir"), + ("verify", "Portable ZIP smoke test"), + ("nsi", "!ifndef APP_ICON"), + ): + original = {"f4": f4, "verify": verify, "nsi": nsi} + original[source] = original[source].replace(needle, "BROKEN_CONTRACT", 1) + try: + check(original["f4"], original["verify"], original["nsi"]) + 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") + + +if __name__ == "__main__": + main() From f92e59372cab2700253007dd2e7322673eaca5fa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:25:20 +0700 Subject: [PATCH 03/10] fix(f4): bind field traces to real endpoint and matching installed Workbench source SHA --- .../field_acceptance/collect-f4-evidence.ps1 | 88 +++++++++++++++---- 1 file changed, 71 insertions(+), 17 deletions(-) diff --git a/apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 b/apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 index a8204260a..08b8411c5 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) { From de5726f19c9f9595da43da28c5b642fb94994176 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:25:43 +0700 Subject: [PATCH 04/10] docs(f4): exact SHA, target-bound packet capture and installed Workbench prerequisites --- .../field_acceptance/F4_REAL_IED_ACCEPTANCE.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 From e562107b364d68cb414866cf9f87a7c7a56e36d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:26:08 +0700 Subject: [PATCH 05/10] test(f4): reject unsafe source provenance and unrelated/empty TCP captures --- .../ied_simulator/test_f4_release_contract.py | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/ied_simulator/test_f4_release_contract.py b/apps/ied_simulator/test_f4_release_contract.py index c6ce466e2..2c9441fa8 100644 --- a/apps/ied_simulator/test_f4_release_contract.py +++ b/apps/ied_simulator/test_f4_release_contract.py @@ -12,9 +12,10 @@ 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) -> None: +def check(workflow: str, verifier: str, installer: str, collector: str) -> None: failures: list[str] = [] def require(haystack: str, needle: str, label: str) -> None: @@ -47,6 +48,14 @@ def require(haystack: str, needle: str, label: str) -> None: 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") if failures: raise RuntimeError("F4_RELEASE_CONTRACT_FAIL " + ", ".join(failures)) @@ -56,7 +65,8 @@ def main() -> None: f4 = F4.read_text(encoding="utf-8") verify = VERIFY.read_text(encoding="utf-8") nsi = NSI.read_text(encoding="utf-8") - check(f4, verify, nsi) + 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 ( @@ -66,17 +76,19 @@ def main() -> None: ("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} + 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"]) + 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") + print("F4_RELEASE_CONTRACT_PASS source=verified zip=verified installed=verified target_pcap=guarded") if __name__ == "__main__": From 9789bfb89a07708f4493824adf0bc66a553f62b1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:26:36 +0700 Subject: [PATCH 06/10] ci(f4): parse PowerShell evidence scripts before costly Qt/NSIS build --- .github/workflows/ied-real-device-f4.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ied-real-device-f4.yml b/.github/workflows/ied-real-device-f4.yml index 28c186e29..86589d1c7 100644 --- a/.github/workflows/ied-real-device-f4.yml +++ b/.github/workflows/ied-real-device-f4.yml @@ -47,6 +47,26 @@ jobs: steps: - uses: actions/checkout@v4 + - 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 with: From 1fc5f4948bee8932b85461825c3fc737519ec4ff Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:26:48 +0700 Subject: [PATCH 07/10] docs(f4): record actual installed build SHA and target-bound packet evidence --- .../field_acceptance/ACCEPTANCE_RESULT_TEMPLATE.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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: `______________________________` From fa51e49f77f743e716e43218619c5ab6f304f05a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:29:49 +0700 Subject: [PATCH 08/10] fix(f4): parse dumpcap interface index with real PowerShell regex escapes --- apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 b/apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 index 08b8411c5..40fa64636 100644 --- a/apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 +++ b/apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1 @@ -107,7 +107,7 @@ try { 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\\." })) { + 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 From 482c68d48c48faa61778009b07d2e8287a942aef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:30:12 +0700 Subject: [PATCH 09/10] fix(f4): checkout and label exact PR HEAD, never synthetic merge SHA --- .github/workflows/ied-real-device-f4.yml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ied-real-device-f4.yml b/.github/workflows/ied-real-device-f4.yml index 86589d1c7..a99dceb23 100644 --- a/.github/workflows/ied-real-device-f4.yml +++ b/.github/workflows/ied-real-device-f4.yml @@ -22,12 +22,20 @@ on: 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 @@ -46,6 +54,8 @@ 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 @@ -169,8 +179,8 @@ jobs: 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:GITHUB_SHA) { - throw "F4 source mismatch: checkout=$sourceSha action=$env:GITHUB_SHA" + $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'), @@ -203,7 +213,7 @@ jobs: -BuildDir ./build-f4-windows/Release -InstallerPath ./release-artifacts/ARStack-IEC61850-Workbench-Setup-win64.exe -PortablePath ./release-artifacts/ARStack-IEC61850-Workbench-portable-win64.zip - -ExpectedSourceSha ${{ github.sha }} + -ExpectedSourceSha ${{ env.F4_SOURCE_SHA }} -FixturePath ./tests/fixtures/scl/client-workbench.scd - name: Assemble F4 external-acceptance kit @@ -224,8 +234,8 @@ jobs: Copy-Item 'tests\fixtures\scl\interop-golden-synthetic.cid' $fixtures $headPath = Join-Path $PWD 'release-stage\ARStack-IEC61850-Workbench\BUILD_HEAD.txt' $stageHead = (Get-Content -LiteralPath $headPath -Raw).Trim() - if ($stageHead -cne $env:GITHUB_SHA) { - throw "F4 kit source mismatch: staged=$stageHead expected=$env:GITHUB_SHA" + 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') @@ -254,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' From f8029fc18d17fa31f52a58b4b966d2e7667f7388 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 09:30:27 +0700 Subject: [PATCH 10/10] test(f4): enforce actual PR head lineage and valid NIC regex --- apps/ied_simulator/test_f4_release_contract.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/ied_simulator/test_f4_release_contract.py b/apps/ied_simulator/test_f4_release_contract.py index 2c9441fa8..addeb3592 100644 --- a/apps/ied_simulator/test_f4_release_contract.py +++ b/apps/ied_simulator/test_f4_release_contract.py @@ -28,11 +28,16 @@ def require(haystack: str, needle: str, label: str) -> None: 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, "-ExpectedSourceSha ${{ github.sha }}", "exact GitHub SHA bound") + 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:GITHUB_SHA", "fail closed on wrong checkout") - require(workflow, "$stageHead -cne $env:GITHUB_SHA", "fail closed on stale kit SHA") + 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") @@ -56,6 +61,7 @@ def require(haystack: str, needle: str, label: str) -> None: 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)) @@ -70,9 +76,11 @@ def main() -> None: # Mutation controls prove that omissions in either source are rejected. for source, needle in ( - ("f4", "-ExpectedSourceSha ${{ github.sha }}"), + ("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:GITHUB_SHA"), + ("f4", "$stageHead -cne $env:F4_SOURCE_SHA"), ("verify", "Assert-BuildHead $installDir"), ("verify", "Portable ZIP smoke test"), ("nsi", "!ifndef APP_ICON"),