diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml new file mode 100644 index 000000000..13b6ed527 --- /dev/null +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -0,0 +1,288 @@ +name: Smart Discovery Field Capture Build + +on: + pull_request: + workflow_dispatch: + +jobs: + build-smart-capture: + name: Build smart-discovery portable field capture + runs-on: windows-latest + steps: + - name: Checkout ARSAS test branch + shell: powershell + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --depth 1 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + + - name: Resolve and validate engine pin + shell: powershell + 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 field-capture pin.' + } + if ($lock.commit -ne '4467124775d8d9d76f3db194f9fbfd97144767a8') { + throw "Unexpected engine commit: $($lock.commit)" + } + $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim() + if ($arsasCommit -notmatch '^[0-9a-f]{40}$') { + throw "Invalid cloned ARSAS commit: $arsasCommit" + } + "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_COMMIT=$($lock.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 + Write-Host "ARSAS field source: $arsasCommit" + + - name: Verify P0-5c smart capture sources + shell: powershell + run: | + $helper = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw + $optimization = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryOptimization.cs -Raw + $lifecycle = Get-Content .\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryLifecycle.cs -Raw + $patcher = Get-Content .\ArIED61850Tester\scripts\enable-smart-discovery-capture.ps1 -Raw + $targets = Get-Content .\ArIED61850Tester\Directory.Build.targets -Raw + if ($helper -notmatch 'DiscoverSmartSingleFlightAsync' -or + $helper -notmatch 'LiveIedVariableTypeProbeExecutor' -or + $helper -notmatch 'variableTypeAttributes' -or + $helper -notmatch 'SMART-CAPTURE PR134 P0-5c' -or + $helper -notmatch 'control inventory=authoritative' -or + $helper -notmatch 'GetOrCreateSmartDiscoveryAssociationFlight' -or + $helper -notmatch 'flight\.WaitAsync\(cancellationToken\)' -or + $helper -notmatch '_mmsIoGate\.WaitAsync\(CancellationToken\.None\)' -or + $helper -notmatch 'ProbeSmartAsync\(_session, discovery\.IedDirectory, smartOptions, CancellationToken\.None\)' -or + $optimization -notmatch 'TryGetSmartDiscoveryAuthority' -or + $optimization -notmatch 'BuildSmartCaptureSignalProjection' -or + $optimization -notmatch 'AddSmartIndexedLogicalNodeFallbacks' -or + $optimization -notmatch 'IsSmartDiscoveryAuthorityBoundToCurrentAssociation' -or + $lifecycle -notmatch '_smartDiscoveryAssociationGeneration' -or + $lifecycle -notmatch '_smartDiscoveryAssociationFlight' -or + $lifecycle -notmatch 'TryPublishSmartDiscoveryAuthority' -or + $lifecycle -notmatch 'generation != _smartDiscoveryAssociationGeneration' -or + $patcher -notmatch '__P0_5C_CONNECT_RESET__' -or + $patcher -notmatch '__P0_5C_DISPOSE_RESET__' -or + $patcher -notmatch '_lastDiscovery\.Snapshot\.DomainVariables' -or + $targets -notmatch 'EnableSmartDiscoveryCaptureRoute') { + throw 'P0-5c association-scoped enrichment single-flight, authority publication, or lifecycle invalidation is incomplete.' + } + if ($helper -match 'ProbeSmartAsync\(_session, discovery\.IedDirectory, smartOptions, cancellationToken\)' -or + $helper -match 'AddGenericLogicalNodeFallbacksFromDiscoveryArtifacts' -or + $helper -match 'FinalizeDiscoveredSignals') { + throw 'P0-5c critical path regressed to caller-cancellable GVA, reflection fallback, or second full finalization.' + } + + - name: Execute P0-5d wire-proof regressions + shell: powershell + run: | + $verifier = ".\ArIED61850Tester\scripts\verify-smart-discovery-pcap.ps1" + $contract = ".\ArIED61850Tester\docs\P0-5D_PHYSICAL_CAPTURE_PROOF.md" + $regression = ".\ArIED61850Tester\tests\ARSAS.Tests\SmartDiscoveryPhysicalCaptureProofRegressionTests.cs" + foreach ($required in @($verifier, $contract, $regression)) { + if (-not (Test-Path $required -PathType Leaf)) { throw "P0-5d source missing: $required" } + } + + $tokens = $null + $parseErrors = $null + [System.Management.Automation.Language.Parser]::ParseFile($verifier, [ref]$tokens, [ref]$parseErrors) | Out-Null + if ($parseErrors.Count -ne 0) { + throw "P0-5d verifier has PowerShell parse errors: $($parseErrors | ForEach-Object Message -join '; ')" + } + + $source = Get-Content $verifier -Raw + foreach ($token in @( + 'Get-RequestFingerprint', + 'DuplicateSemanticRequests', + 'DuplicateGetNameListRequests', + 'DuplicateGvaRequests', + 'SecondGetNameListSweepDetected', + 'PeakOutstandingRequests', + 'negociatedMaxServOutstandingCalling', + 'ReferencePcapPath', + 'DecodedRowsPath')) { + if ($source -notmatch [regex]::Escape($token)) { throw "P0-5d verifier contract missing: $token" } + } + + New-Item -ItemType Directory -Force .\ArIED61850Tester\TestResults | Out-Null + function New-Row($frame, $src, $dst, $invoke, $request, $response, $gnl, $gva, $domain, $item, $negotiated) { + [pscustomobject][ordered]@{ + 'frame.number' = $frame + 'frame.time_epoch' = "1.$frame" + 'ip.src' = $src + 'ip.dst' = $dst + 'tcp.stream' = '0' + 'mms.invokeID' = $invoke + 'mms.confirmed_requestPDU' = $request + 'mms.confirmed_responsePDU' = $response + 'mms.confirmed_errorPDU' = '' + 'mms.confirmedServiceRequest' = '' + 'mms.getNameList_element' = $gnl + 'mms.getVariableAccessAttributes_element' = $gva + 'mms.getNamedVariableListAttributes_element' = '' + 'mms.read_element' = '' + 'mms.objectClass' = if ($gnl) { '9' } else { '' } + 'mms.objectScope' = if ($gnl) { '1' } else { '' } + 'mms.domainId' = $domain + 'mms.objectName_domain_specific_itemId' = $item + 'mms.getNameList-Request_continueAfter' = '' + 'mms.negociatedMaxServOutstandingCalling' = $negotiated + } + } + + $client = '192.0.2.10' + $server = '192.0.2.20' + $passRows = @( + (New-Row 1 $server $client '' '' '' '' '' '' '' '10'), + (New-Row 2 $client $server '1' '1' '' '1' '' 'LD0' '' ''), + (New-Row 3 $client $server '2' '1' '' '' '1' 'LD0' 'LLN0$ST$Mod' ''), + (New-Row 4 $server $client '2' '' '1' '' '' '' '' ''), + (New-Row 5 $server $client '1' '' '1' '' '' '' '' '') + ) + $passFixture = '.\ArIED61850Tester\TestResults\p0-5d-pass.tsv' + $passJson = '.\ArIED61850Tester\TestResults\P0-5D-fixture-pass.json' + $passRows | Export-Csv -Delimiter "`t" -NoTypeInformation -Encoding utf8 $passFixture + & $verifier -DecodedRowsPath $passFixture -OutputJson $passJson + $pass = Get-Content $passJson -Raw | ConvertFrom-Json + if ($pass.Verdict -ne 'PASS' -or $pass.ArsasCapture.PeakOutstandingRequests -ne 2 -or $pass.ArsasCapture.DuplicateSemanticRequests -ne 0) { + throw 'P0-5d PASS fixture produced incorrect proof metrics.' + } + + $failRows = @($passRows) + $failRows += (New-Row 6 $client $server '3' '1' '' '1' '' 'LD0' '' '') + $failRows += (New-Row 7 $server $client '3' '' '1' '' '' '' '' '') + $failFixture = '.\ArIED61850Tester\TestResults\p0-5d-duplicate.tsv' + $failJson = '.\ArIED61850Tester\TestResults\P0-5D-fixture-duplicate.json' + $failRows | Export-Csv -Delimiter "`t" -NoTypeInformation -Encoding utf8 $failFixture + & $verifier -DecodedRowsPath $failFixture -OutputJson $failJson -NoFailExit + $fail = Get-Content $failJson -Raw | ConvertFrom-Json + if ($fail.Verdict -ne 'FAIL' -or $fail.ArsasCapture.DuplicateGetNameListRequests -lt 1 -or -not $fail.ArsasCapture.SecondGetNameListSweepDetected) { + throw 'P0-5d duplicate fixture was not rejected by the semantic wire proof.' + } + + - name: Checkout immutable ARIEC61850 PR 134 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() + if ($actual -ne $env:ARIEC61850_COMMIT) { throw "Engine SHA mismatch: $actual" } + $smart = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscovery.cs -Raw + $singleFlight = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscoverySingleFlight.cs -Raw + $smartTypes = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartVariableAccessAttributes.cs -Raw + $controlTransport = Get-Content .\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlTransport.cs -Raw + $controlService = Get-Content .\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlService.cs -Raw + $controlTest = Get-Content .\ARIEC61850\tests\AR.Iec61850.Tests\Control\AuthoritativeControlInventoryTests.cs -Raw + $hierarchy = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedVariableTypeHierarchy.cs -Raw + if ($smart -notmatch 'DiscoverSmartAsync' -or + $singleFlight -notmatch 'DiscoverSmartSingleFlightAsync' -or + $singleFlight -notmatch 'GetAuthoritativeDomainVariableNamesAsync' -or + $singleFlight -notmatch 'WaitAsync\(cancellationToken\)' -or + $singleFlight -notmatch 'incompleteChains=0' -or + $smartTypes -notmatch 'LastSmartTypeProbeBudget' -or + $smartTypes -notmatch 'SuppressedNonLiveLogicalNodeCandidates' -or + $smartTypes -notmatch 'SuppressedExactRepeatRequests' -or + $smartTypes -notmatch 'BuildUnprobedExactFallbacks' -or + $controlTransport -notmatch 'GetAuthoritativeDomainVariableNamesAsync' -or + $controlTransport -match '=> _session\.DiscoverDomainVariableNamesAsync\(cancellationToken\)' -or + $controlService -notmatch 'authoritativeDomainVariables' -or + $controlService -notmatch 'domainInventory=authoritative-reuse' -or + $controlTest -notmatch 'DoesNotBrowseDomainVariablesAgain' -or + $hierarchy -notmatch 'ProbeSmartAsync') { + 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@v4 + 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 + 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.' + } + 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.' + } + if ($native -notmatch 'public async ValueTask DisposeAsync\(\)\s*\{\s*ResetSmartDiscoveryAuthority\(\);\s*// __P0_5C_DISPOSE_RESET__') { + 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.' + } + + - 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 + + - name: Publish portable single EXE x64 + shell: powershell + run: | + .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` + -Version $env:ARSAS_VERSION ` + -Runtime win-x64 ` + -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" + + - name: Smoke test portable executable + shell: powershell + run: | + $exe = ".\ArIED61850Tester\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable.exe" + if (-not (Test-Path $exe -PathType Leaf)) { throw "Portable EXE missing: $exe" } + $env:DOTNET_BUNDLE_EXTRACT_BASE_DIR = Join-Path $env:RUNNER_TEMP 'ARSAS-smart-capture-bundle-cache' + $process = Start-Process -FilePath $exe -ArgumentList @('--portable-smoke-test') -PassThru + if (-not $process.WaitForExit(30000)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw 'Portable EXE smoke test timed out.' + } + if ($process.ExitCode -ne 0) { throw "Portable EXE smoke test failed: $($process.ExitCode)" } + @( + "ARSAS smart discovery field-capture build", + "ARSAS commit: $env:ARSAS_COMMIT", + "ARIEC61850 commit: $env:ARIEC61850_COMMIT", + "Engine PR: 134", + "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", + "Field invariant: one association capture, zero duplicate semantic confirmed requests, peak outstanding never above negotiated calling limit", + "Deferred during discovery: supplemental naming, eager report/dataset enrichment, custom sibling/equipment/reference/unit probes" + ) | Set-Content .\ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt -Encoding utf8 + + - name: Upload smart field-capture build + uses: actions/upload-artifact@v4 + with: + name: ARSAS-smart-discovery-pr134-win-x64 + path: | + ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt + ArIED61850Tester\scripts\verify-smart-discovery-pcap.ps1 + ArIED61850Tester\scripts\new-smart-discovery-golden-lock.ps1 + ArIED61850Tester\scripts\verify-smart-discovery-golden-lock.ps1 + ArIED61850Tester\docs\P0-5D_PHYSICAL_CAPTURE_PROOF.md + ArIED61850Tester\docs\P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md + ArIED61850Tester\evidence\smart-discovery-golden-target.json + if-no-files-found: error + retention-days: 14 + + - name: Upload regression evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-smart-discovery-pr134-test-evidence + path: | + ArIED61850Tester\TestResults\*.trx + ArIED61850Tester\TestResults\P0-5D-*.json + ArIED61850Tester\TestResults\P0-5E-*.json + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/smart-discovery-golden-budget-lock.yml b/.github/workflows/smart-discovery-golden-budget-lock.yml new file mode 100644 index 000000000..0459d702f --- /dev/null +++ b/.github/workflows/smart-discovery-golden-budget-lock.yml @@ -0,0 +1,188 @@ +name: Smart Discovery Golden Budget Lock + +on: + pull_request: + workflow_dispatch: + +jobs: + verify-golden-lock: + name: Verify P0-5e evidence-derived hard budget + runs-on: windows-latest + steps: + - name: Checkout ARSAS branch + shell: powershell + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --depth 1 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + + - name: Validate P0-5e production target remains evidence-gated + shell: powershell + run: | + $targetPath = '.\ArIED61850Tester\evidence\smart-discovery-golden-target.json' + $target = Get-Content $targetPath -Raw | ConvertFrom-Json + if ($target.Phase -ne 'P0-5e' -or $target.Status -ne 'awaiting-fresh-physical-budget-lock') { + throw 'P0-5e target state is invalid.' + } + if ($null -ne $target.BudgetAuthority.BudgetValues) { + throw 'Production hard budget must not be populated by CI fixture values.' + } + if ($target.DeviceIdentity -ne 'AA1E1F06R4' -or + $target.SemanticTarget.LogicalDevices -ne 32 -or + $target.SemanticTarget.LogicalNodes -ne 119 -or + $target.SemanticTarget.SemanticLeaves -ne 4925 -or + $target.SemanticTarget.DataSets -ne 2 -or + $target.SemanticTarget.OrderedFcdaMembers -ne 58 -or + $target.SemanticTarget.LogicalReportControls -ne 32 -or + $target.SemanticTarget.RuntimeReportControlInstances -ne 34) { + throw 'P0-5e same-IED semantic target changed unexpectedly.' + } + + - name: Execute P0-5e golden budget fixtures + shell: powershell + run: | + $writer = '.\ArIED61850Tester\scripts\new-smart-discovery-golden-lock.ps1' + $verifier = '.\ArIED61850Tester\scripts\verify-smart-discovery-golden-lock.ps1' + $target = '.\ArIED61850Tester\evidence\smart-discovery-golden-target.json' + foreach ($required in @($writer, $verifier, $target)) { + if (-not (Test-Path $required -PathType Leaf)) { throw "P0-5e source missing: $required" } + } + + foreach ($script in @($writer, $verifier)) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($script, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -ne 0) { throw "PowerShell parse failure in $script" } + } + + $results = '.\ArIED61850Tester\TestResults' + New-Item -ItemType Directory -Force $results | Out-Null + $capture = Join-Path $results 'p0-5e-fixture.pcapng' + [IO.File]::WriteAllBytes($capture, [Text.Encoding]::UTF8.GetBytes('CI fixture only - not physical evidence')) + + $proofPath = Join-Path $results 'P0-5D-fixture-golden-source.json' + $proof = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5d' + Verdict = 'PASS' + AcceptanceFailures = @() + ArsasCapture = [ordered]@{ + Capture = 'fixture' + ClientIp = '192.0.2.10' + ServerIp = '192.0.2.20' + RequestTcpStreams = @('0') + ConfirmedRequests = 4 + ConfirmedResponsesOrErrors = 4 + ServiceCounts = [ordered]@{ + GetNameList = 2 + GetVariableAccessAttributes = 2 + } + DuplicateSemanticRequests = 0 + DuplicateGetNameListRequests = 0 + DuplicateGvaRequests = 0 + DuplicateDetails = @() + SecondGetNameListSweepDetected = $false + PeakOutstandingRequests = 3 + NegotiatedMaxOutstandingCalling = 10 + InvokeIdReuseWhileOutstanding = 0 + OrphanResponses = 0 + UnansweredRequestsAtCaptureEnd = 0 + } + } + $proof | ConvertTo-Json -Depth 12 | Set-Content $proofPath -Encoding utf8 + + $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() + $engineCommit = '4467124775d8d9d76f3db194f9fbfd97144767a8' + $manifest = Join-Path $results 'SMART-CAPTURE-BUILD.txt' + @( + 'ARSAS smart discovery field-capture build', + "ARSAS commit: $arsasCommit", + "ARIEC61850 commit: $engineCommit", + 'Engine PR: 134' + ) | Set-Content $manifest -Encoding utf8 + + $lockPath = Join-Path $results 'P0-5E-fixture-golden.lock.json' + & $writer ` + -ProofJson $proofPath ` + -CapturePath $capture ` + -DeviceIdentity 'AA1E1F06R4' ` + -ArsasCommit $arsasCommit ` + -EngineCommit $engineCommit ` + -TargetPath $target ` + -BuildManifestPath $manifest ` + -OutputPath $lockPath ` + -AllowFixtureEvidence + + $lock = Get-Content $lockPath -Raw | ConvertFrom-Json + if ($lock.Status -ne 'locked' -or + $lock.SchemaVersion -ne 2 -or + $lock.HardRequestBudget.MaxConfirmedRequests -ne 4 -or + $lock.HardRequestBudget.MaxServiceRequests.GetNameList -ne 2 -or + $lock.HardRequestBudget.MaxServiceRequests.GetVariableAccessAttributes -ne 2 -or + $lock.GoldenSource.CaptureSha256 -notmatch '^[0-9a-f]{64}$' -or + $lock.GoldenSource.ProofSha256 -notmatch '^[0-9a-f]{64}$' -or + $lock.GoldenSource.BuildManifestSha256 -notmatch '^[0-9a-f]{64}$' -or + $lock.GoldenSource.SemanticTargetSha256 -notmatch '^[0-9a-f]{64}$') { + throw 'P0-5e lock writer did not derive the expected fixture budget/provenance.' + } + + $acceptPath = Join-Path $results 'P0-5E-fixture-accept-pass.json' + & $verifier ` + -LockPath $lockPath ` + -ProofJson $proofPath ` + -DeviceIdentity 'AA1E1F06R4' ` + -CandidateArsasCommit $arsasCommit ` + -CandidateEngineCommit $engineCommit ` + -TargetPath $target ` + -OutputJson $acceptPath + $accept = Get-Content $acceptPath -Raw | ConvertFrom-Json + if ($accept.Verdict -ne 'PASS') { throw 'P0-5e golden fixture should pass its own lock.' } + + $growthProofPath = Join-Path $results 'P0-5D-fixture-growth.json' + $growth = Get-Content $proofPath -Raw | ConvertFrom-Json + $growth.ArsasCapture.ConfirmedRequests = 5 + $growth.ArsasCapture.ServiceCounts.GetVariableAccessAttributes = 3 + $growth | ConvertTo-Json -Depth 12 | Set-Content $growthProofPath -Encoding utf8 + $growthAcceptPath = Join-Path $results 'P0-5E-fixture-growth-rejected.json' + & $verifier ` + -LockPath $lockPath ` + -ProofJson $growthProofPath ` + -DeviceIdentity 'AA1E1F06R4' ` + -CandidateArsasCommit $arsasCommit ` + -CandidateEngineCommit $engineCommit ` + -TargetPath $target ` + -OutputJson $growthAcceptPath ` + -NoFailExit + $growthAcceptance = Get-Content $growthAcceptPath -Raw | ConvertFrom-Json + if ($growthAcceptance.Verdict -ne 'FAIL' -or + -not (@($growthAcceptance.AcceptanceFailures) -match 'Confirmed request budget exceeded')) { + throw 'P0-5e failed to reject confirmed-request growth.' + } + + $serviceProofPath = Join-Path $results 'P0-5D-fixture-unexpected-service.json' + $serviceGrowth = Get-Content $proofPath -Raw | ConvertFrom-Json + $serviceGrowth.ArsasCapture.ServiceCounts | Add-Member -NotePropertyName Read -NotePropertyValue 1 + $serviceGrowth | ConvertTo-Json -Depth 12 | Set-Content $serviceProofPath -Encoding utf8 + $serviceAcceptPath = Join-Path $results 'P0-5E-fixture-unexpected-service-rejected.json' + & $verifier ` + -LockPath $lockPath ` + -ProofJson $serviceProofPath ` + -DeviceIdentity 'AA1E1F06R4' ` + -CandidateArsasCommit $arsasCommit ` + -CandidateEngineCommit $engineCommit ` + -TargetPath $target ` + -OutputJson $serviceAcceptPath ` + -NoFailExit + $serviceAcceptance = Get-Content $serviceAcceptPath -Raw | ConvertFrom-Json + if ($serviceAcceptance.Verdict -ne 'FAIL' -or + -not (@($serviceAcceptance.AcceptanceFailures) -match "Unexpected MMS service 'Read'")) { + throw 'P0-5e failed to reject an unexpected MMS service.' + } + + - name: Upload P0-5e regression evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-p0-5e-golden-budget-fixtures + path: ArIED61850Tester\TestResults\P0-5E-*.json + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/smart-discovery-golden-provenance.yml b/.github/workflows/smart-discovery-golden-provenance.yml new file mode 100644 index 000000000..7e2535557 --- /dev/null +++ b/.github/workflows/smart-discovery-golden-provenance.yml @@ -0,0 +1,157 @@ +name: Smart Discovery Golden Provenance + +on: + pull_request: + workflow_dispatch: + +jobs: + verify-provenance: + name: Verify P0-5e capture build and target provenance + runs-on: windows-latest + steps: + - name: Checkout ARSAS branch + shell: powershell + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --depth 1 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + + - name: Execute P0-5e provenance fixtures + shell: powershell + run: | + $writer = '.\ArIED61850Tester\scripts\new-smart-discovery-golden-lock.ps1' + $verifier = '.\ArIED61850Tester\scripts\verify-smart-discovery-golden-lock.ps1' + $target = '.\ArIED61850Tester\evidence\smart-discovery-golden-target.json' + foreach ($required in @($writer, $verifier, $target)) { + if (-not (Test-Path $required -PathType Leaf)) { throw "Missing P0-5e provenance source: $required" } + } + + foreach ($script in @($writer, $verifier)) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($script, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -ne 0) { throw "PowerShell parse failure in $script" } + } + + $results = '.\ArIED61850Tester\TestResults' + New-Item -ItemType Directory -Force $results | Out-Null + $capture = Join-Path $results 'p0-5e-provenance-fixture.pcapng' + [IO.File]::WriteAllBytes($capture, [Text.Encoding]::UTF8.GetBytes('CI fixture only - never physical authority')) + + $arsasCommit = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() + $engineCommit = '4467124775d8d9d76f3db194f9fbfd97144767a8' + $manifest = Join-Path $results 'SMART-CAPTURE-BUILD.txt' + @( + 'ARSAS smart discovery field-capture build', + "ARSAS commit: $arsasCommit", + "ARIEC61850 commit: $engineCommit", + 'Engine PR: 134' + ) | Set-Content $manifest -Encoding utf8 + + $proofPath = Join-Path $results 'P0-5D-provenance-source.json' + $proof = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5d' + Verdict = 'PASS' + AcceptanceFailures = @() + ArsasCapture = [ordered]@{ + Capture = 'fixture' + ClientIp = '192.0.2.10' + ServerIp = '192.0.2.20' + RequestTcpStreams = @('0') + ConfirmedRequests = 4 + ConfirmedResponsesOrErrors = 4 + ServiceCounts = [ordered]@{ + GetNameList = 2 + GetVariableAccessAttributes = 2 + } + DuplicateSemanticRequests = 0 + DuplicateGetNameListRequests = 0 + DuplicateGvaRequests = 0 + DuplicateDetails = @() + SecondGetNameListSweepDetected = $false + PeakOutstandingRequests = 3 + NegotiatedMaxOutstandingCalling = 10 + InvokeIdReuseWhileOutstanding = 0 + OrphanResponses = 0 + UnansweredRequestsAtCaptureEnd = 0 + } + } + $proof | ConvertTo-Json -Depth 12 | Set-Content $proofPath -Encoding utf8 + + $lockPath = Join-Path $results 'P0-5E-provenance.lock.json' + & $writer ` + -ProofJson $proofPath ` + -CapturePath $capture ` + -DeviceIdentity 'AA1E1F06R4' ` + -ArsasCommit $arsasCommit ` + -EngineCommit $engineCommit ` + -TargetPath $target ` + -BuildManifestPath $manifest ` + -OutputPath $lockPath ` + -AllowFixtureEvidence + + $lock = Get-Content $lockPath -Raw | ConvertFrom-Json + if ($lock.SchemaVersion -ne 2 -or + $lock.GoldenSource.BuildManifestSha256 -notmatch '^[0-9a-f]{64}$' -or + $lock.GoldenSource.SemanticTargetSha256 -notmatch '^[0-9a-f]{64}$' -or + $lock.GoldenSource.RawCaptureReverified) { + throw 'Fixture lock did not carry expected P0-5e provenance metadata.' + } + + $acceptPath = Join-Path $results 'P0-5E-provenance-pass.json' + & $verifier ` + -LockPath $lockPath ` + -ProofJson $proofPath ` + -DeviceIdentity 'AA1E1F06R4' ` + -CandidateArsasCommit $arsasCommit ` + -CandidateEngineCommit $engineCommit ` + -TargetPath $target ` + -OutputJson $acceptPath + $accept = Get-Content $acceptPath -Raw | ConvertFrom-Json + if ($accept.Verdict -ne 'PASS') { throw 'Exact build/target fixture should pass the golden provenance lock.' } + + $wrongArsas = '1111111111111111111111111111111111111111' + $mismatchPath = Join-Path $results 'P0-5E-provenance-arsas-mismatch.json' + & $verifier ` + -LockPath $lockPath ` + -ProofJson $proofPath ` + -DeviceIdentity 'AA1E1F06R4' ` + -CandidateArsasCommit $wrongArsas ` + -CandidateEngineCommit $engineCommit ` + -TargetPath $target ` + -OutputJson $mismatchPath ` + -NoFailExit + $mismatch = Get-Content $mismatchPath -Raw | ConvertFrom-Json + if ($mismatch.Verdict -ne 'FAIL' -or + -not (@($mismatch.AcceptanceFailures) -match 'Candidate ARSAS commit differs')) { + throw 'P0-5e did not reject ARSAS build identity drift.' + } + + $alteredTarget = Join-Path $results 'altered-target.json' + $changed = Get-Content $target -Raw | ConvertFrom-Json + $changed.SemanticTarget.LogicalNodes = 120 + $changed | ConvertTo-Json -Depth 12 | Set-Content $alteredTarget -Encoding utf8 + $targetMismatchPath = Join-Path $results 'P0-5E-provenance-target-mismatch.json' + & $verifier ` + -LockPath $lockPath ` + -ProofJson $proofPath ` + -DeviceIdentity 'AA1E1F06R4' ` + -CandidateArsasCommit $arsasCommit ` + -CandidateEngineCommit $engineCommit ` + -TargetPath $alteredTarget ` + -OutputJson $targetMismatchPath ` + -NoFailExit + $targetMismatch = Get-Content $targetMismatchPath -Raw | ConvertFrom-Json + if ($targetMismatch.Verdict -ne 'FAIL' -or + -not (@($targetMismatch.AcceptanceFailures) -match 'Semantic target hash differs')) { + throw 'P0-5e did not reject semantic-target authority drift.' + } + + - name: Upload P0-5e provenance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-p0-5e-golden-provenance-fixtures + path: ArIED61850Tester\TestResults\P0-5E-*.json + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/smart-discovery-mainline-readiness.yml b/.github/workflows/smart-discovery-mainline-readiness.yml new file mode 100644 index 000000000..79bdc79bb --- /dev/null +++ b/.github/workflows/smart-discovery-mainline-readiness.yml @@ -0,0 +1,89 @@ +name: Smart Discovery Mainline Readiness + +on: + pull_request: + workflow_dispatch: + +jobs: + mainline-readiness: + name: Require physical promotion before mainline review + runs-on: windows-latest + steps: + - name: Checkout ARSAS full history + shell: powershell + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + $arsasHead = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() + "ARSAS_HEAD=$arsasHead" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "P0-5g mainline ARSAS head: $arsasHead" + + - name: Checkout ARIEC61850 PR 134 head + shell: powershell + run: | + git clone --quiet --branch perf/smart-ied-discovery https://github.com/masarray/ARIEC61850.git ARIEC61850 + $engineHead = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + "ENGINE_HEAD=$engineHead" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "P0-5g mainline engine head: $engineHead" + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Validate exact engine head + shell: powershell + run: | + .\ARIEC61850\scripts\verify-source-clean.ps1 + 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 + + - name: Require READY_FOR_REVIEW promotion state + shell: powershell + run: | + $root = '.\ArIED61850Tester' + $physical = "$root\evidence\smart-discovery-repeat-run.authority.json" + $promotion = "$root\evidence\smart-discovery-production-promotion-authority.json" + $physicalArg = if (Test-Path $physical -PathType Leaf) { $physical } else { '' } + $promotionArg = if (Test-Path $promotion -PathType Leaf) { $promotion } else { '' } + $output = "$root\TestResults\P0-5G-mainline-readiness.json" + New-Item -ItemType Directory -Force "$root\TestResults" | Out-Null + + & "$root\scripts\verify-smart-discovery-production-readiness.ps1" ` + -TargetPath "$root\evidence\smart-discovery-production-promotion-target.json" ` + -EngineLockPath "$root\engines\ARIEC61850.lock.json" ` + -PromotionPropsPath "$root\evidence\SmartDiscoveryPromotion.props" ` + -ArsasRepositoryPath $root ` + -EngineRepositoryPath '.\ARIEC61850' ` + -ArsasHeadCommit $env:ARSAS_HEAD ` + -EngineHeadCommit $env:ENGINE_HEAD ` + -EngineHeadCiConclusion success ` + -PhysicalAuthorityPath $physicalArg ` + -PromotionAuthorityPath $promotionArg ` + -OutputJson $output ` + -NoFailExit + + $result = Get-Content $output -Raw | ConvertFrom-Json + if ($result.Verdict -ne 'READY_FOR_REVIEW') { + throw "MAINLINE BLOCKED: readiness verdict is '$($result.Verdict)': $(@($result.Blockers) -join '; ')" + } + if (-not [bool]$result.ProductionSwitchEnabled) { + throw 'MAINLINE BLOCKED: production smart-discovery switch is not enabled.' + } + + - name: Build and test promoted ARSAS path + shell: powershell + run: | + dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln + dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore + dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore + + - name: Upload P0-5g mainline evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-p0-5g-mainline-readiness + path: ArIED61850Tester\TestResults\P0-5G-mainline-readiness.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/smart-discovery-merge-execution-guard.yml b/.github/workflows/smart-discovery-merge-execution-guard.yml new file mode 100644 index 000000000..831a0e318 --- /dev/null +++ b/.github/workflows/smart-discovery-merge-execution-guard.yml @@ -0,0 +1,87 @@ +name: Smart Discovery Merge Execution Guard + +on: + pull_request: + workflow_dispatch: + +jobs: + verify-merge-execution-contract: + name: Verify P0-5h ordered merge contract + runs-on: windows-latest + steps: + - name: Checkout ARSAS branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate P0-5h source contract + shell: powershell + run: | + $target = '.\evidence\smart-discovery-mainline-merge-target.json' + $writer = '.\scripts\new-smart-discovery-mainline-merge-manifest.ps1' + $preflight = '.\scripts\verify-smart-discovery-live-merge-preflight.ps1' + $postMerge = '.\scripts\verify-smart-discovery-post-merge-production.ps1' + $test = '.\tests\ARSAS.Tests\SmartDiscoveryMainlineMergeExecutionRegressionTests.cs' + $doc = '.\docs\P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md' + foreach ($path in @($target,$writer,$preflight,$postMerge,$test,$doc)) { + if (-not (Test-Path $path -PathType Leaf)) { throw "P0-5h source missing: $path" } + } + foreach ($script in @($writer,$preflight,$postMerge)) { + $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 parse failure in ${script}: $messages" + } + } + $json = Get-Content $target -Raw | ConvertFrom-Json + if ($json.Phase -ne 'P0-5h' -or $json.MergeMethod -ne 'merge') { throw 'P0-5h target does not lock merge-commit execution.' } + if ((@($json.MergeOrder) -join ',') -ne 'engine,arsas') { throw 'P0-5h target does not require engine-first order.' } + if (-not [bool]$json.ExecutionContract.RequireOnlyMergeManifestChangeAfterValidatedArsasHead) { + throw 'P0-5h target does not close the tracked-manifest self-reference boundary.' + } + + $manifest = '.\evidence\smart-discovery-mainline-merge-manifest.json' + if (Test-Path $manifest -PathType Leaf) { + $m = Get-Content $manifest -Raw | ConvertFrom-Json + if ($m.Phase -ne 'P0-5h-merge-manifest' -or $m.Status -ne 'authorized-for-ordered-merge' -or $m.MergeMethod -ne 'merge') { + throw 'Tracked P0-5h merge manifest is invalid.' + } + if ((@($m.MergeOrder) -join ',') -ne 'engine,arsas') { throw 'Tracked merge manifest changed merge order.' } + if ([string]$m.Arsas.LiveMergeHeadSha) { throw 'Tracked manifest must not self-bind its own future commit SHA.' } + if ((@($m.Arsas.AllowedPostAuthorizationPaths) -join ',') -ne 'evidence/smart-discovery-mainline-merge-manifest.json') { + throw 'Tracked manifest widened the post-authorization ARSAS mutation boundary.' + } + } + + - 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 + + $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@v4 + with: + dotnet-version: 8.0.x + + - name: Run ARSAS regression tests + 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 diff --git a/.github/workflows/smart-discovery-post-merge-production.yml b/.github/workflows/smart-discovery-post-merge-production.yml new file mode 100644 index 000000000..222603332 --- /dev/null +++ b/.github/workflows/smart-discovery-post-merge-production.yml @@ -0,0 +1,77 @@ +name: Smart Discovery Post-Merge Production Verification + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + post-merge-production: + name: Verify P0-5h production state on main + runs-on: windows-latest + steps: + - name: Checkout ARSAS main with history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Require tracked P0-5h production authorities + shell: powershell + run: | + foreach ($path in @( + '.\evidence\smart-discovery-mainline-merge-manifest.json', + '.\evidence\smart-discovery-production-promotion-authority.json', + '.\evidence\SmartDiscoveryPromotion.props', + '.\scripts\verify-smart-discovery-post-merge-production.ps1')) { + if (-not (Test-Path $path -PathType Leaf)) { throw "P0-5h post-merge authority missing on main: $path" } + } + + - name: Checkout ARIEC61850 main with history + shell: powershell + run: | + git clone --quiet https://github.com/masarray/ARIEC61850.git ARIEC61850 + $engineMain = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + "ENGINE_MAIN=$engineMain" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "P0-5h engine main: $engineMain" + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Validate and test engine main + shell: powershell + run: | + .\ARIEC61850\scripts\verify-source-clean.ps1 + 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 + + - name: Validate post-merge ancestry and promotion binding + shell: powershell + run: | + New-Item -ItemType Directory -Force .\TestResults | Out-Null + .\scripts\verify-smart-discovery-post-merge-production.ps1 ` + -MergeManifestPath .\evidence\smart-discovery-mainline-merge-manifest.json ` + -PromotionAuthorityPath .\evidence\smart-discovery-production-promotion-authority.json ` + -PromotionPropsPath .\evidence\SmartDiscoveryPromotion.props ` + -ArsasRepositoryPath . ` + -EngineRepositoryPath .\ARIEC61850 ` + -OutputJson .\TestResults\P0-5H-post-merge-production.json + + - name: Build and test promoted ARSAS main + 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 + + - name: Upload P0-5h post-merge attestation + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-p0-5h-post-merge-production + path: .\TestResults\P0-5H-post-merge-production.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/smart-discovery-production-promotion.yml b/.github/workflows/smart-discovery-production-promotion.yml new file mode 100644 index 000000000..f649aef4d --- /dev/null +++ b/.github/workflows/smart-discovery-production-promotion.yml @@ -0,0 +1,166 @@ +name: Smart Discovery Production Promotion Guard + +on: + pull_request: + workflow_dispatch: + +jobs: + verify-production-promotion: + name: Verify P0-5g production promotion and merge-readiness contract + runs-on: windows-latest + steps: + - name: Checkout ARSAS branch + shell: powershell + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + $arsasHead = (git -C .\ArIED61850Tester rev-parse HEAD).Trim().ToLowerInvariant() + "ARSAS_HEAD=$arsasHead" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "P0-5g ARSAS head: $arsasHead" + + - name: Checkout ARIEC61850 PR 134 head + shell: powershell + run: | + git clone --quiet --branch perf/smart-ied-discovery https://github.com/masarray/ARIEC61850.git ARIEC61850 + $engineHead = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + "ENGINE_HEAD=$engineHead" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "P0-5g engine PR head: $engineHead" + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Validate P0-5g source contract + shell: powershell + run: | + $root = '.\ArIED61850Tester' + $required = @( + "$root\evidence\SmartDiscoveryPromotion.props", + "$root\evidence\smart-discovery-production-promotion-target.json", + "$root\scripts\verify-smart-discovery-production-readiness.ps1", + "$root\scripts\new-smart-discovery-production-promotion-authority.ps1", + "$root\docs\P0-5G_PRODUCTION_PROMOTION_MAINLINE_READINESS.md", + "$root\tests\ARSAS.Tests\SmartDiscoveryProductionPromotionRegressionTests.cs" + ) + foreach ($path in $required) { + if (-not (Test-Path $path -PathType Leaf)) { throw "P0-5g source missing: $path" } + } + foreach ($script in @( + "$root\scripts\verify-smart-discovery-production-readiness.ps1", + "$root\scripts\new-smart-discovery-production-promotion-authority.ps1")) { + $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 parse failure in ${script}: $messages" + } + } + [xml]$props = Get-Content "$root\evidence\SmartDiscoveryPromotion.props" -Raw + $promoted = ([string]$props.Project.PropertyGroup.SmartDiscoveryProductionPromoted).Trim().ToLowerInvariant() + if ($promoted -notin @('true','false')) { throw 'P0-5g production switch is not a boolean.' } + + - name: Build and test exact engine PR head + shell: powershell + run: | + .\ARIEC61850\scripts\verify-source-clean.ps1 + 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 + + - name: Verify P0-5g readiness state + shell: powershell + run: | + $root = '.\ArIED61850Tester' + $verifier = "$root\scripts\verify-smart-discovery-production-readiness.ps1" + $physical = "$root\evidence\smart-discovery-repeat-run.authority.json" + $promotion = "$root\evidence\smart-discovery-production-promotion-authority.json" + $physicalArg = if (Test-Path $physical -PathType Leaf) { $physical } else { '' } + $promotionArg = if (Test-Path $promotion -PathType Leaf) { $promotion } else { '' } + $output = "$root\TestResults\P0-5G-production-readiness.json" + New-Item -ItemType Directory -Force "$root\TestResults" | Out-Null + + & $verifier ` + -TargetPath "$root\evidence\smart-discovery-production-promotion-target.json" ` + -EngineLockPath "$root\engines\ARIEC61850.lock.json" ` + -PromotionPropsPath "$root\evidence\SmartDiscoveryPromotion.props" ` + -ArsasRepositoryPath $root ` + -EngineRepositoryPath '.\ARIEC61850' ` + -ArsasHeadCommit $env:ARSAS_HEAD ` + -EngineHeadCommit $env:ENGINE_HEAD ` + -EngineHeadCiConclusion success ` + -PhysicalAuthorityPath $physicalArg ` + -PromotionAuthorityPath $promotionArg ` + -OutputJson $output ` + -NoFailExit + + $result = Get-Content $output -Raw | ConvertFrom-Json + if (-not (Test-Path $physical -PathType Leaf)) { + if ($result.Verdict -ne 'BLOCKED' -or + -not (@($result.Blockers) -match 'P0-5f physical-finalized authority is missing')) { + throw 'P0-5g must remain fail-closed until physical P0-5f authority is present.' + } + if ([bool]$result.ProductionSwitchEnabled) { + throw 'P0-5g production switch became enabled without physical authority.' + } + } elseif (-not (Test-Path $promotion -PathType Leaf)) { + if ($result.Verdict -ne 'READY_TO_PROMOTE') { + throw "Physical authority exists but P0-5g is not READY_TO_PROMOTE: $(@($result.Blockers) -join '; ')" + } + } elseif ($result.Verdict -ne 'READY_FOR_REVIEW') { + throw "Promotion authority exists but P0-5g is not READY_FOR_REVIEW: $(@($result.Blockers) -join '; ')" + } + + - name: Build and test default fail-closed ARSAS path + shell: powershell + run: | + # The current engine PR head is validated above, but ordinary pre-promotion + # ARSAS remains pinned to the physical-evidence engine baseline. Build the + # fail-closed route against that exact lock rather than an arbitrary newer head. + $lock = Get-Content '.\ArIED61850Tester\engines\ARIEC61850.lock.json' -Raw | ConvertFrom-Json + git -C .\ARIEC61850 checkout --quiet --detach $lock.commit + $actualEngine = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + if ($actualEngine -ne ([string]$lock.commit).ToLowerInvariant()) { + throw "P0-5g fail-closed engine checkout mismatch: $actualEngine" + } + + $native = '.\ArIED61850Tester\Services\NativeIec61850Client.cs' + $before = Get-Content $native -Raw + if ($before -match 'return await DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { + throw 'Tracked NativeIec61850Client.cs already contains the field-route patch before default build.' + } + dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln + dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore + dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore + $after = Get-Content $native -Raw + [xml]$props = Get-Content '.\ArIED61850Tester\evidence\SmartDiscoveryPromotion.props' -Raw + $promoted = ([string]$props.Project.PropertyGroup.SmartDiscoveryProductionPromoted).Trim().ToLowerInvariant() -eq 'true' + if (-not $promoted -and $after -match 'return await DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { + throw 'Default pre-promotion build unexpectedly installed the smart discovery route.' + } + + - name: Build and test explicit smart-route candidate + shell: powershell + run: | + dotnet build .\ArIED61850Tester\ArIED61850Tester.sln -c Release --no-restore -p:EnableSmartDiscoveryCaptureRoute=true + $native = Get-Content '.\ArIED61850Tester\Services\NativeIec61850Client.cs' -Raw + if ($native -notmatch 'return await DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { + throw 'Explicit P0-5g smart-route build did not install the discovery route.' + } + if ($native -notmatch '__P0_5C_CONNECT_RESET__' -or $native -notmatch '__P0_5C_DISPOSE_RESET__') { + throw 'Explicit P0-5g smart-route build did not install association lifecycle resets.' + } + if ($native -notmatch '_lastDiscovery\.Snapshot\.DomainVariables') { + throw 'Explicit P0-5g smart-route build did not install authoritative Control inventory reuse.' + } + dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore + + - name: Upload P0-5g readiness evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-p0-5g-production-readiness + path: ArIED61850Tester\TestResults\P0-5G-*.json + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/smart-discovery-repeat-run-stability.yml b/.github/workflows/smart-discovery-repeat-run-stability.yml new file mode 100644 index 000000000..123c12a80 --- /dev/null +++ b/.github/workflows/smart-discovery-repeat-run-stability.yml @@ -0,0 +1,227 @@ +name: Smart Discovery Repeat-Run Stability + +on: + pull_request: + workflow_dispatch: + +jobs: + verify-repeat-run-stability: + name: Verify P0-5f repeat-run stability contract + runs-on: windows-latest + steps: + - name: Checkout ARSAS branch + shell: powershell + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --depth 1 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + + - name: Validate P0-5f sources + shell: powershell + run: | + $finalizer = '.\ArIED61850Tester\scripts\finalize-smart-discovery-repeat-run-stability.ps1' + $bundleWriter = '.\ArIED61850Tester\scripts\new-smart-discovery-repeat-run-bundle.ps1' + $authorityWriter = '.\ArIED61850Tester\scripts\new-smart-discovery-repeat-run-authority.ps1' + $target = '.\ArIED61850Tester\evidence\smart-discovery-repeat-run-target.json' + $runtime = '.\ArIED61850Tester\Services\NativeIec61850Client.SmartDiscoveryRepeatRunEvidence.cs' + $test = '.\ArIED61850Tester\tests\ARSAS.Tests\SmartDiscoveryRepeatRunStabilityRegressionTests.cs' + $doc = '.\ArIED61850Tester\docs\P0-5F_REPEAT_RUN_STABILITY_PROOF.md' + foreach ($required in @($finalizer, $bundleWriter, $authorityWriter, $target, $runtime, $test, $doc)) { + if (-not (Test-Path $required -PathType Leaf)) { throw "P0-5f source missing: $required" } + } + foreach ($script in @($finalizer, $bundleWriter, $authorityWriter)) { + $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 parse failure in ${script}: $messages" + } + } + $targetJson = Get-Content $target -Raw | ConvertFrom-Json + if ($targetJson.Phase -ne 'P0-5f' -or $targetJson.MinimumIndependentAssociations -lt 3) { + throw 'P0-5f target does not require at least three independent associations.' + } + if ($null -ne $targetJson.FinalizationAuthority) { + throw 'CI source must not pre-populate physical FinalizationAuthority.' + } + + - name: Execute P0-5f finalization fixtures + shell: powershell + run: | + $root = '.\ArIED61850Tester' + $results = Join-Path $root 'TestResults' + New-Item -ItemType Directory -Force $results | Out-Null + $finalizer = Join-Path $root 'scripts\finalize-smart-discovery-repeat-run-stability.ps1' + $authorityWriter = Join-Path $root 'scripts\new-smart-discovery-repeat-run-authority.ps1' + $repeatTarget = Join-Path $root 'evidence\smart-discovery-repeat-run-target.json' + $semanticTarget = Join-Path $root 'evidence\smart-discovery-golden-target.json' + $arsasCommit = (git -C $root rev-parse HEAD).Trim().ToLowerInvariant() + $engineCommit = '4467124775d8d9d76f3db194f9fbfd97144767a8' + + $manifest = Join-Path $results 'SMART-CAPTURE-BUILD.txt' + @( + 'ARSAS smart discovery field-capture build', + "ARSAS commit: $arsasCommit", + "ARIEC61850 commit: $engineCommit", + 'Engine PR: 134' + ) | Set-Content $manifest -Encoding utf8 + $manifestHash = (Get-FileHash $manifest -Algorithm SHA256).Hash.ToLowerInvariant() + $semanticHash = (Get-FileHash $semanticTarget -Algorithm SHA256).Hash.ToLowerInvariant() + + $lockPath = Join-Path $results 'P0-5E-fixture.lock.json' + $lock = [ordered]@{ + SchemaVersion = 2 + Phase = 'P0-5e' + Status = 'locked' + DeviceIdentity = 'AA1E1F06R4' + GoldenSource = [ordered]@{ + ArsasCommit = $arsasCommit + EngineCommit = $engineCommit + BuildManifestSha256 = $manifestHash + SemanticTargetSha256 = $semanticHash + RawCaptureReverified = $false + } + HardRequestBudget = [ordered]@{ + MaxConfirmedRequests = 4 + MaxPeakOutstandingRequests = 4 + } + } + $lock | ConvertTo-Json -Depth 12 | Set-Content $lockPath -Encoding utf8 + $goldenHash = (Get-FileHash $lockPath -Algorithm SHA256).Hash.ToLowerInvariant() + + function New-Bundle([int]$index, [long]$generation, [int]$requests = 4, [string]$kpi = 'kpi-a', [string]$directory = 'dir-a', [string]$projection = 'proj-a') { + $captureHash = ('{0:x64}' -f (1000 + $index)) + $proofHash = ('{0:x64}' -f (2000 + $index)) + $runtimeHash = ('{0:x64}' -f (3000 + $index)) + [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5f-run-bundle' + Verdict = 'PASS' + DeviceIdentity = 'AA1E1F06R4' + ArsasCommit = $arsasCommit + EngineCommit = $engineCommit + GoldenLockSha256 = $goldenHash + BuildManifestSha256 = $manifestHash + SemanticTargetSha256 = $semanticHash + FixtureEvidence = $true + Provenance = [ordered]@{ + CaptureSha256 = $captureHash + ProofSha256 = $proofHash + RuntimeEvidenceSha256 = $runtimeHash + } + Wire = [ordered]@{ + ConfirmedRequests = $requests + ServiceCounts = [ordered]@{ GetNameList = 2; GetVariableAccessAttributes = 2 } + PeakOutstandingRequests = 3 + DuplicateSemanticRequests = 0 + DuplicateGetNameListRequests = 0 + DuplicateGvaRequests = 0 + SecondGetNameListSweepDetected = $false + } + Runtime = [ordered]@{ + AssociationGeneration = $generation + DirectoryModelSignature = $directory + ProjectionSignature = $projection + SmartDiscoveryKpi = [ordered]@{ + TotalRequests = $requests + DuplicateRequests = 0 + WireAccountingComplete = $true + DeterministicSignature = $kpi + } + TypeProbeBudget = [ordered]@{ + DirectoryPoints = 4925 + SuppliedLogicalNodeCandidates = 119 + SuppressedNonLiveLogicalNodeCandidates = 0 + LogicalNodeRequests = 119 + PointsCoveredByLogicalNode = 4925 + DataObjectRequests = 0 + PointsCoveredByDataObject = 0 + ExactLeafRequests = 0 + SuppressedExactRepeatRequests = 0 + PointsCoveredByExactLeaf = 0 + RemainingUnresolvedPoints = 0 + TotalPlannedRequests = 119 + } + Model = [ordered]@{ + LogicalDevices = 32 + LogicalNodes = 119 + SemanticPoints = 4925 + ProjectedSignals = 4925 + DataSets = 2 + ReportControls = 34 + BufferedReportControls = 2 + UnbufferedReportControls = 32 + } + } + } + } + + $bundlePaths = @() + foreach ($i in 1..3) { + $path = Join-Path $results "P0-5F-run-$i.json" + (New-Bundle $i (100 + $i)) | ConvertTo-Json -Depth 18 | Set-Content $path -Encoding utf8 + $bundlePaths += $path + } + + $passPath = Join-Path $results 'P0-5F-finalization-pass.json' + & $finalizer -GoldenLockPath $lockPath -RepeatTargetPath $repeatTarget -RunBundlePaths $bundlePaths -OutputPath $passPath -AllowFixtureEvidence + $pass = Get-Content $passPath -Raw | ConvertFrom-Json + if ($pass.Verdict -ne 'PASS' -or $pass.SchemaVersion -ne 2 -or $pass.ObservedRunBundles -ne 3) { + throw 'P0-5f stable three-run fixture did not PASS.' + } + if (@($pass.Consensus.AssociationGenerations | Sort-Object -Unique).Count -ne 3) { + throw 'P0-5f PASS did not preserve three unique association generations.' + } + + $fixturePromotionRejected = $false + try { + & $authorityWriter ` + -GoldenLockPath $lockPath ` + -RepeatTargetPath $repeatTarget ` + -FinalizationJson $passPath ` + -RunBundlePaths $bundlePaths ` + -OutputPath (Join-Path $results 'P0-5F-FORBIDDEN-fixture-authority.json') + } + catch { + $fixturePromotionRejected = $_.Exception.Message -match 'fixture/non-reverified P0-5e golden lock' + } + if (-not $fixturePromotionRejected) { + throw 'P0-5f fixture evidence was incorrectly promotable to physical authority.' + } + + $reused = New-Bundle 3 102 + $reusedPath = Join-Path $results 'P0-5F-run-reused-generation.json' + $reused | ConvertTo-Json -Depth 18 | Set-Content $reusedPath -Encoding utf8 + $reuseFailPath = Join-Path $results 'P0-5F-finalization-reused-generation-fail.json' + & $finalizer -GoldenLockPath $lockPath -RepeatTargetPath $repeatTarget -RunBundlePaths @($bundlePaths[0], $bundlePaths[1], $reusedPath) -OutputPath $reuseFailPath -AllowFixtureEvidence -NoFailExit + $reuseFail = Get-Content $reuseFailPath -Raw | ConvertFrom-Json + if ($reuseFail.Verdict -ne 'FAIL' -or -not (@($reuseFail.AcceptanceFailures) -match 'reuses an association generation')) { + throw 'P0-5f failed to reject reused association generation.' + } + + $drift = New-Bundle 4 104 5 'kpi-b' 'dir-b' 'proj-b' + $driftPath = Join-Path $results 'P0-5F-run-drift.json' + $drift | ConvertTo-Json -Depth 18 | Set-Content $driftPath -Encoding utf8 + $driftFailPath = Join-Path $results 'P0-5F-finalization-drift-fail.json' + & $finalizer -GoldenLockPath $lockPath -RepeatTargetPath $repeatTarget -RunBundlePaths @($bundlePaths[0], $bundlePaths[1], $driftPath) -OutputPath $driftFailPath -AllowFixtureEvidence -NoFailExit + $driftFail = Get-Content $driftFailPath -Raw | ConvertFrom-Json + if ($driftFail.Verdict -ne 'FAIL' -or + -not (@($driftFail.AcceptanceFailures) -match 'Confirmed-request drift') -or + -not (@($driftFail.AcceptanceFailures) -match 'deterministic-signature drift')) { + throw 'P0-5f failed to reject repeat-run request/signature drift.' + } + + - name: Upload P0-5f regression evidence and field toolkit + if: always() + uses: actions/upload-artifact@v4 + with: + name: ARSAS-p0-5f-repeat-run-toolkit + path: | + ArIED61850Tester\TestResults\P0-5F-*.json + ArIED61850Tester\scripts\new-smart-discovery-repeat-run-bundle.ps1 + ArIED61850Tester\scripts\finalize-smart-discovery-repeat-run-stability.ps1 + ArIED61850Tester\scripts\new-smart-discovery-repeat-run-authority.ps1 + ArIED61850Tester\docs\P0-5F_REPEAT_RUN_STABILITY_PROOF.md + ArIED61850Tester\evidence\smart-discovery-repeat-run-target.json + if-no-files-found: warn + retention-days: 14 diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 000000000..2148450ce --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,22 @@ + + + + + + true + + + true + false + + + + + + + diff --git a/MainWindow.RcbExport.cs b/MainWindow.RcbExport.cs index 7d7c89f64..fbe25ad7d 100644 --- a/MainWindow.RcbExport.cs +++ b/MainWindow.RcbExport.cs @@ -396,36 +396,106 @@ private async Task ExportLegacySasRcbAsync( var liveModel = device.LiveDiscoveryModel ?? throw new InvalidOperationException("A source SCL file or complete live discovery model is required for legacy SAS export."); - var selectedDataSet = string.IsNullOrWhiteSpace(row.DataSetReference) - ? null - : liveModel.DataSets.FirstOrDefault(dataSet => - NormalizeRcbReference(dataSet.Reference) - .Equals(NormalizeRcbReference(row.DataSetReference), StringComparison.OrdinalIgnoreCase)); + var effectiveAvailability = availability; var exportModel = liveModel; - // An RCB with no DataSet is still a real RCB and must remain exportable. - // Only request FCDA evidence when the RCB actually declares a DataSet. - if (!string.IsNullOrWhiteSpace(row.DataSetReference) && + // Reuse any availability evidence that the operator already acquired. Merge the live + // DatSet binding before resolving the DataSet so a runtime binding can override stale + // discovery evidence without forcing an unnecessary second MMS association. + if (effectiveAvailability != null) + { + exportModel = LiveRcbDataSetEvidenceMerger.MergeSelectedReportControlEvidence( + exportModel, + row.Reference, + effectiveAvailability); + } + + var effectiveDataSetReference = ResolveExportDataSetReference(exportModel, row); + var selectedDataSet = FindExportDataSet(exportModel, effectiveDataSetReference); + + // An RCB with no DataSet is still a real RCB and remains exportable. When an RCB does + // declare a DataSet, however, Save/Export owns the evidence acquisition: first reuse + // an existing availability directory, then perform one bounded read-only audit only + // when FCDA evidence is still missing. This keeps R4 discovery fast and makes Save SCL + // self-contained instead of requiring a manual Check Availability + retry cycle. + if (!string.IsNullOrWhiteSpace(effectiveDataSetReference) && (selectedDataSet is null || selectedDataSet.Members.Count == 0)) { - if (availability is null) + if (effectiveAvailability != null) { - throw new InvalidOperationException( - "The selected RCB declares a DataSet, but live discovery has no FCDA directory evidence yet. Click Check Availability, wait for the read-only audit to finish, then export again."); + exportModel = LiveRcbDataSetEvidenceMerger.MergeSelectedDataSetDirectory( + exportModel, + row.Reference, + effectiveAvailability); + effectiveDataSetReference = ResolveExportDataSetReference(exportModel, row); + selectedDataSet = FindExportDataSet(exportModel, effectiveDataSetReference); } - exportModel = LiveRcbDataSetEvidenceMerger.MergeSelectedDataSetDirectory( - liveModel, - row.Reference, - availability); + if (!string.IsNullOrWhiteSpace(effectiveDataSetReference) && + (selectedDataSet is null || selectedDataSet.Members.Count == 0)) + { + if (!device.IsConnected) + { + throw new InvalidOperationException( + $"The selected RCB declares DataSet '{effectiveDataSetReference}', but no FCDA directory evidence is available and the IED is disconnected."); + } + + AddLog("INFO", "RCB Export", + $"{device.Name}: FCDA evidence missing for {row.Reference}; acquiring one read-only MMS availability snapshot before export."); + + using var evidenceTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + evidenceTimeout.CancelAfter(TimeSpan.FromSeconds(30)); + try + { + effectiveAvailability = await _rcbAvailabilityProbe + .CheckAsync(device, evidenceTimeout.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"Timed out while acquiring FCDA directory evidence for RCB '{row.Reference}'. The CID was not written."); + } + + exportModel = LiveRcbDataSetEvidenceMerger.MergeSelectedReportControlEvidence( + exportModel, + row.Reference, + effectiveAvailability); + effectiveDataSetReference = ResolveExportDataSetReference(exportModel, row); + + if (!string.IsNullOrWhiteSpace(effectiveDataSetReference)) + { + exportModel = LiveRcbDataSetEvidenceMerger.MergeSelectedDataSetDirectory( + exportModel, + row.Reference, + effectiveAvailability); + effectiveDataSetReference = ResolveExportDataSetReference(exportModel, row); + selectedDataSet = FindExportDataSet(exportModel, effectiveDataSetReference); + } + else + { + selectedDataSet = null; + } + } + + if (!string.IsNullOrWhiteSpace(effectiveDataSetReference) && + (selectedDataSet is null || selectedDataSet.Members.Count == 0)) + { + throw new InvalidOperationException( + $"RCB '{row.Reference}' resolves to DataSet '{effectiveDataSetReference}', but the authoritative read-only MMS audit did not return any FCDA members. The CID was not written."); + } } - if (availability != null) + // If the probe proved a dynamic RCB is currently unbound, keep that authoritative + // result and do not resurrect the stale discovery binding during serialization. + if (effectiveAvailability != null) { exportModel = LiveRcbDataSetEvidenceMerger.MergeSelectedReportControlEvidence( exportModel, row.Reference, - availability); + effectiveAvailability); + effectiveDataSetReference = ResolveExportDataSetReference(exportModel, row); + selectedDataSet = FindExportDataSet(exportModel, effectiveDataSetReference); } var filteredModel = SclReportControlFilter.FilterLiveModel(exportModel, row.Reference); @@ -444,7 +514,7 @@ private async Task ExportLegacySasRcbAsync( var removedCount = Math.Max(0, liveModel.ReportControls.Count - 1); AddLog("INFO", "RCB Export", - $"{device.Name}: live-model legacy SAS CID saved; schema={liveResult.SclSchema}; RCB={row.Reference}; DataSet={row.DataSetName}; members={row.MemberCount}; removed RCB={removedCount}; output={liveResult.SclPath}"); + $"{device.Name}: live-model legacy SAS CID saved; schema={liveResult.SclSchema}; RCB={row.Reference}; DataSet={effectiveDataSetReference}; members={selectedDataSet?.Members.Count ?? 0}; removed RCB={removedCount}; output={liveResult.SclPath}"); SetStatus($"{device.Name}: legacy SAS CID exported with one RCB — {row.Name}."); return new RcbExportCompletion { @@ -453,13 +523,37 @@ private async Task ExportLegacySasRcbAsync( SummaryPath = liveResult.SummaryPath, SchemaDisplayName = liveResult.SclSchema, RetainedReportControl = row.Reference, - DataSetName = row.DataSetName, - DataSetMemberCount = row.MemberCount, + DataSetName = string.IsNullOrWhiteSpace(effectiveDataSetReference) ? row.DataSetName : LastReferenceSegment(effectiveDataSetReference), + DataSetMemberCount = selectedDataSet?.Members.Count ?? 0, RemovedReportControlCount = removedCount, Message = $"Export complete: {row.Reference} is the only RCB in the generated CID." }; } + private static string ResolveExportDataSetReference(LiveIedModelDiscoveryDocument model, RcbExportRow row) + { + var selectedReportControl = model.ReportControls.FirstOrDefault(reportControl => + NormalizeRcbReference(reportControl.Reference) + .Equals(NormalizeRcbReference(row.Reference), StringComparison.OrdinalIgnoreCase)); + + return selectedReportControl is not null + ? (selectedReportControl.DataSetReference ?? string.Empty).Trim() + : (row.DataSetReference ?? string.Empty).Trim(); + } + + private static LiveIedDataSetModel? FindExportDataSet( + LiveIedModelDiscoveryDocument model, + string? dataSetReference) + { + if (string.IsNullOrWhiteSpace(dataSetReference)) + return null; + + var normalizedReference = NormalizeRcbReference(dataSetReference); + return model.DataSets.FirstOrDefault(dataSet => + NormalizeRcbReference(dataSet.Reference) + .Equals(normalizedReference, StringComparison.OrdinalIgnoreCase)); + } + private static string EffectiveSclIedName(Iec61850MonitorDevice device) => string.IsNullOrWhiteSpace(device.SclIedName) ? device.Name : device.SclIedName; diff --git a/Services/NativeIec61850Client.SmartDiscoveryCapture.cs b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs new file mode 100644 index 000000000..fc5df66ae --- /dev/null +++ b/Services/NativeIec61850Client.SmartDiscoveryCapture.cs @@ -0,0 +1,318 @@ +using System.Diagnostics; +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + // This path is intentionally isolated to the PR #134 comparison build. + // It keeps live MMS evidence authoritative while removing ARSAS's historical + // supplemental naming/probe passes. Evidence-directed DataSet and report + // enrichment stays inside the same association-scoped single flight. + private static bool SmartDiscoveryCaptureModeEnabled => true; + + private async Task> DiscoverSignalsSmartForCaptureAsync( + CancellationToken cancellationToken, + IProgress? progress) + { + cancellationToken.ThrowIfCancellationRequested(); + + // P0-5c: one owner performs the complete enrichment chain for one association + // generation. Every concurrent caller receives the same in-flight task. Caller + // cancellation only stops that caller waiting; it never cancels the shared MMS + // owner and therefore cannot cause a second GVA ladder on the same association. + var flight = GetOrCreateSmartDiscoveryAssociationFlight( + generation => RunSmartDiscoveryAssociationFlightAsync(generation, progress)); + + return await flight.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task> RunSmartDiscoveryAssociationFlightAsync( + long associationGeneration, + IProgress? progress) + { + // The complete directory -> semantic enrichment -> GVA -> canonical model -> + // projection -> publish sequence owns the application MMS gate. Waiter + // cancellation is deliberately absent here: only association generation + // invalidation can make this owner stale. + await _mmsIoGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + + return await DiscoverSignalsSmartForCaptureCoreAsync( + associationGeneration, + progress) + .ConfigureAwait(false); + } + finally + { + _mmsIoGate.Release(); + } + } + + private async Task> DiscoverSignalsSmartForCaptureCoreAsync( + long associationGeneration, + IProgress? progress) + { + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + + LastDiscoverySummary = string.Empty; + if (!_session.IsMmsInitiated) + { + if (IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + { + LastErrorMessage = $"ARIEC61850 smart discovery requires ACSE/MMS association. Current state: {_session.State}. {_session.LastAssociationAttemptSummary}"; + } + return Array.Empty(); + } + + var totalWatch = Stopwatch.StartNew(); + try + { + // A completed discovery on this exact association generation is wire-free. + // Concurrent callers do not reach this branch independently because they + // already share the same association flight above. P0-5f deliberately does + // not emit a new repeat-run evidence file from this cached branch: a repeat + // physical run requires a new association generation and fresh wire traffic. + if (TryGetSmartDiscoveryAuthority(out var cachedDiscovery, out var cachedModel)) + { + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.MappingSignals, + "Reusing the authoritative smart discovery for this MMS association…", + 82d, 7, 10)); + + var cachedProjectionWatch = Stopwatch.StartNew(); + var cachedSnapshot = ToNativeSnapshot(cachedDiscovery.Snapshot); + var cachedInventory = ToNativeInventory(cachedDiscovery.ReportInventory); + var cachedSignals = BuildSmartCaptureSignalProjection( + cachedModel, + cachedSnapshot, + cachedInventory, + out var cachedProjectionStats); + cachedProjectionWatch.Stop(); + + var cachedReportWatch = Stopwatch.StartNew(); + NativeReportDiscoveryMapper.ApplyReportHints(cachedSignals, cachedInventory); + cachedReportWatch.Stop(); + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.ResolvingIdentity, + "Resolving IED identity from the cached canonical live model…", + 94d, 8, 10)); + + var cachedIdentityWatch = Stopwatch.StartNew(); + var cachedIdentity = Iec61850DeviceIdentityResolver.Resolve( + cachedDiscovery, + cachedModel, + cachedSignals); + cachedIdentityWatch.Stop(); + totalWatch.Stop(); + + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + + var cachedLogicalNodes = cachedSignals + .Select(signal => signal.LogicalNode) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + var cachedRawVariables = cachedSnapshot.DomainVariables.Values.Sum(values => values.Count); + var cachedBudget = _session.LastSmartTypeProbeBudget?.Summary ?? "Smart type budget unavailable."; + var cachedSummary = + $"SMART-CAPTURE PR134 P0-5c; association authority=reused; association flight=new-wire-free; control inventory=authoritative; wire discovery=skipped; " + + $"IEDName={(string.IsNullOrWhiteSpace(cachedIdentity.IedName) ? "unresolved" : cachedIdentity.IedName)} ({cachedIdentity.Source}); " + + $"{cachedDiscovery.Summary} {cachedModel.Summary} LN={cachedLogicalNodes}, SCADA candidates={cachedSignals.Count}, " + + $"MMS names={cachedRawVariables}, smart type probes={_smartDiscoveryTypeProbeCount}, successful type probes={_smartDiscoverySuccessfulTypeProbeCount}, " + + $"indexed LN hints={cachedProjectionStats.LogicalNodeHints}, indexed fallback signals={cachedProjectionStats.AddedFallbackSignals}. " + + $"{cachedBudget} " + + $"TimingMs directory=0.0, types=0.0, model=0.0, projection={cachedProjectionWatch.Elapsed.TotalMilliseconds:F1}, " + + $"reportHints={cachedReportWatch.Elapsed.TotalMilliseconds:F1}, identity={cachedIdentityWatch.Elapsed.TotalMilliseconds:F1}, total={totalWatch.Elapsed.TotalMilliseconds:F1}. " + + "Deferred: supplemental GetNameList, reflection fallback, adaptive sibling/equipment/reference/unit probes."; + + if (!TryPublishSmartDiscoveryPresentation( + associationGeneration, + cachedInventory, + cachedIdentity, + cachedSummary)) + { + return Array.Empty(); + } + + return cachedSignals; + } + + var smartOptions = new ArMms.MmsSmartDiscoveryOptions + { + MaxConcurrentChains = 8, + UnknownPeerMaxConcurrentChains = 4, + MaxDomains = 256, + MaxVariableNamesPerDomain = 20000, + MaxVariableListNamesPerDomain = 4096, + MaxNameListPages = 64, + ProbeReportAttributes = true, + MaxReportAttributeProbes = 64, + ReadDataSetDirectories = true, + MaxDataSetDirectoryReads = 64 + }; + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.DiscoveringDirectory, + "Smart MMS discovery: bounded directory scan with evidence-directed DataSet/report enrichment…", + 28d, 4, 10)); + + var directoryWatch = Stopwatch.StartNew(); + var discovery = await _session + .DiscoverSmartSingleFlightAsync(smartOptions, CancellationToken.None) + .ConfigureAwait(false); + directoryWatch.Stop(); + + // Reconnect/dispose may invalidate the generation while the current PDU is + // in flight. Stop at the boundary before issuing any GVA request. + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.ProbingLogicalNodes, + "Smart MMS type discovery: coverage-aware Logical Node hierarchy probes…", + 52d, 5, 10)); + + var typeWatch = Stopwatch.StartNew(); + var variableTypes = await LiveIedVariableTypeProbeExecutor + .ProbeSmartAsync(_session, discovery.IedDirectory, smartOptions, CancellationToken.None) + .ConfigureAwait(false); + typeWatch.Stop(); + + // A stale owner may finish an already-issued GVA batch, but it cannot build + // or publish state into the replacement association generation. + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.BuildingLiveModel, + "Building canonical IEC 61850 model from smart discovery evidence…", + 68d, 6, 10)); + + var modelWatch = Stopwatch.StartNew(); + var liveModel = LiveIedModelDiscoveryBuilder.Build( + discovery, + new LiveIedModelDiscoveryBuildOptions + { + Host = _host, + Port = _port, + IncludeLowConfidenceTemplates = true + }, + variableTypeAttributes: variableTypes); + modelWatch.Stop(); + + var snapshot = ToNativeSnapshot(discovery.Snapshot); + var reportInventory = ToNativeInventory(discovery.ReportInventory); + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.MappingSignals, + "Mapping canonical smart evidence with indexed fallbacks…", + 82d, 7, 10)); + + var projectionWatch = Stopwatch.StartNew(); + var signals = BuildSmartCaptureSignalProjection( + liveModel, + snapshot, + reportInventory, + out var projectionStats); + projectionWatch.Stop(); + + // Structural hints, bounded report reads, and observed DataSet directories + // all belong to the authoritative single-flight evidence for this association. + var reportWatch = Stopwatch.StartNew(); + NativeReportDiscoveryMapper.ApplyReportHints(signals, reportInventory); + reportWatch.Stop(); + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.ResolvingIdentity, + "Resolving IED identity from the canonical live model…", + 94d, 8, 10)); + + var identityWatch = Stopwatch.StartNew(); + var identity = Iec61850DeviceIdentityResolver.Resolve(discovery, liveModel, signals); + identityWatch.Stop(); + + if (!IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + return Array.Empty(); + + var logicalNodes = signals + .Select(signal => signal.LogicalNode) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + var rawVariables = snapshot.DomainVariables.Values.Sum(values => values.Count); + var successfulTypeRoots = variableTypes.Count(result => result.IsSuccess); + var typeBudget = _session.LastSmartTypeProbeBudget?.Summary ?? "Smart type budget unavailable."; + + totalWatch.Stop(); + var summary = + $"SMART-CAPTURE PR134 P0-5c; association authority=new; association flight=single-owner; app MMS gate=exclusive; control inventory=authoritative; " + + $"IEDName={(string.IsNullOrWhiteSpace(identity.IedName) ? "unresolved" : identity.IedName)} ({identity.Source}); " + + $"{discovery.Summary} {liveModel.Summary} LN={logicalNodes}, SCADA candidates={signals.Count}, " + + $"MMS names={rawVariables}, smart type probes={variableTypes.Count}, successful type probes={successfulTypeRoots}, " + + $"indexed LN hints={projectionStats.LogicalNodeHints}, indexed fallback signals={projectionStats.AddedFallbackSignals}. " + + $"{typeBudget} " + + $"TimingMs directory={directoryWatch.Elapsed.TotalMilliseconds:F1}, types={typeWatch.Elapsed.TotalMilliseconds:F1}, " + + $"model={modelWatch.Elapsed.TotalMilliseconds:F1}, projection={projectionWatch.Elapsed.TotalMilliseconds:F1}, " + + $"reportHints={reportWatch.Elapsed.TotalMilliseconds:F1}, identity={identityWatch.Elapsed.TotalMilliseconds:F1}, total={totalWatch.Elapsed.TotalMilliseconds:F1}. " + + "Deferred: supplemental GetNameList, reflection fallback, adaptive sibling/equipment/reference/unit probes."; + + // The generation check and state publication are atomic with Reset. A stale + // owner can never write _lastDiscovery/_liveModel/identity into a new session. + if (!TryPublishSmartDiscoveryAuthority( + associationGeneration, + discovery, + liveModel, + reportInventory, + identity, + variableTypes.Count, + successfulTypeRoots, + summary)) + { + return Array.Empty(); + } + + // P0-5f: emit one local, zero-traffic evidence snapshot only after a fresh + // association owner has successfully published authority. Cached reuse above + // deliberately never reaches this call and therefore cannot masquerade as an + // independent physical repeat run. + if (IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + { + var repeatEvidencePath = TryWriteSmartDiscoveryRepeatRunEvidence( + associationGeneration, + discovery, + signals, + identity); + var repeatKpi = _session.LastSmartDiscoveryKpi; + if (!string.IsNullOrWhiteSpace(repeatEvidencePath) && + IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + { + LastDiscoverySummary += + $" P0-5f repeatEvidence={repeatEvidencePath}; " + + $"kpiSignature={repeatKpi?.DeterministicSignature ?? "unavailable"}."; + } + } + + return signals; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + totalWatch.Stop(); + if (IsCurrentSmartDiscoveryAssociationGeneration(associationGeneration)) + { + LastErrorMessage = + $"ARIEC61850 smart capture discovery failed after {totalWatch.Elapsed.TotalMilliseconds:F1} ms: " + + $"{ex.GetType().Name}: {ex.Message}. Last discovery: {_session.LastDiscoveryAttemptSummary}. Last request: {_session.LastDiscoveryRequestHex}"; + } + return Array.Empty(); + } + } +} diff --git a/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs new file mode 100644 index 000000000..6cbac46be --- /dev/null +++ b/Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs @@ -0,0 +1,155 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + private readonly object _smartDiscoveryFlightSync = new(); + private Task>? _smartDiscoveryAssociationFlight; + private long _smartDiscoveryAssociationGeneration; + private long _smartDiscoveryFlightGeneration = -1; + private string _smartDiscoveryAuthorityHost = string.Empty; + private int _smartDiscoveryAuthorityPort; + + /// + /// Explicitly invalidates every association-scoped smart-discovery authority and + /// advances the generation token. An already-running owner is intentionally not + /// force-cancelled mid-PDU; it observes the generation change at the next safe + /// boundary and is forbidden from publishing into the replacement association. + /// + private void ResetSmartDiscoveryAuthority() + { + lock (_smartDiscoveryFlightSync) + { + unchecked + { + _smartDiscoveryAssociationGeneration++; + } + + _smartDiscoveryAssociationFlight = null; + _smartDiscoveryFlightGeneration = -1; + _smartDiscoveryAuthority = null; + _smartDiscoveryModelAuthority = null; + _smartDiscoveryTypeProbeCount = 0; + _smartDiscoverySuccessfulTypeProbeCount = 0; + _smartDiscoveryAuthorityHost = string.Empty; + _smartDiscoveryAuthorityPort = 0; + } + } + + private bool IsCurrentSmartDiscoveryAssociationGeneration(long generation) + { + lock (_smartDiscoveryFlightSync) + return generation == _smartDiscoveryAssociationGeneration; + } + + private Task> GetOrCreateSmartDiscoveryAssociationFlight( + Func>> ownerFactory) + { + ArgumentNullException.ThrowIfNull(ownerFactory); + + lock (_smartDiscoveryFlightSync) + { + var generation = _smartDiscoveryAssociationGeneration; + if (_smartDiscoveryAssociationFlight is not null && + _smartDiscoveryFlightGeneration == generation) + { + return _smartDiscoveryAssociationFlight; + } + + var flight = ownerFactory(generation); + _smartDiscoveryAssociationFlight = flight; + _smartDiscoveryFlightGeneration = generation; + _ = flight.ContinueWith( + completed => ClearSmartDiscoveryFlight(generation, completed), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return flight; + } + } + + private void ClearSmartDiscoveryFlight( + long generation, + Task> flight) + { + // Observe a detached owner's fault if every waiter cancelled independently. + _ = flight.Exception; + + lock (_smartDiscoveryFlightSync) + { + if (generation == _smartDiscoveryAssociationGeneration && + _smartDiscoveryFlightGeneration == generation && + ReferenceEquals(_smartDiscoveryAssociationFlight, flight)) + { + _smartDiscoveryAssociationFlight = null; + _smartDiscoveryFlightGeneration = -1; + } + } + } + + private bool TryPublishSmartDiscoveryAuthority( + long generation, + ArMms.MmsDiscoveryResult discovery, + LiveIedModelDiscoveryDocument model, + NativeReportInventory reportInventory, + Iec61850DeviceIdentity identity, + int typeProbeCount, + int successfulTypeProbeCount, + string summary) + { + lock (_smartDiscoveryFlightSync) + { + if (generation != _smartDiscoveryAssociationGeneration || !_session.IsMmsInitiated) + return false; + + _lastDiscovery = discovery; + _liveModel = model; + LastReportInventory = reportInventory; + DetectedIdentity = identity; + PublishSmartDiscoveryAuthority( + discovery, + model, + typeProbeCount, + successfulTypeProbeCount); + LastDiscoverySummary = summary; + LastErrorMessage = summary; + return true; + } + } + + private bool TryPublishSmartDiscoveryPresentation( + long generation, + NativeReportInventory reportInventory, + Iec61850DeviceIdentity identity, + string summary) + { + lock (_smartDiscoveryFlightSync) + { + if (generation != _smartDiscoveryAssociationGeneration || + !IsSmartDiscoveryAuthorityBoundToCurrentAssociation()) + { + return false; + } + + LastReportInventory = reportInventory; + DetectedIdentity = identity; + LastDiscoverySummary = summary; + LastErrorMessage = summary; + return true; + } + } + + private bool IsSmartDiscoveryAuthorityBoundToCurrentAssociation() + => _session.IsMmsInitiated && + string.Equals(_smartDiscoveryAuthorityHost, _host, StringComparison.OrdinalIgnoreCase) && + _smartDiscoveryAuthorityPort == _port; + + private void BindSmartDiscoveryAuthorityToCurrentAssociation() + { + _smartDiscoveryAuthorityHost = _host; + _smartDiscoveryAuthorityPort = _port; + } +} diff --git a/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs b/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs new file mode 100644 index 000000000..7248490a8 --- /dev/null +++ b/Services/NativeIec61850Client.SmartDiscoveryOptimization.cs @@ -0,0 +1,224 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + // One caller owns the smart structural discovery for the active association. A + // concurrent UI/runtime re-entry waits for that work and then reuses the exact + // authoritative result instead of starting another GetNameList/GVA pass. + private readonly SemaphoreSlim _smartDiscoveryCaptureGate = new(1, 1); + private ArMms.MmsDiscoveryResult? _smartDiscoveryAuthority; + private LiveIedModelDiscoveryDocument? _smartDiscoveryModelAuthority; + private int _smartDiscoveryTypeProbeCount; + private int _smartDiscoverySuccessfulTypeProbeCount; + + private bool TryGetSmartDiscoveryAuthority( + out ArMms.MmsDiscoveryResult discovery, + out LiveIedModelDiscoveryDocument model) + { + discovery = null!; + model = null!; + + if (!IsSmartDiscoveryAuthorityBoundToCurrentAssociation() || + _smartDiscoveryAuthority is null || + _smartDiscoveryModelAuthority is null || + !ReferenceEquals(_lastDiscovery, _smartDiscoveryAuthority) || + !ReferenceEquals(_liveModel, _smartDiscoveryModelAuthority)) + { + return false; + } + + discovery = _smartDiscoveryAuthority; + model = _smartDiscoveryModelAuthority; + return true; + } + + private void PublishSmartDiscoveryAuthority( + ArMms.MmsDiscoveryResult discovery, + LiveIedModelDiscoveryDocument model, + int typeProbeCount, + int successfulTypeProbeCount) + { + _smartDiscoveryAuthority = discovery; + _smartDiscoveryModelAuthority = model; + _smartDiscoveryTypeProbeCount = typeProbeCount; + _smartDiscoverySuccessfulTypeProbeCount = successfulTypeProbeCount; + BindSmartDiscoveryAuthorityToCurrentAssociation(); + } + + private readonly record struct SmartProjectionStats( + int LogicalNodeHints, + int AddedFallbackSignals); + + /// + /// Projects the already-finalized canonical model and then adds only cheap indexed + /// fallback evidence. The legacy compatibility fallback walks the discovery graph + /// reflectively (up to 50k objects twice) and repeatedly scans the complete signal + /// list; that work is intentionally excluded from the normal PR134 critical path. + /// + private static List BuildSmartCaptureSignalProjection( + LiveIedModelDiscoveryDocument model, + NativeMmsDiscoverySnapshot snapshot, + NativeReportInventory inventory, + out SmartProjectionStats stats) + { + var signals = BuildSignalsFromArIecModel(model, snapshot).ToList(); + stats = AddSmartIndexedLogicalNodeFallbacks( + signals, + snapshot, + inventory, + DateTime.Now); + + // BuildSignalsFromArIecModel has already finalized the canonical list once. + // Only newly-added fallback statuses can need a control candidate. Do not run + // the full FinalizeDiscoveredSignals grouping/sorting pipeline a second time. + if (stats.AddedFallbackSignals > 0) + AddValidatedControlCandidatesFromStatus(signals); + + return signals; + } + + private static SmartProjectionStats AddSmartIndexedLogicalNodeFallbacks( + ICollection signals, + NativeMmsDiscoverySnapshot snapshot, + NativeReportInventory inventory, + DateTime now) + { + var hints = new Dictionary(StringComparer.OrdinalIgnoreCase); + var signalsByLogicalNode = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var logicalNodesWithCoreSignals = new HashSet(StringComparer.OrdinalIgnoreCase); + var references = new HashSet(StringComparer.OrdinalIgnoreCase); + + static string LogicalNodeKey(string domain, string logicalNode) + => string.Concat(domain, "\u001F", logicalNode); + + void IndexSignal(SignalDefinition signal) + { + var normalizedReference = NormalizeReference(signal.ObjectReference); + if (!string.IsNullOrWhiteSpace(normalizedReference)) + references.Add(normalizedReference); + + var domain = ExtractDomain(signal.ObjectReference); + var logicalNode = signal.LogicalNode?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrWhiteSpace(logicalNode)) + return; + + var key = LogicalNodeKey(domain, logicalNode); + if (!signalsByLogicalNode.TryGetValue(key, out var bucket)) + { + bucket = new List(); + signalsByLogicalNode[key] = bucket; + } + bucket.Add(signal); + if (signal.IsScadaCoreSignal) + logicalNodesWithCoreSignals.Add(key); + } + + void AddHint(string domain, string logicalNode, string source) + { + domain = (domain ?? string.Empty).Trim().Replace('$', '.'); + logicalNode = (logicalNode ?? string.Empty).Trim().Replace('$', '.'); + if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrWhiteSpace(logicalNode)) + return; + + var logicalNodeClass = SignalDefinition.DetectLogicalNodeClass(logicalNode).ToUpperInvariant(); + if (!IsScadaLogicalNodeClassForFallback(logicalNodeClass)) + return; + + var key = LogicalNodeKey(domain, logicalNode); + if (!hints.ContainsKey(key)) + hints[key] = new LogicalNodeHint(domain, logicalNode, logicalNodeClass, source); + } + + foreach (var signal in signals) + { + IndexSignal(signal); + var domain = ExtractDomain(signal.ObjectReference); + if (!string.IsNullOrWhiteSpace(domain) && !string.IsNullOrWhiteSpace(signal.LogicalNode)) + AddHint(domain, signal.LogicalNode, "canonical signal inventory"); + } + + foreach (var domainPair in snapshot.DomainVariables) + { + var domain = domainPair.Key ?? string.Empty; + foreach (var rawName in domainPair.Value) + { + foreach (var hint in ExtractLogicalNodeHintsFromText(rawName, domain, "MMS NamedVariable")) + AddHint(hint.Domain, hint.LogicalNode, hint.Source); + } + } + + foreach (var domainPair in snapshot.DomainVariableLists) + { + var domain = domainPair.Key ?? string.Empty; + foreach (var rawName in domainPair.Value) + { + foreach (var hint in ExtractLogicalNodeHintsFromText(rawName, domain, "MMS NamedVariableList/DataSet")) + AddHint(hint.Domain, hint.LogicalNode, hint.Source); + } + } + + foreach (var dataSet in inventory.DataSets) + { + AddHint(dataSet.Domain, dataSet.LogicalNode, "Report inventory DataSet"); + foreach (var hint in ExtractLogicalNodeHintsFromText(dataSet.Reference, dataSet.Domain, "Report inventory DataSet reference")) + AddHint(hint.Domain, hint.LogicalNode, hint.Source); + foreach (var hint in ExtractLogicalNodeHintsFromText(dataSet.RawMmsName, dataSet.Domain, "Report inventory DataSet raw name")) + AddHint(hint.Domain, hint.LogicalNode, hint.Source); + } + + foreach (var reportControl in inventory.ReportControls) + { + AddHint(reportControl.Domain, reportControl.LogicalNode, "Report inventory RCB"); + foreach (var hint in ExtractLogicalNodeHintsFromText(reportControl.Reference, reportControl.Domain, "Report inventory RCB reference")) + AddHint(hint.Domain, hint.LogicalNode, hint.Source); + foreach (var hint in ExtractLogicalNodeHintsFromText(reportControl.DataSetReference, reportControl.Domain, "Report inventory RCB DataSet reference")) + AddHint(hint.Domain, hint.LogicalNode, hint.Source); + } + + var added = 0; + foreach (var hint in hints.Values + .OrderBy(item => item.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.LogicalNode, StringComparer.OrdinalIgnoreCase)) + { + var key = LogicalNodeKey(hint.Domain, hint.LogicalNode); + if (logicalNodesWithCoreSignals.Contains(key)) + continue; + + var existingForLogicalNode = signalsByLogicalNode.TryGetValue(key, out var bucket) + ? bucket.ToArray() + : Array.Empty(); + + foreach (var point in BuildArIecLogicalNodeFallbackPoints(hint.LogicalNodeClass)) + { + if (hint.LogicalNodeClass is "MMXU" or "MMXN" && + existingForLogicalNode.Length > 0 && + !existingForLogicalNode.Any(signal => HasDataObjectPath(signal.ObjectReference, point.DataObject))) + { + continue; + } + + var reference = $"{hint.Domain}/{hint.LogicalNode}.{point.Path}"; + if (!references.Add(NormalizeReference(reference))) + continue; + + var signal = CreateArIecSignal( + reference, + point.FunctionalConstraint, + point.Category, + hint.LogicalNodeClass, + point.DataObject, + string.Empty, + now, + $"Indexed smart discovery fallback ({hint.Source})"); + signals.Add(signal); + added++; + } + } + + return new SmartProjectionStats(hints.Count, added); + } +} diff --git a/Services/NativeIec61850Client.SmartDiscoveryRepeatRunEvidence.cs b/Services/NativeIec61850Client.SmartDiscoveryRepeatRunEvidence.cs new file mode 100644 index 000000000..aa454a281 --- /dev/null +++ b/Services/NativeIec61850Client.SmartDiscoveryRepeatRunEvidence.cs @@ -0,0 +1,183 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + private const string P05fEngineCommit = "4467124775d8d9d76f3db194f9fbfd97144767a8"; + + public string LastSmartDiscoveryRepeatRunEvidencePath { get; private set; } = string.Empty; + + private string TryWriteSmartDiscoveryRepeatRunEvidence( + long associationGeneration, + ArMms.MmsDiscoveryResult discovery, + IReadOnlyList signals, + Iec61850DeviceIdentity identity) + { + try + { + // Local bookkeeping only. This refresh sends no MMS traffic and makes the + // engine KPI signature reflect hierarchy-materialized live directory counts. + _session.RefreshSmartDiscoveryModelKpi(discovery.IedDirectory); + + var kpi = _session.LastSmartDiscoveryKpi; + var typeBudget = _session.LastSmartTypeProbeBudget; + if (kpi is null) + return string.Empty; + + var directorySignature = ComputeDirectoryModelSignature(discovery.IedDirectory); + var projectionSignature = ComputeSignalProjectionSignature(signals); + var deviceIdentity = string.IsNullOrWhiteSpace(identity.IedName) + ? DetectedIedName + : identity.IedName; + + var payload = new + { + SchemaVersion = 1, + Phase = "P0-5f-run", + CapturedAtUtc = DateTimeOffset.UtcNow, + DeviceIdentity = deviceIdentity ?? string.Empty, + Host = _host, + Port = _port, + AssociationGeneration = associationGeneration, + EngineCommit = P05fEngineCommit, + Model = new + { + LogicalDevices = discovery.IedDirectory.LogicalDeviceCount, + LogicalNodes = discovery.IedDirectory.LogicalNodeCount, + SemanticPoints = discovery.IedDirectory.PointCount, + ProjectedSignals = signals.Count, + DataSets = discovery.ReportInventory.DataSets.Count, + ReportControls = discovery.ReportInventory.ReportControls.Count, + BufferedReportControls = discovery.ReportInventory.BufferedCount, + UnbufferedReportControls = discovery.ReportInventory.UnbufferedCount, + DirectoryModelSignature = directorySignature, + ProjectionSignature = projectionSignature + }, + SmartDiscoveryKpi = new + { + kpi.Generation, + kpi.TotalRequests, + kpi.SuccessfulRequests, + kpi.FailedRequests, + kpi.DuplicateRequests, + kpi.PeakOutstandingRequests, + kpi.WireAccountingComplete, + kpi.AccountingNotes, + kpi.LogicalDeviceCount, + kpi.LogicalNodeCount, + kpi.RawVariableCount, + kpi.FcPointCount, + kpi.DataSetCount, + kpi.DataSetDirectoryCount, + kpi.DataSetMemberCount, + kpi.ReportControlCount, + kpi.BufferedReportControlCount, + kpi.UnbufferedReportControlCount, + kpi.DeterministicSignature + }, + TypeProbeBudget = typeBudget is null + ? null + : new + { + typeBudget.DirectoryPoints, + typeBudget.SuppliedLogicalNodeCandidates, + typeBudget.SuppressedNonLiveLogicalNodeCandidates, + typeBudget.LogicalNodeRequests, + typeBudget.PointsCoveredByLogicalNode, + typeBudget.DataObjectRequests, + typeBudget.PointsCoveredByDataObject, + typeBudget.ExactLeafRequests, + typeBudget.SuppressedExactRepeatRequests, + typeBudget.PointsCoveredByExactLeaf, + typeBudget.RemainingUnresolvedPoints, + typeBudget.TotalPlannedRequests + } + }; + + var root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ARSAS", + "SmartDiscoveryEvidence"); + Directory.CreateDirectory(root); + + var safeIdentity = SanitizeEvidenceFileToken( + string.IsNullOrWhiteSpace(deviceIdentity) ? "unknown-ied" : deviceIdentity); + var stamp = DateTimeOffset.UtcNow.ToString("yyyyMMddTHHmmssfffZ"); + var path = Path.Combine(root, $"P0-5F-{safeIdentity}-{stamp}-g{associationGeneration}.json"); + var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(path, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + LastSmartDiscoveryRepeatRunEvidencePath = path; + return path; + } + catch + { + // P0-5f evidence is observability only. A local filesystem problem must not + // turn a successful IEC 61850 discovery into a protocol failure. + LastSmartDiscoveryRepeatRunEvidencePath = string.Empty; + return string.Empty; + } + } + + private static string ComputeDirectoryModelSignature(ArMms.MmsIedModelDirectory directory) + { + var canonical = directory.Points + .OrderBy(point => point.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.LogicalNode, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.DataObjectPath, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.MmsItemName, StringComparer.OrdinalIgnoreCase) + .Select(point => string.Join('|', + NormalizeSignaturePart(point.Domain), + NormalizeSignaturePart(point.LogicalNode), + NormalizeSignaturePart(point.FunctionalConstraint), + NormalizeSignaturePart(point.DataObjectPath), + NormalizeSignaturePart(point.MmsItemName))) + .ToArray(); + + return Sha256Lines(canonical); + } + + private static string ComputeSignalProjectionSignature(IReadOnlyList signals) + { + var canonical = signals + .OrderBy(signal => signal.ObjectReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(signal => signal.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) + .ThenBy(signal => signal.DataType, StringComparer.OrdinalIgnoreCase) + .ThenBy(signal => signal.Name, StringComparer.OrdinalIgnoreCase) + .Select(signal => string.Join('|', + NormalizeSignaturePart(signal.ObjectReference), + NormalizeSignaturePart(signal.FunctionalConstraint), + NormalizeSignaturePart(signal.DataType), + NormalizeSignaturePart(signal.Name), + NormalizeSignaturePart(signal.Category), + NormalizeSignaturePart(signal.DataSetReference), + NormalizeSignaturePart(signal.ReportControlReference), + NormalizeSignaturePart(signal.QualityReference), + NormalizeSignaturePart(signal.TimestampReference), + NormalizeSignaturePart(signal.Source))) + .ToArray(); + + return Sha256Lines(canonical); + } + + private static string Sha256Lines(IEnumerable lines) + { + var text = string.Join('\n', lines); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant(); + } + + private static string NormalizeSignaturePart(string? value) + => (value ?? string.Empty).Trim().Replace('\r', ' ').Replace('\n', ' ').ToLowerInvariant(); + + private static string SanitizeEvidenceFileToken(string value) + { + var invalid = Path.GetInvalidFileNameChars(); + var chars = value.Trim().Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray(); + return new string(chars); + } +} diff --git a/docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md b/docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md new file mode 100644 index 000000000..b3573d47e --- /dev/null +++ b/docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md @@ -0,0 +1,98 @@ +# P0-5d — Physical Capture Request-Budget & Duplicate-Wire Proof + +Status: field-capture evidence lane for ARSAS PR #324. This phase does not change MMS discovery semantics. It proves, from a fresh PCAP, that the P0-5b engine request-budget work and P0-5c ARSAS association single-flight are actually visible on the wire. + +## Immutable test baseline + +- ARSAS branch: `test/smart-ied-discovery-pr134` +- ARIEC61850 PR: #134 +- Engine commit: `4467124775d8d9d76f3db194f9fbfd97144767a8` +- Engine `.NET CI`: #652 passed +- ARSAS P0-5c Smart Discovery Field Capture Build: #29 passed at ARSAS commit `42c8f54177dd8f1c7570277e44cb95393427197b` + +The P0-5d verifier is additive test tooling. It does not send MMS traffic. + +## What must be captured + +Capture the complete interval from before TCP/ACSE/MMS association establishment until the first smart discovery has completed. For the clean discovery proof, do not start reporting, polling, control inspection, or command execution during the capture. + +Recommended packet-capture filter when the IED address is known: + +```text +host and tcp port 102 +``` + +Save the result as `.pcapng` without trimming the beginning or end of the association. + +## Wire proof contract + +`scripts/verify-smart-discovery-pcap.ps1` decodes MMS with TShark and evaluates the client-to-server confirmed-request stream. A P0-5d PASS requires: + +1. exactly one TCP stream carrying client MMS confirmed requests; +2. zero repeated semantic confirmed requests after invoke-ID is excluded from the fingerprint; +3. zero repeated GetNameList semantic requests — the proxy for a second naming sweep; +4. zero repeated GetVariableAccessAttributes semantic requests; +5. no invoke-ID reuse while the previous request is still outstanding; +6. no orphan response/error and no request left outstanding when capture ends; +7. measured peak outstanding requests does not exceed the MMS `negociatedMaxServOutstandingCalling` value when the decoder exposes it; +8. optional explicit total-request and GVA budgets are respected; +9. optional same-IED reference-capture comparison is emitted from the same verifier. + +The semantic request fingerprint includes service, object class/scope, domain, item/object item identity and continuation markers. It deliberately excludes `mms.invokeID`, so the same logical request sent twice with different invoke IDs is still detected as duplicate traffic. + +## Run the proof + +From the ARSAS repository or from the field-capture artifact bundle: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-pcap.ps1 ` + -PcapPath .\ARSAS_P0-5d.pcapng +``` + +To compare the same IED against a trusted reference-tool capture: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-pcap.ps1 ` + -PcapPath .\ARSAS_P0-5d.pcapng ` + -ReferencePcapPath .\REFERENCE_DiscoveryIED.pcapng +``` + +Optional hard budgets can be imposed after the first clean same-IED run establishes the expected envelope: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-pcap.ps1 ` + -PcapPath .\ARSAS_P0-5d.pcapng ` + -MaxConfirmedRequests ` + -MaxGvaRequests +``` + +Do not use `-RequireNoMoreRequestsThanReference` as a universal correctness rule. Different valid discovery strategies can use different service mixes. It is available only for a deliberately chosen same-IED acceptance contract. + +## Output + +The verifier writes `P0-5D--proof.json` beside the ARSAS capture. The JSON contains: + +- inferred client/server endpoints and request TCP stream(s); +- confirmed request/response counts; +- counts by MMS service; +- semantic duplicate details with frame numbers; +- duplicate GetNameList and GVA counts; +- second-sweep detection; +- peak outstanding requests; +- negotiated calling limit when present; +- invoke-ID lifecycle anomalies; +- unanswered/orphan counts; +- optional ARSAS-vs-reference request-count, peak-outstanding and service-budget deltas; +- final PASS/FAIL plus every failed acceptance gate. + +Keep the raw PCAP and generated proof JSON together. The JSON is derived evidence; the PCAP remains authoritative. + +## Regression mode + +CI may use `-DecodedRowsPath` with a synthetic tab-separated decoder fixture. This bypasses TShark only to execute the duplicate/outstanding proof algorithm deterministically. It is not accepted as physical relay evidence. + +## Field acceptance for the golden relay + +For the AA1E1F06R4 comparison, P0-5d is not considered physically proven until a fresh capture made with the exact P0-5d artifact passes the wire contract and the discovered model is separately checked against the canonical semantic target used throughout PR #134. Do not transfer an older R1/R2 capture result to a newer ARSAS or engine SHA. + +The first clean P0-5d result should be used to establish an evidence-backed same-IED hard request budget. That number should then be locked in a later regression/fixture rather than guessed in protocol code. diff --git a/docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md b/docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md new file mode 100644 index 000000000..2156c155c --- /dev/null +++ b/docs/P0-5E_GOLDEN_CAPTURE_BUDGET_LOCK.md @@ -0,0 +1,106 @@ +# P0-5e — Same-IED Golden Capture Acceptance & Hard Request-Budget Lock + +P0-5e turns one fresh, physically captured P0-5d PASS into an immutable request-budget authority for the same IED. The hard budget is evidence-derived: no total request count, GVA count, service count, or capture hash is guessed in source code. + +## Same-IED semantic authority + +The tracked target is `evidence/smart-discovery-golden-target.json`. + +For `AA1E1F06R4` the canonical semantic target remains: + +- 32 Logical Devices; +- 119 Logical Nodes; +- 4,925 semantic leaves; +- 2 DataSets; +- 58 ordered FCDA members; +- 32 logical ReportControls; +- 34 runtime RCB instances before semantic indexed-family collapse; +- one indexed buffered family with `max=2` and one indexed unbuffered family with `max=2`; +- zero synthetic ReportControl instances. + +The target intentionally contains `BudgetValues: null` until a fresh physical P0-5d PASS exists. Fixture numbers are never promoted into the production golden budget. + +## Production evidence inputs + +A production lock requires all of the following: + +1. the raw, complete same-IED `.pcap` or `.pcapng` generated by the exact field artifact; +2. a P0-5d proof JSON with `Verdict=PASS` for that capture; +3. `SMART-CAPTURE-BUILD.txt` from the exact same field artifact; +4. the full ARSAS commit SHA recorded by that manifest; +5. the full ARIEC61850 engine commit SHA recorded by that manifest; +6. the tracked same-IED semantic target. + +The lock writer independently re-runs the P0-5d verifier against the raw capture and compares the resulting wire metrics/service counts with the supplied proof before any lock is written. A PASS proof from capture A therefore cannot be paired with capture B. + +`-AllowFixtureEvidence` exists only for deterministic CI fixtures. Do not use it for physical acceptance. + +## Create the hard lock from physical evidence + +Run from the extracted field artifact directory: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\new-smart-discovery-golden-lock.ps1 ` + -ProofJson .\P0-5D-physical-proof.json ` + -CapturePath .\physical-discovery.pcapng ` + -DeviceIdentity AA1E1F06R4 ` + -ArsasCommit <40-char-arsas-sha-from-manifest> ` + -EngineCommit 4467124775d8d9d76f3db194f9fbfd97144767a8 ` + -TargetPath .\evidence\smart-discovery-golden-target.json ` + -BuildManifestPath .\SMART-CAPTURE-BUILD.txt ` + -OutputPath .\evidence\smart-discovery-golden-budget.lock.json +``` + +The writer refuses: + +- non-PASS P0-5d evidence; +- non-PCAP production evidence; +- duplicate semantic requests; +- duplicate GetNameList/GVA; +- a second naming sweep; +- invoke-ID reuse while outstanding; +- orphan/unanswered requests; +- a raw capture that does not independently reproduce the supplied proof; +- ARSAS or engine SHA that does not match `SMART-CAPTURE-BUILD.txt`; +- device/engine mismatch against the tracked same-IED target. + +The generated lock records SHA-256 for the raw capture, P0-5d proof, build manifest, and semantic target. It also embeds the reviewed semantic target and exact ARSAS/ARIEC61850 commits. + +The hard confirmed-request maximum is exactly the count observed in the physical PASS. Each MMS service count is separately locked as its own maximum. A service absent from the golden capture has an implicit maximum of zero. + +## Verify later captures against the golden budget + +First run P0-5d on the new candidate capture. Then run: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-golden-lock.ps1 ` + -LockPath .\evidence\smart-discovery-golden-budget.lock.json ` + -ProofJson .\P0-5D-candidate-proof.json ` + -DeviceIdentity AA1E1F06R4 ` + -CandidateArsasCommit ` + -CandidateEngineCommit 4467124775d8d9d76f3db194f9fbfd97144767a8 ` + -TargetPath .\evidence\smart-discovery-golden-target.json +``` + +A default PASS requires: + +- candidate P0-5d proof is PASS; +- exact same IED identity; +- candidate ARSAS commit matches the golden artifact; +- candidate engine commit matches the golden engine; +- semantic-target file hash/content matches the golden lock; +- confirmed-request total does not exceed the locked maximum; +- no service count exceeds its locked maximum; +- no new/unexpected MMS service appears; +- duplicate semantic request/GetNameList/GVA remain zero; +- second GetNameList sweep remains forbidden; +- invoke-ID/orphan/unanswered counts remain zero; +- peak outstanding remains within the locked maximum. + +For an intentional future comparison, `-AllowDifferentArsasCommit` and/or `-AllowDifferentEngineCommit` can compare a new build against the old golden request envelope. These switches do not modify the lock and must never be used to silently redefine production authority. + +## Lock replacement policy + +`smart-discovery-golden-budget.lock.json` must never be hand-edited to make a failing candidate pass. Replace it only by re-running the writer against a newly reviewed physical P0-5d PASS produced by the exact candidate field artifact. Preserve the old capture/proof/build/target hashes in review history. + +The raw physical capture remains primary wire evidence. The P0-5d proof is derived wire evidence. The field build manifest proves application/engine identity. The P0-5e semantic target is reviewed model authority. The P0-5e golden lock is the regression contract derived from all four. diff --git a/docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md b/docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md new file mode 100644 index 000000000..33d62b08a --- /dev/null +++ b/docs/P0-5F_REPEAT_RUN_STABILITY_PROOF.md @@ -0,0 +1,111 @@ +# P0-5f — Physical Golden Lock Finalization & Repeat-Run Stability Proof + +P0-5f proves that the accepted same-IED discovery is not a one-off lucky run. It requires a production P0-5e golden lock plus at least three fresh, independent MMS associations made by the exact same ARSAS/engine artifact. + +## Production prerequisites + +The production path requires: + +1. a P0-5e golden lock created from an independently reverified physical PCAP (`RawCaptureReverified=true`); +2. the exact field artifact and its `SMART-CAPTURE-BUILD.txt` manifest; +3. the tracked `smart-discovery-golden-target.json` and `smart-discovery-repeat-run-target.json`; +4. at least three fresh discovery runs, each starting from a new MMS association generation; +5. for every run: raw PCAP/PCAPNG, P0-5d PASS proof, and the local `P0-5F-*.json` runtime evidence emitted by ARSAS after the fresh association owner publishes authority. + +Cached rediscovery on the same association is not a repeat run and intentionally does not emit new P0-5f runtime evidence. + +## Per-run procedure + +For each run, disconnect/reconnect so ARSAS creates a fresh association generation. Start capture before TCP/ACSE/MMS association establishment, perform one smart discovery, then stop capture only after discovery has completed. + +Create the P0-5d wire proof: + +```powershell +powershell -ExecutionPolicy Bypass -File .\verify-smart-discovery-pcap.ps1 ` + -PcapPath .\run-01.pcapng ` + -OutputJson .\P0-5D-run-01-proof.json +``` + +Locate the matching ARSAS runtime evidence under: + +```text +%LOCALAPPDATA%\ARSAS\SmartDiscoveryEvidence\P0-5F-*.json +``` + +Then create a P0-5f run bundle: + +```powershell +powershell -ExecutionPolicy Bypass -File .\new-smart-discovery-repeat-run-bundle.ps1 ` + -GoldenLockPath .\smart-discovery-golden-budget.lock.json ` + -ProofJson .\P0-5D-run-01-proof.json ` + -CapturePath .\run-01.pcapng ` + -RuntimeEvidenceJson .\P0-5F-run-01-runtime.json ` + -BuildManifestPath .\SMART-CAPTURE-BUILD.txt ` + -TargetPath .\smart-discovery-golden-target.json ` + -DeviceIdentity AA1E1F06R4 ` + -ArsasCommit ` + -EngineCommit 4467124775d8d9d76f3db194f9fbfd97144767a8 ` + -OutputPath .\P0-5F-run-01-bundle.json +``` + +Production bundle creation independently re-decodes the raw PCAP, re-applies the P0-5e golden request budget, verifies exact build/target hashes, and requires engine KPI `TotalRequests` to equal the PCAP confirmed-request count. + +Repeat this for at least three independently established associations. + +## Finalize repeat-run stability + +```powershell +powershell -ExecutionPolicy Bypass -File .\finalize-smart-discovery-repeat-run-stability.ps1 ` + -GoldenLockPath .\smart-discovery-golden-budget.lock.json ` + -RepeatTargetPath .\smart-discovery-repeat-run-target.json ` + -RunBundlePaths .\P0-5F-run-01-bundle.json,.\P0-5F-run-02-bundle.json,.\P0-5F-run-03-bundle.json ` + -OutputPath .\P0-5F-physical-finalization.json +``` + +A production PASS requires all runs to be bound to the same golden lock, device, ARSAS commit, engine commit, build manifest hash, and semantic-target hash. Every raw capture hash, runtime-evidence hash, and association generation must be unique. + +Across all runs the following must be exactly stable: + +- confirmed MMS request count; +- MMS service mix; +- engine smart-discovery deterministic signature; +- canonical directory model signature; +- ARSAS signal-projection signature; +- hierarchy type-probe budget; +- discovered model counts. + +Every run must also preserve: + +- zero semantic duplicate requests; +- zero duplicate GetNameList/GVA; +- no second naming sweep; +- engine `DuplicateRequests=0`; +- complete engine wire accounting; +- PCAP request count equal to engine KPI request count; +- peak outstanding no higher than the P0-5e golden maximum; +- same-IED semantic counts: 32 LD, 119 LN, 4,925 semantic points, 2 DataSets, and 34 runtime RCB instances before semantic family collapse. + +The finalization output records the consensus request/service budget, all deterministic signatures, association generations, peak-outstanding range, bundle hashes, capture hashes, runtime-evidence hashes, golden-lock hash, and repeat-target hash. + +## Promote reviewed physical finalization into authority + +After the finalization JSON is reviewed and reports `Verdict=PASS`, create the immutable physical authority file: + +```powershell +powershell -ExecutionPolicy Bypass -File .\new-smart-discovery-repeat-run-authority.ps1 ` + -GoldenLockPath .\smart-discovery-golden-budget.lock.json ` + -RepeatTargetPath .\smart-discovery-repeat-run-target.json ` + -FinalizationJson .\P0-5F-physical-finalization.json ` + -RunBundlePaths .\P0-5F-run-01-bundle.json,.\P0-5F-run-02-bundle.json,.\P0-5F-run-03-bundle.json ` + -OutputPath .\P0-5F-physical-authority.lock.json +``` + +The authority gate has no fixture override. It requires a P0-5e golden lock with `RawCaptureReverified=true`, a schema-v2 P0-5f PASS, and the exact run-bundle hashes referenced by the reviewed finalization. Every supplied run bundle must have `FixtureEvidence=false`; capture hashes, runtime-evidence hashes, and association generations must all be unique. + +The resulting authority file records the exact ARSAS/engine commits, golden-lock hash, repeat-target hash, finalization hash, consensus signatures/budgets, run-bundle hashes, raw-capture hashes, runtime-evidence hashes, and association generations. + +## Evidence authority + +`-AllowFixtureEvidence` exists only for CI regression fixtures in bundle/finalization testing. Never use it for physical acceptance. The final authority writer intentionally exposes no fixture bypass. + +P0-5f is physically complete only when both the production finalization JSON reports `Verdict=PASS` from at least three fresh physical associations and `P0-5F-physical-authority.lock.json` is generated successfully. Until then, `smart-discovery-repeat-run-target.json` remains in `awaiting-physical-golden-lock-and-three-independent-runs` state and `FinalizationAuthority` remains null. diff --git a/docs/P0-5G_PRODUCTION_PROMOTION_MAINLINE_READINESS.md b/docs/P0-5G_PRODUCTION_PROMOTION_MAINLINE_READINESS.md new file mode 100644 index 000000000..3e816bea7 --- /dev/null +++ b/docs/P0-5G_PRODUCTION_PROMOTION_MAINLINE_READINESS.md @@ -0,0 +1,122 @@ +# P0-5g — Golden Discovery Production Promotion & Mainline Merge Readiness + +P0-5g converts the field-only smart discovery lane into a controlled production promotion. It is deliberately fail-closed: a normal build does not activate the smart route until a reviewed physical P0-5f authority has been accepted and a P0-5g promotion authority has been generated. + +## State machine + +P0-5g has three observable readiness states: + +1. `BLOCKED` — one or more physical/provenance/engine gates are missing or invalid; +2. `READY_TO_PROMOTE` — physical P0-5f authority exists, the engine PR head is green and evidence-compatible, no disallowed post-authority ARSAS source changes exist, and the tracked production switch is still false; +3. `READY_FOR_REVIEW` — a P0-5g promotion authority is present, bound to the physical authority and validated engine head, and the tracked production switch is true. + +CI fixtures may test the state machine but can never create production authority. + +## Fail-closed build routing + +`evidence/SmartDiscoveryPromotion.props` owns the tracked production switch. + +Before production authority: + +```xml +false +``` + +`Directory.Build.targets` activates the smart route only when either: + +- the build is the dedicated `Smart Discovery Field Capture Build` evidence workflow; or +- `SmartDiscoveryProductionPromoted=true` has been written by the P0-5g promotion authority writer. + +This prevents merging the PR from silently converting every ordinary build to the field route before physical evidence is complete. + +## Engine evidence-compatible ancestry + +The physical discovery evidence baseline remains: + +```text +4467124775d8d9d76f3db194f9fbfd97144767a8 +``` + +A newer ARIEC61850 PR #134 head may be accepted for merge-readiness only when: + +- it is a descendant of that baseline; +- its exact head CI is green; +- none of the tracked discovery-critical files listed in `smart-discovery-production-promotion-target.json` changed between the physical baseline and the new head. + +Changes outside those paths, such as independent SCL export/test work, do not automatically invalidate the physical discovery request-budget evidence. Any change to a discovery-critical path requires a new physical evidence cycle rather than an exception. + +## Physical P0-5f authority + +The production readiness verifier expects a physical authority created by: + +```text +scripts/new-smart-discovery-repeat-run-authority.ps1 +``` + +That authority must have: + +- `Phase=P0-5f-authority`; +- `Status=physical-finalized`; +- production evidence only; +- the same device identity and evidence engine baseline; +- the reviewed independent repeat-run set. + +After the physical authority commit, only the narrow promotion-only allowlist in the P0-5g target may change before promotion. Runtime discovery source changes invalidate readiness. + +## Verify readiness + +The verifier requires local checkouts of ARSAS and the exact engine PR head: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\verify-smart-discovery-production-readiness.ps1 ` + -TargetPath .\evidence\smart-discovery-production-promotion-target.json ` + -EngineLockPath .\engines\ARIEC61850.lock.json ` + -PromotionPropsPath .\evidence\SmartDiscoveryPromotion.props ` + -ArsasRepositoryPath . ` + -EngineRepositoryPath ..\ARIEC61850 ` + -ArsasHeadCommit ` + -EngineHeadCommit ` + -EngineHeadCiConclusion success ` + -PhysicalAuthorityPath .\evidence\smart-discovery-repeat-run.authority.json ` + -PromotionAuthorityPath .\evidence\smart-discovery-production-promotion-authority.json ` + -OutputJson .\P0-5G-production-readiness.json +``` + +Before physical evidence exists the expected result is `BLOCKED`; that is a safety result, not a reason to bypass the verifier. + +## Create production promotion authority + +Only a `READY_TO_PROMOTE` proof may be promoted: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\new-smart-discovery-production-promotion-authority.ps1 ` + -ReadinessJson .\P0-5G-production-readiness.json ` + -TargetPath .\evidence\smart-discovery-production-promotion-target.json ` + -PhysicalAuthorityPath .\evidence\smart-discovery-repeat-run.authority.json ` + -EngineLockPath .\engines\ARIEC61850.lock.json ` + -OutputAuthorityPath .\evidence\smart-discovery-production-promotion-authority.json ` + -OutputPropsPath .\evidence\SmartDiscoveryPromotion.props +``` + +The writer has no fixture or force bypass. It writes `SmartDiscoveryProductionPromoted=true` only together with a cryptographically bound P0-5g authority. + +## Mainline ready-for-review gate + +`READY_FOR_REVIEW` from the local promotion verifier is necessary but not sufficient to mark PR #324 ready. Before leaving Draft, also verify on the exact ARSAS head: + +- Smart Discovery Field Capture Build = success; +- Smart Discovery Golden Budget Lock = success; +- Smart Discovery Golden Provenance = success; +- Smart Discovery Repeat-Run Stability = success; +- P0-5g Production Promotion Guard = success; +- generic ARSAS build/test workflow = success; +- relevant installer/evidence validation workflows = success; +- ARIEC61850 PR #134 exact head `.NET CI` = success; +- no unresolved blocking review threads; +- PR remains mergeable against current `main`. + +Do not mark the PR ready, enable auto-merge, or merge either repository while any of these conditions are unresolved. + +## Production source cleanup + +The current promotion mechanism keeps the historically proven build-time route patcher but places it behind the fail-closed promotion switch. Removing the patcher and moving the equivalent calls directly into `NativeIec61850Client.cs` is a separate source-cleanup operation and must preserve binary/runtime behavior. Do not combine that cleanup with the physical-evidence promotion commit because it would invalidate the exact ARSAS commit lineage being promoted. diff --git a/docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md b/docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md new file mode 100644 index 000000000..9ce0e0408 --- /dev/null +++ b/docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md @@ -0,0 +1,83 @@ +# P0-5h — Mainline Merge Execution & Post-Merge Production Verification + +P0-5h is the execution phase after P0-5g reaches `READY_FOR_REVIEW`. It does not weaken or bypass P0-5f/P0-5g evidence requirements. If physical authority is missing, the phase remains blocked and neither PR is merged. + +## Preconditions + +All of the following must be true on the exact final heads before merge execution: + +- P0-5f authority is `physical-finalized` and production evidence only; +- P0-5g promotion authority is `production-promoted`; +- `SmartDiscoveryProductionPromoted=true` and props are bound to the exact promotion-authority SHA-256 and validated engine head; +- P0-5g readiness is exactly `READY_FOR_REVIEW` with zero blockers; +- Smart Discovery Production Promotion Guard succeeds; +- Smart Discovery Mainline Readiness succeeds; +- Smart Discovery Field Capture Build, Golden Budget Lock, Golden Provenance, Repeat-Run Stability, generic Build ARSAS, installer/IO/SV/legacy guards succeed; +- engine PR #134 exact head CI succeeds; +- both PRs are open, mergeable, and have no unresolved review threads. + +## Why merge commits are mandatory + +P0-5h uses GitHub merge method `merge` for both repositories. Squash or rebase are not accepted because the physical and promotion evidence bind exact PR head commits. A merge commit preserves those validated commits as ancestors of `main`, which can be verified after merge. + +## Merge manifest + +After P0-5g is fully ready, generate `evidence/smart-discovery-mainline-merge-manifest.json` with `new-smart-discovery-mainline-merge-manifest.ps1`. + +The manifest records: + +- P0-5g validated ARSAS head; +- exact engine PR head; +- both base SHAs at authorization time; +- physical authority, promotion authority, readiness, target, and promotion-props hashes; +- merge method `merge`; +- merge order `engine -> arsas`. + +The tracked manifest intentionally does **not** store its own future ARSAS commit SHA. After adding the manifest, the only allowed post-authorization ARSAS change is that manifest file itself. + +## Live merge preflight + +Immediately before any GitHub merge action, run `verify-smart-discovery-live-merge-preflight.ps1` against the live PR heads/base SHAs. It rejects: + +- ARSAS base drift after authorization; +- engine base drift after authorization; +- engine PR head drift; +- an ARSAS live head that is not a descendant of the P0-5g validated head; +- any ARSAS post-authorization path other than `evidence/smart-discovery-mainline-merge-manifest.json`; +- absence of the tracked P0-5h manifest change. + +A PASS writes `P0-5h-live-preflight` evidence containing the live ARSAS head. That live SHA—not a self-referential value stored in the manifest—is supplied to GitHub as the ARSAS `expected_head_sha`. + +## Exact execution sequence + +1. Re-fetch ARIEC61850 PR #134 and ARSAS PR #324. +2. Require each PR to still be open and mergeable. +3. Require current base SHA to equal the SHA captured by the P0-5h manifest. +4. Require no unresolved review threads on either PR. +5. Run the live merge preflight and require `PASS`. +6. Merge engine PR #134 first using merge method `merge` and its exact `expected_head_sha`. +7. Verify the engine merge succeeded and the validated engine head is now an ancestor of engine `main`. +8. Re-fetch ARSAS PR #324. Abort if its head/base/mergeability/review state changed after engine merge. +9. Merge ARSAS PR #324 using merge method `merge` and the freshly resolved live ARSAS head as `expected_head_sha`. +10. Never enable auto-merge in this phase. + +Any mismatch aborts execution. There is no force or fixture bypass. + +## Post-merge production verification + +`Smart Discovery Post-Merge Production Verification` runs on pushes to ARSAS `main` and can also be dispatched manually. It: + +- requires the tracked P0-5h merge manifest and P0-5g promotion authority; +- clones current ARIEC61850 `main`; +- verifies the exact validated engine head is an ancestor of engine main; +- verifies the P0-5g validated ARSAS head is an ancestor of ARSAS main; +- verifies production promotion props remain enabled and hash-bound to the promotion authority and validated engine head; +- runs engine source hygiene, restore, build, and tests from engine main; +- runs ARSAS restore, build, and tests from ARSAS main; +- emits `P0-5H-post-merge-production.json` as the post-merge attestation artifact. + +P0-5h is complete only when both PRs are merged in the required order and this post-merge attestation reports `Verdict=PASS` from mainline state. + +## Current blocked state + +Until a real P0-5f physical-finalized authority exists, P0-5g remains `BLOCKED`, no P0-5h merge manifest may be authorized, and neither PR may be merged by this phase. diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index e4e2a9548..e85247a75 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,13 +2,18 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "3afc924c97627fe86adbe784c905e2f35dff0b1a", - "sourcePullRequest": 133, - "purpose": "Pins merged ARIEC61850 PR #133 on top of the exact PR #132 golden-wire tree. ARIEC .NET CI #605 passed source/provenance verification, restore, build, and tests. This adds only an opt-in source-backed Legacy SAS export boundary: ARSAS may display concrete live runtime RCB slots such as Buffer01/Buffer02 while CID/IID export retains the logical source ReportControl identity and RptEnabled indexing metadata. The option is default-off, so existing exact-runtime live-model export remains unchanged. All trusted-SCL acquisition, reporting and control contracts from PR #132 remain unchanged: SCL-authoritative DataSet/RCB identity, LDevice ldName and ReportControl indexed semantics, quoted Edition-1/vendor OSI-AP-Title compatibility, Domain/VMD reconciliation, bounded sequential initial FC-root Reads, receiver-before-write report registration, URCB Resv -> RptEna, BRCB direct RptEna with ResvTms retry-only, two whole-RCB verification reads, one-shot GI after routing is registered, GI fail-closed cleanup, no cyclic process polling, no network DataSet-directory browse, and no dynamic DataSet mutation on the trusted-SCL path. SCL RptEnabled@max remains declarative design metadata and is never authority to synthesize concrete runtime RCB names.", + "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", + "sourcePullRequest": 134, + "purpose": "P0-5c ARSAS smart-discovery capture pin. Compiles against the exact P0-5b hierarchy-coverage/GVA-budget convergence engine commit, including live-LN suppression, LN -> unresolved DO -> distinct exact-leaf fallback, exact-repeat suppression, LastSmartTypeProbeBudget diagnostics, association-scoped engine directory single-flight, authoritative domain-variable reuse, and Control inventory injection. ARSAS P0-5c adds one application-level association-generation flight around directory discovery, GVA enrichment, canonical model build, projection, and publication so caller cancellation cannot restart GVA work on the same association and stale reconnect/dispose flights cannot publish into a replacement association. This trial pin changes discovery orchestration only; it preserves the complete field-proven reporting/control ancestry recorded in fieldProvenBaseline below.", "previousTrialPin": { + "commit": "57c8311c0dcf9e51da4d7dc31c757fc3f0912586", + "sourcePullRequest": 134, + "purpose": "Previous PR #134 R4 smart-discovery capture pin before P0-5b hierarchy request-budget convergence and P0-5c ARSAS association-generation flight integration." + }, + "priorGoldenWirePin": { "commit": "0023ef9a4373855497464ed3979e359c4041c95d", "sourcePullRequest": 132, - "purpose": "Previous ARSAS 1.6.36 combined golden-wire convergence pin retained for explicit ancestry." + "purpose": "Earlier ARSAS 1.6.36 combined golden-wire convergence trial retained for explicit ancestry." }, "fieldProvenBaseline": { "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9", diff --git a/evidence/SmartDiscoveryPromotion.props b/evidence/SmartDiscoveryPromotion.props new file mode 100644 index 000000000..58eaafd72 --- /dev/null +++ b/evidence/SmartDiscoveryPromotion.props @@ -0,0 +1,9 @@ + + + + false + 4467124775d8d9d76f3db194f9fbfd97144767a8 + P0-5g + + diff --git a/evidence/smart-discovery-golden-target.json b/evidence/smart-discovery-golden-target.json new file mode 100644 index 000000000..1bdfb9870 --- /dev/null +++ b/evidence/smart-discovery-golden-target.json @@ -0,0 +1,27 @@ +{ + "SchemaVersion": 1, + "Phase": "P0-5e", + "Status": "awaiting-fresh-physical-budget-lock", + "DeviceIdentity": "AA1E1F06R4", + "EngineCommit": "4467124775d8d9d76f3db194f9fbfd97144767a8", + "SemanticTarget": { + "LogicalDevices": 32, + "LogicalNodes": 119, + "SemanticLeaves": 4925, + "DataSets": 2, + "OrderedFcdaMembers": 58, + "LogicalReportControls": 32, + "RuntimeReportControlInstances": 34, + "IndexedBufferedFamilyMax": 2, + "IndexedUnbufferedFamilyMax": 2, + "SyntheticReportControlInstancesAllowed": 0 + }, + "BudgetAuthority": { + "RequiredProofPhase": "P0-5d", + "RequiredProofVerdict": "PASS", + "RequireRawCaptureSha256": true, + "RequireExactArsasCommit": true, + "RequireExactEngineCommit": true, + "BudgetValues": null + } +} diff --git a/evidence/smart-discovery-mainline-merge-target.json b/evidence/smart-discovery-mainline-merge-target.json new file mode 100644 index 000000000..89c363233 --- /dev/null +++ b/evidence/smart-discovery-mainline-merge-target.json @@ -0,0 +1,41 @@ +{ + "SchemaVersion": 1, + "Phase": "P0-5h", + "Status": "blocked-until-p0-5g-ready-for-review", + "ArsasRepository": "masarray/arsas", + "ArsasPullRequest": 324, + "EngineRepository": "masarray/ARIEC61850", + "EnginePullRequest": 134, + "MergeMethod": "merge", + "MergeOrder": [ + "engine", + "arsas" + ], + "PostMergeVerification": { + "RequireEngineHeadAncestorOfEngineMain": true, + "RequireArsasValidatedHeadAncestorOfArsasMain": true, + "RequireTrackedMergeManifestOnArsasMain": true, + "RequireProductionPromotionAuthority": true, + "RequireProductionSwitchEnabled": true, + "RequireExactPromotionAuthorityHashBinding": true, + "RequireExactValidatedEngineHeadBinding": true, + "RequireEngineSourceHygiene": true, + "RequireEngineBuildAndTests": true, + "RequireArsasBuildAndTests": true, + "RequirePostMergeAttestationArtifact": true + }, + "ExecutionContract": { + "RequireP05gReadyForReview": true, + "RequireExactExpectedHeadShaOnMerge": true, + "RequireBaseShaUnchangedFromMergeManifest": true, + "RequireEngineMergeBeforeArsasMerge": true, + "RequireMergeCommitMethod": true, + "RequireOnlyMergeManifestChangeAfterValidatedArsasHead": true, + "RequireNoUnresolvedReviewThreads": true, + "RequireBothPullRequestsMergeable": true, + "ForbidAutoMergeBeforeAllPreconditions": true, + "ForbidFixtureOrForceBypass": true + }, + "MergeManifest": null, + "PostMergeAuthority": null +} diff --git a/evidence/smart-discovery-production-promotion-target.json b/evidence/smart-discovery-production-promotion-target.json new file mode 100644 index 000000000..d2459dfc5 --- /dev/null +++ b/evidence/smart-discovery-production-promotion-target.json @@ -0,0 +1,48 @@ +{ + "SchemaVersion": 2, + "Phase": "P0-5g", + "Status": "awaiting-physical-p0-5f-authority-and-green-mainline-gates", + "ArsasPullRequest": 324, + "EngineRepository": "masarray/ARIEC61850", + "EnginePullRequest": 134, + "EvidenceEngineBaselineCommit": "4467124775d8d9d76f3db194f9fbfd97144767a8", + "DiscoveryCriticalEnginePaths": [ + "src/AR.Iec61850/Control/Iec61850ControlService.cs", + "src/AR.Iec61850/Control/Iec61850ControlTransport.cs", + "src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs", + "src/AR.Iec61850/Mms/MmsClientSession.SmartDataSetDirectories.cs", + "src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs", + "src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs", + "src/AR.Iec61850/Mms/MmsClientSession.SmartInitialFcRead.cs", + "src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs", + "src/AR.Iec61850/Mms/MmsDataSetDirectory.cs", + "src/AR.Iec61850/Mms/MmsIedModelDirectory.cs", + "src/AR.Iec61850/Mms/MmsSmartDataSetPipelinePolicy.cs", + "src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs", + "src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs", + "src/AR.Iec61850/Osi/TpktClient.cs" + ], + "AllowedPostPhysicalAuthorityPaths": [ + "evidence/smart-discovery-repeat-run.authority.json", + "evidence/smart-discovery-production-promotion-authority.json", + "evidence/SmartDiscoveryPromotion.props", + "evidence/smart-discovery-production-promotion-target.json", + "evidence/smart-discovery-mainline-merge-manifest.json" + ], + "ProductionPromotionContract": { + "RequireP05fPhysicalAuthority": true, + "RequirePhysicalAuthorityProductionEvidenceOnly": true, + "RequireEngineHeadCiSuccess": true, + "AllowEngineHeadDescendantWhenCriticalDiscoveryPathsUnchanged": true, + "RequireFieldRouteExplicitOptInBeforePromotion": true, + "RequireProductionSwitchFalseUntilAuthority": true, + "RequireExactPromotionAuthoritySha256Binding": true, + "RequireExactValidatedEngineHeadBinding": true, + "RequirePurposeDiscoveryCiSuccess": true, + "RequireGenericBuildSuccessBeforeReadyForReview": true, + "RequireNoUnresolvedReviewThreadsBeforeReadyForReview": true, + "RequireDedicatedMainlineReadinessGateSuccess": true, + "RequirePrRemainDraftUntilAllReadyGatesPass": true + }, + "PromotionAuthority": null +} diff --git a/evidence/smart-discovery-repeat-run-target.json b/evidence/smart-discovery-repeat-run-target.json new file mode 100644 index 000000000..6bf095cc0 --- /dev/null +++ b/evidence/smart-discovery-repeat-run-target.json @@ -0,0 +1,40 @@ +{ + "SchemaVersion": 1, + "Phase": "P0-5f", + "Status": "awaiting-physical-golden-lock-and-three-independent-runs", + "DeviceIdentity": "AA1E1F06R4", + "EngineCommit": "4467124775d8d9d76f3db194f9fbfd97144767a8", + "MinimumIndependentAssociations": 3, + "SemanticTarget": { + "LogicalDevices": 32, + "LogicalNodes": 119, + "SemanticLeaves": 4925, + "DataSets": 2, + "OrderedFcdaMembers": 58, + "LogicalReportControls": 32, + "RuntimeReportControlInstances": 34, + "IndexedBufferedFamilyMax": 2, + "IndexedUnbufferedFamilyMax": 2, + "SyntheticReportControlInstancesAllowed": 0 + }, + "RepeatRunContract": { + "RequireProductionGoldenLock": true, + "RequireFreshAssociationGenerationPerRun": true, + "RequireExactArsasCommit": true, + "RequireExactEngineCommit": true, + "RequireExactBuildManifestHash": true, + "RequireExactSemanticTargetHash": true, + "RequireWireAccountingComplete": true, + "RequireZeroDuplicateSemanticRequests": true, + "RequireZeroEngineDuplicateRequests": true, + "RequireExactConfirmedRequestCountAcrossRuns": true, + "RequireExactServiceMixAcrossRuns": true, + "RequireExactEngineKpiSignatureAcrossRuns": true, + "RequireExactDirectoryModelSignatureAcrossRuns": true, + "RequireExactProjectionSignatureAcrossRuns": true, + "RequireExactTypeProbeBudgetAcrossRuns": true, + "RequireExactModelCountsAcrossRuns": true, + "PeakOutstandingRule": "within-golden-lock-max-record-range" + }, + "FinalizationAuthority": null +} diff --git a/landing/release-notes.json b/landing/release-notes.json index 788535252..d53a0fc50 100644 --- a/landing/release-notes.json +++ b/landing/release-notes.json @@ -8,14 +8,14 @@ "summaryId": "ARSAS 1.6.37 adalah stable release Windows terverifikasi terbaru. Identitas paket, link download, checksum, dan publication evidence disinkronkan otomatis dari GitHub Release bertag.", "highlights": [ "fix(v1.6.36): smooth COMTRADE scrub presentation at display cadence", - "fix(v1.6.36): match IEDScout RCB instances and source export identity", + "fix(v1.6.36): converge RCB instances and source export identity with the trusted reference workflow", "COMTRADE: smooth scrubbing across analysis workspaces", "Release convergence: promote field-tested v1.6.36 trial fixes to main", "docs: synchronize documentation and landing with ARSAS 1.6.37" ], "highlightsId": [ "Perubahan rilis: fix(v1.6.36): smooth COMTRADE scrub presentation at display cadence", - "Perubahan rilis: fix(v1.6.36): match IEDScout RCB instances and source export identity", + "Perubahan rilis: fix(v1.6.36): samakan instance RCB dan identitas source export dengan workflow referensi tepercaya", "Perubahan rilis: COMTRADE: smooth scrubbing across analysis workspaces", "Perubahan rilis: Release convergence: promote field-tested v1.6.36 trial fixes to main", "Dokumentasi: synchronize documentation and landing with ARSAS 1.6.37" diff --git a/scripts/enable-smart-discovery-capture.ps1 b/scripts/enable-smart-discovery-capture.ps1 new file mode 100644 index 000000000..e25090bf4 --- /dev/null +++ b/scripts/enable-smart-discovery-capture.ps1 @@ -0,0 +1,101 @@ +$ErrorActionPreference = 'Stop' + +$sourcePath = Join-Path $PSScriptRoot '..\Services\NativeIec61850Client.cs' +$sourcePath = [System.IO.Path]::GetFullPath($sourcePath) +$text = [System.IO.File]::ReadAllText($sourcePath) +$changed = $false + +$routeMarker = 'DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress)' +if ($text.IndexOf($routeMarker, [System.StringComparison]::Ordinal) -lt 0) { + $pattern = '(public async Task> DiscoverSignalsAsync\(CancellationToken cancellationToken, IProgress\? progress = null\)\s*\{)' + $match = [regex]::Match($text, $pattern) + if (-not $match.Success) { + throw 'Could not locate NativeIec61850Client.DiscoverSignalsAsync entrypoint.' + } + if ([regex]::Matches($text, $pattern).Count -ne 1) { + throw 'DiscoverSignalsAsync entrypoint is not unique; refusing ambiguous build-time patch.' + } + + $injection = @' + + if (SmartDiscoveryCaptureModeEnabled) + return await DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress).ConfigureAwait(false); +'@ + + $text = $text.Insert($match.Index + $match.Length, $injection) + $changed = $true + Write-Host 'Installed PR #134 smart discovery capture route into NativeIec61850Client.DiscoverSignalsAsync.' +} +else { + Write-Host 'Smart discovery capture route already installed.' +} + +$resetMarker = '_liveModel = null;__P0_5C_CONNECT_RESET__' +if ($text.IndexOf('__P0_5C_CONNECT_RESET__', [System.StringComparison]::Ordinal) -lt 0) { + $resetAnchor = " _lastDiscovery = null;`r`n _liveModel = null;" + $anchorIndex = $text.IndexOf($resetAnchor, [System.StringComparison]::Ordinal) + if ($anchorIndex -lt 0) { + $resetAnchor = " _lastDiscovery = null;`n _liveModel = null;" + $anchorIndex = $text.IndexOf($resetAnchor, [System.StringComparison]::Ordinal) + } + if ($anchorIndex -lt 0) { + throw 'Could not locate ConnectAsync discovery reset anchor for smart authority invalidation.' + } + if ($text.IndexOf($resetAnchor, $anchorIndex + $resetAnchor.Length, [System.StringComparison]::Ordinal) -ge 0) { + throw 'ConnectAsync discovery reset anchor is not unique; refusing ambiguous smart authority patch.' + } + + $lineBreak = if ($resetAnchor.Contains("`r`n")) { "`r`n" } else { "`n" } + $resetInjection = $resetAnchor + $lineBreak + ' ResetSmartDiscoveryAuthority(); // __P0_5C_CONNECT_RESET__' + $text = $text.Remove($anchorIndex, $resetAnchor.Length).Insert($anchorIndex, $resetInjection) + $changed = $true + Write-Host 'Installed P0-5c association-generation reset into ConnectAsync.' +} +else { + Write-Host 'P0-5c ConnectAsync association reset already installed.' +} + +$disposeMarker = '__P0_5C_DISPOSE_RESET__' +if ($text.IndexOf($disposeMarker, [System.StringComparison]::Ordinal) -lt 0) { + $disposePattern = '(public async ValueTask DisposeAsync\(\)\s*\{)' + $disposeMatch = [regex]::Match($text, $disposePattern) + if (-not $disposeMatch.Success -or [regex]::Matches($text, $disposePattern).Count -ne 1) { + throw 'Could not locate a unique NativeIec61850Client.DisposeAsync entrypoint for association invalidation.' + } + + $disposeInjection = @' + + ResetSmartDiscoveryAuthority(); // __P0_5C_DISPOSE_RESET__ +'@ + $text = $text.Insert($disposeMatch.Index + $disposeMatch.Length, $disposeInjection) + $changed = $true + Write-Host 'Installed P0-5c association-generation reset into DisposeAsync.' +} +else { + Write-Host 'P0-5c DisposeAsync association reset already installed.' +} + +$controlAuthorityMarker = '_lastDiscovery.Snapshot.DomainVariables' +if ($text.IndexOf($controlAuthorityMarker, [System.StringComparison]::Ordinal) -lt 0) { + $controlPattern = '\(\) => service\.OpenAsync\(_session, signal\.ObjectReference, cancellationToken\)' + $controlMatches = [regex]::Matches($text, $controlPattern) + if ($controlMatches.Count -ne 1) { + throw "Expected exactly one control OpenAsync discovery call, found $($controlMatches.Count); refusing ambiguous authority patch." + } + + $controlReplacement = @' +() => _lastDiscovery != null + ? service.OpenAsync(_session, signal.ObjectReference, _lastDiscovery.Snapshot.DomainVariables, cancellationToken) + : service.OpenAsync(_session, signal.ObjectReference, cancellationToken) +'@ + $text = [regex]::Replace($text, $controlPattern, $controlReplacement, 1) + $changed = $true + Write-Host 'Installed authoritative smart domain inventory reuse into control inspection.' +} +else { + Write-Host 'Control inspection already reuses authoritative smart domain inventory.' +} + +if ($changed) { + [System.IO.File]::WriteAllText($sourcePath, $text, (New-Object System.Text.UTF8Encoding($false))) +} diff --git a/scripts/finalize-smart-discovery-repeat-run-stability.ps1 b/scripts/finalize-smart-discovery-repeat-run-stability.ps1 new file mode 100644 index 000000000..0d11b22a0 --- /dev/null +++ b/scripts/finalize-smart-discovery-repeat-run-stability.ps1 @@ -0,0 +1,210 @@ +param( + [Parameter(Mandatory=$true)][string]$GoldenLockPath, + [Parameter(Mandatory=$true)][string]$RepeatTargetPath, + [Parameter(Mandatory=$true)][string[]]$RunBundlePaths, + [Parameter(Mandatory=$true)][string]$OutputPath, + [switch]$AllowFixtureEvidence, + [switch]$NoFailExit +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +function Get-Int($Object, [string]$Name) { + if ($null -eq $Object) { return 0 } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return 0 } + return [int]$property.Value +} + +function Get-Long($Object, [string]$Name) { + if ($null -eq $Object) { return [long]0 } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return [long]0 } + return [long]$property.Value +} + +function Get-CanonicalServiceMap($Object) { + $map = [ordered]@{} + if ($null -ne $Object) { + foreach ($property in @($Object.PSObject.Properties | Sort-Object Name)) { $map[$property.Name] = [int]$property.Value } + } + return ($map | ConvertTo-Json -Compress) +} + +function Get-CanonicalObject($Object, [string[]]$Names) { + $map = [ordered]@{} + foreach ($name in $Names) { + $property = $Object.PSObject.Properties[$name] + $map[$name] = if ($null -eq $property) { $null } else { $property.Value } + } + return ($map | ConvertTo-Json -Compress -Depth 6) +} + +$goldenLockFile = Resolve-File $GoldenLockPath 'P0-5e golden lock' +$repeatTargetFile = Resolve-File $RepeatTargetPath 'P0-5f repeat target' +$lock = Get-Content -LiteralPath $goldenLockFile -Raw | ConvertFrom-Json +$target = Get-Content -LiteralPath $repeatTargetFile -Raw | ConvertFrom-Json +$failures = [System.Collections.Generic.List[string]]::new() + +if ($lock.Phase -ne 'P0-5e' -or $lock.Status -ne 'locked') { $failures.Add('P0-5f requires an active P0-5e golden lock.') } +if (-not $AllowFixtureEvidence -and -not [bool]$lock.GoldenSource.RawCaptureReverified) { $failures.Add('Production finalization rejects a fixture/non-reverified P0-5e lock.') } +if ($target.Phase -ne 'P0-5f') { $failures.Add('Repeat-run target is not P0-5f authority.') } +if ($target.DeviceIdentity -ne $lock.DeviceIdentity) { $failures.Add('Repeat target device differs from the golden lock.') } +if ($target.EngineCommit -ne $lock.GoldenSource.EngineCommit) { $failures.Add('Repeat target engine differs from the golden lock.') } + +$minimumRuns = [int]$target.MinimumIndependentAssociations +if ($minimumRuns -lt 3) { $failures.Add('P0-5f target must require at least three independent associations.') } +if ($RunBundlePaths.Count -lt $minimumRuns) { $failures.Add("Insufficient independent repeat runs: $($RunBundlePaths.Count) < $minimumRuns.") } + +$goldenHash = (Get-FileHash -LiteralPath $goldenLockFile -Algorithm SHA256).Hash.ToLowerInvariant() +$repeatTargetHash = (Get-FileHash -LiteralPath $repeatTargetFile -Algorithm SHA256).Hash.ToLowerInvariant() +$targetHash = [string]$lock.GoldenSource.SemanticTargetSha256 +$manifestHash = [string]$lock.GoldenSource.BuildManifestSha256 +$expectedArsas = [string]$lock.GoldenSource.ArsasCommit +$expectedEngine = [string]$lock.GoldenSource.EngineCommit +$maxPeak = [int]$lock.HardRequestBudget.MaxPeakOutstandingRequests +$bundles = @() + +foreach ($path in $RunBundlePaths) { + $file = Resolve-File $path 'P0-5f run bundle' + $bundle = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json + $bundles += [pscustomobject]@{ Path = $file; Hash = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash.ToLowerInvariant(); Evidence = $bundle } + + if ($bundle.Phase -ne 'P0-5f-run-bundle' -or $bundle.Verdict -ne 'PASS') { $failures.Add("Run bundle '$file' is not PASS P0-5f evidence.") } + if (-not $AllowFixtureEvidence -and [bool]$bundle.FixtureEvidence) { $failures.Add("Production finalization rejects fixture run bundle '$file'.") } + if ($bundle.DeviceIdentity -ne $lock.DeviceIdentity) { $failures.Add("Run bundle '$file' device identity mismatch.") } + if ($bundle.ArsasCommit -ne $expectedArsas) { $failures.Add("Run bundle '$file' ARSAS commit drift.") } + if ($bundle.EngineCommit -ne $expectedEngine) { $failures.Add("Run bundle '$file' engine commit drift.") } + if ($bundle.GoldenLockSha256 -ne $goldenHash) { $failures.Add("Run bundle '$file' is bound to a different golden lock.") } + if ($bundle.BuildManifestSha256 -ne $manifestHash) { $failures.Add("Run bundle '$file' build manifest drift.") } + if ($bundle.SemanticTargetSha256 -ne $targetHash) { $failures.Add("Run bundle '$file' semantic target drift.") } + if ((Get-Int $bundle.Wire 'DuplicateSemanticRequests') -ne 0 -or + (Get-Int $bundle.Wire 'DuplicateGetNameListRequests') -ne 0 -or + (Get-Int $bundle.Wire 'DuplicateGvaRequests') -ne 0 -or + [bool]$bundle.Wire.SecondGetNameListSweepDetected) { + $failures.Add("Run bundle '$file' contains duplicate/second-sweep wire work.") + } + if ((Get-Int $bundle.Runtime.SmartDiscoveryKpi 'DuplicateRequests') -ne 0 -or -not [bool]$bundle.Runtime.SmartDiscoveryKpi.WireAccountingComplete) { + $failures.Add("Run bundle '$file' engine KPI duplicate/accounting invariant failed.") + } + if ((Get-Int $bundle.Wire 'ConfirmedRequests') -ne (Get-Int $bundle.Runtime.SmartDiscoveryKpi 'TotalRequests')) { + $failures.Add("Run bundle '$file' wire/engine request accounting mismatch.") + } + if ((Get-Long $bundle.Runtime 'AssociationGeneration') -le 0) { + $failures.Add("Run bundle '$file' has an invalid association generation.") + } + if ((Get-Int $bundle.Wire 'PeakOutstandingRequests') -gt $maxPeak) { $failures.Add("Run bundle '$file' exceeded golden peak outstanding max $maxPeak.") } +} + +if ($bundles.Count -gt 0) { + $first = $bundles[0].Evidence + $expectedRequests = Get-Int $first.Wire 'ConfirmedRequests' + $expectedServices = Get-CanonicalServiceMap $first.Wire.ServiceCounts + $expectedKpiSignature = [string]$first.Runtime.SmartDiscoveryKpi.DeterministicSignature + $expectedDirectorySignature = [string]$first.Runtime.DirectoryModelSignature + $expectedProjectionSignature = [string]$first.Runtime.ProjectionSignature + $typeFields = @( + 'DirectoryPoints','SuppliedLogicalNodeCandidates','SuppressedNonLiveLogicalNodeCandidates', + 'LogicalNodeRequests','PointsCoveredByLogicalNode','DataObjectRequests','PointsCoveredByDataObject', + 'ExactLeafRequests','SuppressedExactRepeatRequests','PointsCoveredByExactLeaf', + 'RemainingUnresolvedPoints','TotalPlannedRequests') + $modelFields = @('LogicalDevices','LogicalNodes','SemanticPoints','ProjectedSignals','DataSets','ReportControls','BufferedReportControls','UnbufferedReportControls') + $expectedTypeBudget = Get-CanonicalObject $first.Runtime.TypeProbeBudget $typeFields + $expectedModel = Get-CanonicalObject $first.Runtime.Model $modelFields + + foreach ($entry in $bundles) { + $bundle = $entry.Evidence + if ((Get-Int $bundle.Wire 'ConfirmedRequests') -ne $expectedRequests) { $failures.Add("Confirmed-request drift in '$($entry.Path)'.") } + if ((Get-CanonicalServiceMap $bundle.Wire.ServiceCounts) -ne $expectedServices) { $failures.Add("MMS service-mix drift in '$($entry.Path)'.") } + if ([string]$bundle.Runtime.SmartDiscoveryKpi.DeterministicSignature -ne $expectedKpiSignature) { $failures.Add("Engine KPI deterministic-signature drift in '$($entry.Path)'.") } + if ([string]$bundle.Runtime.DirectoryModelSignature -ne $expectedDirectorySignature) { $failures.Add("Directory model signature drift in '$($entry.Path)'.") } + if ([string]$bundle.Runtime.ProjectionSignature -ne $expectedProjectionSignature) { $failures.Add("ARSAS signal projection signature drift in '$($entry.Path)'.") } + if ((Get-CanonicalObject $bundle.Runtime.TypeProbeBudget $typeFields) -ne $expectedTypeBudget) { $failures.Add("Hierarchy type-probe budget drift in '$($entry.Path)'.") } + if ((Get-CanonicalObject $bundle.Runtime.Model $modelFields) -ne $expectedModel) { $failures.Add("Discovered model-count drift in '$($entry.Path)'.") } + } + + $semantic = $target.SemanticTarget + if ((Get-Int $first.Runtime.Model 'LogicalDevices') -ne [int]$semantic.LogicalDevices) { $failures.Add('Repeat model LogicalDevice count differs from same-IED semantic target.') } + if ((Get-Int $first.Runtime.Model 'LogicalNodes') -ne [int]$semantic.LogicalNodes) { $failures.Add('Repeat model LogicalNode count differs from same-IED semantic target.') } + if ((Get-Int $first.Runtime.Model 'SemanticPoints') -ne [int]$semantic.SemanticLeaves) { $failures.Add('Repeat model semantic point count differs from same-IED semantic target.') } + if ((Get-Int $first.Runtime.Model 'DataSets') -ne [int]$semantic.DataSets) { $failures.Add('Repeat model DataSet count differs from same-IED semantic target.') } + if ((Get-Int $first.Runtime.Model 'ReportControls') -ne [int]$semantic.RuntimeReportControlInstances) { $failures.Add('Repeat runtime ReportControl count differs from same-IED semantic target.') } +} + +$distinctCaptureHashes = @($bundles | ForEach-Object { [string]$_.Evidence.Provenance.CaptureSha256 } | Sort-Object -Unique) +$distinctRuntimeHashes = @($bundles | ForEach-Object { [string]$_.Evidence.Provenance.RuntimeEvidenceSha256 } | Sort-Object -Unique) +$associationGenerations = @($bundles | ForEach-Object { Get-Long $_.Evidence.Runtime 'AssociationGeneration' }) +$distinctAssociationGenerations = @($associationGenerations | Sort-Object -Unique) +if ($bundles.Count -gt 0 -and $distinctCaptureHashes.Count -ne $bundles.Count) { $failures.Add('Repeat set contains reused raw capture evidence; runs are not independent.') } +if ($bundles.Count -gt 0 -and $distinctRuntimeHashes.Count -ne $bundles.Count) { $failures.Add('Repeat set contains reused runtime evidence; runs are not independent.') } +if ($bundles.Count -gt 0 -and $distinctAssociationGenerations.Count -ne $bundles.Count) { $failures.Add('Repeat set reuses an association generation; every run must reconnect and use a fresh association generation.') } + +$peaks = @($bundles | ForEach-Object { Get-Int $_.Evidence.Wire 'PeakOutstandingRequests' }) +$result = [ordered]@{ + SchemaVersion = 2 + Phase = 'P0-5f' + Verdict = if ($failures.Count -eq 0) { 'PASS' } else { 'FAIL' } + DeviceIdentity = $lock.DeviceIdentity + RequiredIndependentAssociations = $minimumRuns + ObservedRunBundles = $bundles.Count + GoldenLockSha256 = $goldenHash + RepeatTargetSha256 = $repeatTargetHash + ArsasCommit = $expectedArsas + EngineCommit = $expectedEngine + BuildManifestSha256 = $manifestHash + SemanticTargetSha256 = $targetHash + Consensus = if ($bundles.Count -gt 0) { + [ordered]@{ + ConfirmedRequests = Get-Int $bundles[0].Evidence.Wire 'ConfirmedRequests' + ServiceCounts = $bundles[0].Evidence.Wire.ServiceCounts + EngineKpiDeterministicSignature = [string]$bundles[0].Evidence.Runtime.SmartDiscoveryKpi.DeterministicSignature + DirectoryModelSignature = [string]$bundles[0].Evidence.Runtime.DirectoryModelSignature + ProjectionSignature = [string]$bundles[0].Evidence.Runtime.ProjectionSignature + TypeProbeBudget = $bundles[0].Evidence.Runtime.TypeProbeBudget + Model = $bundles[0].Evidence.Runtime.Model + AssociationGenerations = @($associationGenerations) + PeakOutstandingMin = if ($peaks.Count -gt 0) { ($peaks | Measure-Object -Minimum).Minimum } else { $null } + PeakOutstandingMax = if ($peaks.Count -gt 0) { ($peaks | Measure-Object -Maximum).Maximum } else { $null } + GoldenPeakOutstandingMax = $maxPeak + } + } else { $null } + Runs = @($bundles | ForEach-Object { + [ordered]@{ + BundlePath = $_.Path + BundleSha256 = $_.Hash + CaptureSha256 = $_.Evidence.Provenance.CaptureSha256 + ProofSha256 = $_.Evidence.Provenance.ProofSha256 + RuntimeEvidenceSha256 = $_.Evidence.Provenance.RuntimeEvidenceSha256 + AssociationGeneration = $_.Evidence.Runtime.AssociationGeneration + PeakOutstandingRequests = $_.Evidence.Wire.PeakOutstandingRequests + } + }) + AcceptanceFailures = @($failures) +} + +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$result | ConvertTo-Json -Depth 18 | Set-Content -LiteralPath $OutputPath -Encoding utf8 +Write-Host "P0-5f physical golden repeat-run finalization: $($result.Verdict)" +Write-Host " runs: $($bundles.Count) / required $minimumRuns" +if ($result.Consensus) { + Write-Host " requests: $($result.Consensus.ConfirmedRequests)" + Write-Host " KPI signature: $($result.Consensus.EngineKpiDeterministicSignature)" + Write-Host " directory signature: $($result.Consensus.DirectoryModelSignature)" + Write-Host " projection signature: $($result.Consensus.ProjectionSignature)" + Write-Host " association generations: $($result.Consensus.AssociationGenerations -join ', ')" + Write-Host " peak outstanding range: $($result.Consensus.PeakOutstandingMin)-$($result.Consensus.PeakOutstandingMax) / max $maxPeak" +} +Write-Host " finalization JSON: $OutputPath" + +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Error $failure -ErrorAction Continue } + if (-not $NoFailExit) { exit 1 } +} diff --git a/scripts/new-smart-discovery-golden-lock.ps1 b/scripts/new-smart-discovery-golden-lock.ps1 new file mode 100644 index 000000000..11d7970a9 --- /dev/null +++ b/scripts/new-smart-discovery-golden-lock.ps1 @@ -0,0 +1,220 @@ +param( + [Parameter(Mandatory=$true)][string]$ProofJson, + [Parameter(Mandatory=$true)][string]$CapturePath, + [Parameter(Mandatory=$true)][string]$DeviceIdentity, + [Parameter(Mandatory=$true)][string]$ArsasCommit, + [Parameter(Mandatory=$true)][string]$EngineCommit, + [Parameter(Mandatory=$true)][string]$OutputPath, + [Parameter(Mandatory=$true)][string]$TargetPath, + [Parameter(Mandatory=$true)][string]$BuildManifestPath, + [switch]$AllowFixtureEvidence +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { + throw "$Label is not a file: $Path" + } + return $resolved.Path +} + +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} + +function Get-IntProperty($Object, [string]$Name) { + if ($null -eq $Object) { return 0 } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return 0 } + return [int]$property.Value +} + +function Get-ServiceMap($Object) { + $map = [ordered]@{} + if ($null -eq $Object) { return $map } + foreach ($property in @($Object.PSObject.Properties | Sort-Object Name)) { + $map[$property.Name] = [int]$property.Value + } + return $map +} + +function Assert-EquivalentWireProof($Expected, $Observed) { + foreach ($name in @( + 'ConfirmedRequests', + 'DuplicateSemanticRequests', + 'DuplicateGetNameListRequests', + 'DuplicateGvaRequests', + 'PeakOutstandingRequests', + 'InvokeIdReuseWhileOutstanding', + 'OrphanResponses', + 'UnansweredRequestsAtCaptureEnd')) { + $a = Get-IntProperty $Expected $name + $b = Get-IntProperty $Observed $name + if ($a -ne $b) { throw "Proof/capture mismatch for ${name}: supplied=$a reverified=$b." } + } + + if ([bool]$Expected.SecondGetNameListSweepDetected -ne [bool]$Observed.SecondGetNameListSweepDetected) { + throw 'Proof/capture mismatch for second GetNameList sweep evidence.' + } + if ([string]$Expected.ClientIp -ne [string]$Observed.ClientIp -or [string]$Expected.ServerIp -ne [string]$Observed.ServerIp) { + throw 'Proof/capture endpoint identity mismatch.' + } + + $expectedServices = Get-ServiceMap $Expected.ServiceCounts + $observedServices = Get-ServiceMap $Observed.ServiceCounts + $allNames = @($expectedServices.Keys + $observedServices.Keys | Sort-Object -Unique) + foreach ($name in $allNames) { + $a = if ($expectedServices.Contains($name)) { [int]$expectedServices[$name] } else { 0 } + $b = if ($observedServices.Contains($name)) { [int]$observedServices[$name] } else { 0 } + if ($a -ne $b) { throw "Proof/capture service mismatch for '$name': supplied=$a reverified=$b." } + } +} + +$proofPath = Resolve-File $ProofJson 'P0-5d proof JSON' +$capture = Resolve-File $CapturePath 'physical capture' +$targetFile = Resolve-File $TargetPath 'same-IED target' +$manifestFile = Resolve-File $BuildManifestPath 'field build manifest' +Assert-Commit $ArsasCommit 'ARSAS commit' +Assert-Commit $EngineCommit 'Engine commit' +if ([string]::IsNullOrWhiteSpace($DeviceIdentity)) { throw 'DeviceIdentity must be non-empty.' } + +$proof = Get-Content -LiteralPath $proofPath -Raw | ConvertFrom-Json +if ($proof.Phase -ne 'P0-5d' -or $proof.Verdict -ne 'PASS') { + throw 'Golden budget can only be created from a P0-5d PASS proof.' +} +$actual = $proof.ArsasCapture +if ($null -eq $actual) { throw 'P0-5d proof does not contain ArsasCapture evidence.' } +if (@($actual.RequestTcpStreams).Count -ne 1) { throw 'Golden capture must contain exactly one MMS request TCP stream.' } +if ((Get-IntProperty $actual 'DuplicateSemanticRequests') -ne 0 -or + (Get-IntProperty $actual 'DuplicateGetNameListRequests') -ne 0 -or + (Get-IntProperty $actual 'DuplicateGvaRequests') -ne 0 -or + [bool]$actual.SecondGetNameListSweepDetected -or + (Get-IntProperty $actual 'InvokeIdReuseWhileOutstanding') -ne 0 -or + (Get-IntProperty $actual 'OrphanResponses') -ne 0 -or + (Get-IntProperty $actual 'UnansweredRequestsAtCaptureEnd') -ne 0) { + throw 'Golden capture contains duplicate, second-sweep, invoke-ID, orphan, or incomplete-capture evidence.' +} + +$extension = [IO.Path]::GetExtension($capture).ToLowerInvariant() +if (-not $AllowFixtureEvidence -and $extension -notin @('.pcap', '.pcapng')) { + throw 'Production golden lock requires a raw .pcap or .pcapng capture.' +} + +# Production default: independently decode the raw capture again before locking it. +# This prevents a PASS proof from capture A being paired with unrelated capture B. +if (-not $AllowFixtureEvidence) { + $wireVerifier = Join-Path $PSScriptRoot 'verify-smart-discovery-pcap.ps1' + if (-not (Test-Path -LiteralPath $wireVerifier -PathType Leaf)) { + throw 'P0-5d verifier is missing beside the P0-5e lock writer.' + } + $temporaryProof = Join-Path ([IO.Path]::GetTempPath()) ("p0-5e-reverify-{0}.json" -f [Guid]::NewGuid().ToString('N')) + try { + & $wireVerifier -PcapPath $capture -OutputJson $temporaryProof -NoFailExit + $reverified = Get-Content -LiteralPath $temporaryProof -Raw | ConvertFrom-Json + if ($reverified.Phase -ne 'P0-5d' -or $reverified.Verdict -ne 'PASS') { + throw 'Raw capture does not independently reproduce a P0-5d PASS.' + } + Assert-EquivalentWireProof $actual $reverified.ArsasCapture + } + finally { + Remove-Item -LiteralPath $temporaryProof -Force -ErrorAction SilentlyContinue + } +} + +$manifestText = Get-Content -LiteralPath $manifestFile -Raw +$arsasMatch = [regex]::Match($manifestText, '(?im)^ARSAS commit:\s*([0-9a-f]{40})\s*$') +$engineMatch = [regex]::Match($manifestText, '(?im)^ARIEC61850 commit:\s*([0-9a-f]{40})\s*$') +if (-not $arsasMatch.Success -or -not $engineMatch.Success) { + throw 'Build manifest does not contain exact ARSAS and ARIEC61850 commit identities.' +} +if ($arsasMatch.Groups[1].Value -ne $ArsasCommit.ToLowerInvariant()) { + throw 'ARSAS commit argument does not match the field build manifest.' +} +if ($engineMatch.Groups[1].Value -ne $EngineCommit.ToLowerInvariant()) { + throw 'Engine commit argument does not match the field build manifest.' +} + +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json +if ($target.Phase -ne 'P0-5e') { throw 'Same-IED target is not a P0-5e target.' } +if ($target.DeviceIdentity -ne $DeviceIdentity) { + throw "DeviceIdentity '$DeviceIdentity' does not match target '$($target.DeviceIdentity)'." +} +if ($target.EngineCommit -and $target.EngineCommit -ne $EngineCommit.ToLowerInvariant()) { + throw 'Engine commit does not match the target baseline.' +} +if ($null -eq $target.SemanticTarget) { throw 'Same-IED target does not contain SemanticTarget authority.' } + +$confirmedRequests = Get-IntProperty $actual 'ConfirmedRequests' +if ($confirmedRequests -le 0) { throw 'Golden capture must contain at least one confirmed MMS request.' } +$peakOutstanding = Get-IntProperty $actual 'PeakOutstandingRequests' +$negotiated = $null +if ($null -ne $actual.PSObject.Properties['NegotiatedMaxOutstandingCalling'] -and + $null -ne $actual.NegotiatedMaxOutstandingCalling -and + [string]$actual.NegotiatedMaxOutstandingCalling -match '^\d+$') { + $negotiated = [int]$actual.NegotiatedMaxOutstandingCalling + if ($peakOutstanding -gt $negotiated) { throw 'Golden peak outstanding exceeds the negotiated calling limit.' } +} + +$serviceBudget = Get-ServiceMap $actual.ServiceCounts +if ($serviceBudget.Count -eq 0) { throw 'Golden proof contains no MMS service budget.' } +foreach ($entry in $serviceBudget.GetEnumerator()) { + if ([int]$entry.Value -lt 0) { throw "Invalid negative service count for '$($entry.Key)'." } +} + +$captureHash = (Get-FileHash -LiteralPath $capture -Algorithm SHA256).Hash.ToLowerInvariant() +$proofHash = (Get-FileHash -LiteralPath $proofPath -Algorithm SHA256).Hash.ToLowerInvariant() +$manifestHash = (Get-FileHash -LiteralPath $manifestFile -Algorithm SHA256).Hash.ToLowerInvariant() +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() +$maxOutstanding = if ($null -ne $negotiated) { $negotiated } else { $peakOutstanding } + +$lock = [ordered]@{ + SchemaVersion = 2 + Phase = 'P0-5e' + Status = 'locked' + DeviceIdentity = $DeviceIdentity + GoldenSource = [ordered]@{ + ArsasCommit = $ArsasCommit.ToLowerInvariant() + EngineCommit = $EngineCommit.ToLowerInvariant() + CaptureFileName = [IO.Path]::GetFileName($capture) + CaptureSha256 = $captureHash + ProofFileName = [IO.Path]::GetFileName($proofPath) + ProofSha256 = $proofHash + BuildManifestFileName = [IO.Path]::GetFileName($manifestFile) + BuildManifestSha256 = $manifestHash + SemanticTargetFileName = [IO.Path]::GetFileName($targetFile) + SemanticTargetSha256 = $targetHash + ClientIp = $actual.ClientIp + ServerIp = $actual.ServerIp + RequestTcpStream = @($actual.RequestTcpStreams)[0] + ObservedPeakOutstandingRequests = $peakOutstanding + NegotiatedMaxOutstandingCalling = $negotiated + RawCaptureReverified = -not [bool]$AllowFixtureEvidence + } + HardRequestBudget = [ordered]@{ + MaxConfirmedRequests = $confirmedRequests + MaxServiceRequests = $serviceBudget + MaxDuplicateSemanticRequests = 0 + MaxDuplicateGetNameListRequests = 0 + MaxDuplicateGvaRequests = 0 + MaxInvokeIdReuseWhileOutstanding = 0 + MaxOrphanResponses = 0 + MaxUnansweredRequestsAtCaptureEnd = 0 + MaxPeakOutstandingRequests = $maxOutstanding + ForbidSecondGetNameListSweep = $true + ForbidUnexpectedServices = $true + } + SemanticTarget = $target.SemanticTarget +} + +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$lock | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputPath -Encoding utf8 +Write-Host "P0-5e golden request-budget lock written: $OutputPath" +Write-Host " capture SHA256: $captureHash" +Write-Host " build manifest SHA256: $manifestHash" +Write-Host " semantic target SHA256: $targetHash" +Write-Host " confirmed request hard max: $confirmedRequests" +Write-Host " service hard max: $($serviceBudget | ConvertTo-Json -Compress)" diff --git a/scripts/new-smart-discovery-mainline-merge-manifest.ps1 b/scripts/new-smart-discovery-mainline-merge-manifest.ps1 new file mode 100644 index 000000000..7091b021d --- /dev/null +++ b/scripts/new-smart-discovery-mainline-merge-manifest.ps1 @@ -0,0 +1,132 @@ +param( + [Parameter(Mandatory=$true)][string]$ReadinessJson, + [Parameter(Mandatory=$true)][string]$PromotionAuthorityPath, + [Parameter(Mandatory=$true)][string]$PhysicalAuthorityPath, + [Parameter(Mandatory=$true)][string]$PromotionPropsPath, + [Parameter(Mandatory=$true)][string]$TargetPath, + [Parameter(Mandatory=$true)][string]$ArsasValidatedHeadCommit, + [Parameter(Mandatory=$true)][string]$ArsasBaseCommit, + [Parameter(Mandatory=$true)][string]$EngineHeadCommit, + [Parameter(Mandatory=$true)][string]$EngineBaseCommit, + [Parameter(Mandatory=$true)][string]$OutputPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} +function Assert-Sha256([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{64}$') { throw "$Label must be a 64-character SHA-256 value." } +} +function Get-XmlChildText($Parent, [string]$Name) { + $node = @($Parent.ChildNodes | Where-Object { $_.Name -eq $Name } | Select-Object -First 1) + if ($node.Count -eq 0) { return '' } + return ([string]$node[0].InnerText).Trim() +} + +$readinessFile = Resolve-File $ReadinessJson 'P0-5g readiness JSON' +$promotionFile = Resolve-File $PromotionAuthorityPath 'P0-5g promotion authority' +$physicalFile = Resolve-File $PhysicalAuthorityPath 'P0-5f physical authority' +$propsFile = Resolve-File $PromotionPropsPath 'promotion props' +$targetFile = Resolve-File $TargetPath 'P0-5h merge target' +foreach ($entry in @( + @{ Value = $ArsasValidatedHeadCommit; Label = 'ARSAS validated head commit' }, + @{ Value = $ArsasBaseCommit; Label = 'ARSAS base commit' }, + @{ Value = $EngineHeadCommit; Label = 'engine head commit' }, + @{ Value = $EngineBaseCommit; Label = 'engine base commit' })) { Assert-Commit $entry.Value $entry.Label } + +$arsasValidatedHead = $ArsasValidatedHeadCommit.ToLowerInvariant() +$arsasBase = $ArsasBaseCommit.ToLowerInvariant() +$engineHead = $EngineHeadCommit.ToLowerInvariant() +$engineBase = $EngineBaseCommit.ToLowerInvariant() +$readiness = Get-Content -LiteralPath $readinessFile -Raw | ConvertFrom-Json +$promotion = Get-Content -LiteralPath $promotionFile -Raw | ConvertFrom-Json +$physical = Get-Content -LiteralPath $physicalFile -Raw | ConvertFrom-Json +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json + +if ($readiness.Phase -ne 'P0-5g' -or $readiness.Verdict -ne 'READY_FOR_REVIEW') { throw 'P0-5h merge execution requires P0-5g READY_FOR_REVIEW.' } +if (@($readiness.Blockers).Count -ne 0 -or -not [bool]$readiness.ProductionSwitchEnabled) { throw 'P0-5h refuses readiness evidence with blockers or a disabled production switch.' } +if ($promotion.Phase -ne 'P0-5g-authority' -or $promotion.Status -ne 'production-promoted') { throw 'P0-5h requires a production-promoted P0-5g authority.' } +if ($physical.Phase -ne 'P0-5f-authority' -or $physical.Status -ne 'physical-finalized') { throw 'P0-5h requires physical-finalized P0-5f authority.' } +if ($target.Phase -ne 'P0-5h' -or [int]$target.ArsasPullRequest -ne 324 -or [int]$target.EnginePullRequest -ne 134) { throw 'P0-5h target repository/PR authority is invalid.' } +if ([string]$target.MergeMethod -ne 'merge' -or @($target.MergeOrder) -join ',' -ne 'engine,arsas') { throw 'P0-5h target must require merge-commit method and engine-first order.' } +if (([string]$readiness.ArsasHeadCommit).ToLowerInvariant() -ne $arsasValidatedHead -or ([string]$promotion.ArsasValidatedHeadCommit).ToLowerInvariant() -ne $arsasValidatedHead) { throw 'P0-5h ARSAS validated head differs from P0-5g authority.' } +if (([string]$readiness.EngineHeadCommit).ToLowerInvariant() -ne $engineHead -or ([string]$promotion.EngineHeadCommit).ToLowerInvariant() -ne $engineHead) { throw 'P0-5h engine head differs from P0-5g authority.' } +if ([string]$readiness.EngineHeadCiConclusion -ne 'success') { throw 'P0-5h requires green engine-head CI evidence.' } + +$physicalHash = (Get-FileHash -LiteralPath $physicalFile -Algorithm SHA256).Hash.ToLowerInvariant() +$promotionHash = (Get-FileHash -LiteralPath $promotionFile -Algorithm SHA256).Hash.ToLowerInvariant() +$readinessHash = (Get-FileHash -LiteralPath $readinessFile -Algorithm SHA256).Hash.ToLowerInvariant() +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() +$propsHash = (Get-FileHash -LiteralPath $propsFile -Algorithm SHA256).Hash.ToLowerInvariant() +if (([string]$promotion.PhysicalAuthoritySha256).ToLowerInvariant() -ne $physicalHash) { throw 'P0-5g promotion authority is bound to a different physical authority.' } + +[xml]$props = Get-Content -LiteralPath $propsFile -Raw +$group = $props.Project.PropertyGroup +$promoted = (Get-XmlChildText $group 'SmartDiscoveryProductionPromoted').ToLowerInvariant() +$propsAuthority = (Get-XmlChildText $group 'SmartDiscoveryPromotionAuthoritySha256').ToLowerInvariant() +$propsEngineHead = (Get-XmlChildText $group 'SmartDiscoveryValidatedEngineHead').ToLowerInvariant() +if ($promoted -ne 'true') { throw 'P0-5h production switch is not enabled.' } +Assert-Sha256 $propsAuthority 'promotion props authority SHA-256' +if ($propsAuthority -ne $promotionHash) { throw 'Promotion props are bound to a different P0-5g authority.' } +if ($propsEngineHead -ne $engineHead) { throw 'Promotion props are bound to a different validated engine head.' } + +$manifest = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5h-merge-manifest' + Status = 'authorized-for-ordered-merge' + MergeMethod = 'merge' + MergeOrder = @('engine','arsas') + Arsas = [ordered]@{ + Repository = 'masarray/arsas' + PullRequest = 324 + ValidatedHeadSha = $arsasValidatedHead + BaseShaAtAuthorization = $arsasBase + LiveMergeHeadSha = $null + AllowedPostAuthorizationPaths = @('evidence/smart-discovery-mainline-merge-manifest.json') + } + Engine = [ordered]@{ + Repository = 'masarray/ARIEC61850' + PullRequest = 134 + ExpectedHeadSha = $engineHead + BaseShaAtAuthorization = $engineBase + } + Provenance = [ordered]@{ + PhysicalAuthoritySha256 = $physicalHash + PromotionAuthoritySha256 = $promotionHash + P05gReadinessSha256 = $readinessHash + P05hTargetSha256 = $targetHash + PromotionPropsSha256 = $propsHash + } + RequiredExecutionChecks = @( + 'both-prs-open-and-mergeable', + 'no-unresolved-review-threads', + 'base-sha-unchanged', + 'engine-expected-head-sha-match', + 'arsas-live-head-resolved-at-execution', + 'arsas-post-authorization-diff=merge-manifest-only', + 'merge-method=merge', + 'engine-merge-first', + 'engine-merge-success-before-arsas-merge', + 'post-merge-production-verification' + ) +} + +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$manifest | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputPath -Encoding utf8 +$manifestHash = (Get-FileHash -LiteralPath $OutputPath -Algorithm SHA256).Hash.ToLowerInvariant() +Write-Host 'P0-5h merge execution manifest: AUTHORIZED' +Write-Host ' merge method: merge' +Write-Host " engine expected head: $engineHead" +Write-Host " ARSAS validated head: $arsasValidatedHead" +Write-Host ' ARSAS live merge head: resolve immediately before merge after manifest-only diff check' +Write-Host " manifest SHA256: $manifestHash" +Write-Host " output: $OutputPath" diff --git a/scripts/new-smart-discovery-production-promotion-authority.ps1 b/scripts/new-smart-discovery-production-promotion-authority.ps1 new file mode 100644 index 000000000..6317d3a60 --- /dev/null +++ b/scripts/new-smart-discovery-production-promotion-authority.ps1 @@ -0,0 +1,154 @@ +param( + [Parameter(Mandatory=$true)][string]$ReadinessJson, + [Parameter(Mandatory=$true)][string]$TargetPath, + [Parameter(Mandatory=$true)][string]$PhysicalAuthorityPath, + [Parameter(Mandatory=$true)][string]$EngineLockPath, + [Parameter(Mandatory=$true)][string]$OutputAuthorityPath, + [Parameter(Mandatory=$true)][string]$OutputPropsPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +function Assert-Sha256([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{64}$') { throw "$Label must be a 64-character SHA-256 value." } +} + +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} + +function Assert-PhysicalAuthorityProvenance($Physical) { + if ([int]$Physical.SchemaVersion -lt 1 -or $Physical.Phase -ne 'P0-5f-authority' -or $Physical.Status -ne 'physical-finalized') { + throw 'Production promotion requires physical-finalized P0-5f authority.' + } + Assert-Commit ([string]$Physical.ArsasCommit) 'P0-5f ARSAS commit' + Assert-Commit ([string]$Physical.EngineCommit) 'P0-5f engine commit' + Assert-Sha256 ([string]$Physical.GoldenLockSha256) 'P0-5f golden lock SHA-256' + Assert-Sha256 ([string]$Physical.RepeatTargetSha256) 'P0-5f repeat target SHA-256' + Assert-Sha256 ([string]$Physical.FinalizationSha256) 'P0-5f finalization SHA-256' + + $count = [int]$Physical.IndependentAssociations + if ($count -lt 3) { throw 'P0-5f physical authority must contain at least three independent associations.' } + + $generations = @($Physical.AssociationGenerations) + $bundleHashes = @($Physical.RunBundleSha256) + $captureHashes = @($Physical.CaptureSha256) + $runtimeHashes = @($Physical.RuntimeEvidenceSha256) + foreach ($entry in @( + @{ Label = 'association generations'; Values = $generations }, + @{ Label = 'run bundle hashes'; Values = $bundleHashes }, + @{ Label = 'capture hashes'; Values = $captureHashes }, + @{ Label = 'runtime evidence hashes'; Values = $runtimeHashes })) { + if ($entry.Values.Count -ne $count) { throw "P0-5f physical authority $($entry.Label) count does not match IndependentAssociations." } + if (@($entry.Values | Sort-Object -Unique).Count -ne $count) { throw "P0-5f physical authority contains reused $($entry.Label)." } + } + foreach ($generation in $generations) { + if ([long]$generation -le 0) { throw 'P0-5f physical authority contains an invalid association generation.' } + } + foreach ($hash in @($bundleHashes + $captureHashes + $runtimeHashes)) { + Assert-Sha256 ([string]$hash) 'P0-5f evidence SHA-256' + } + if ($null -eq $Physical.Consensus) { throw 'P0-5f physical authority is missing consensus evidence.' } +} + +$readinessFile = Resolve-File $ReadinessJson 'P0-5g readiness JSON' +$targetFile = Resolve-File $TargetPath 'P0-5g promotion target' +$physicalFile = Resolve-File $PhysicalAuthorityPath 'P0-5f physical authority' +$engineLockFile = Resolve-File $EngineLockPath 'ARIEC61850 engine lock' + +$readiness = Get-Content -LiteralPath $readinessFile -Raw | ConvertFrom-Json +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json +$physical = Get-Content -LiteralPath $physicalFile -Raw | ConvertFrom-Json +$engineLock = Get-Content -LiteralPath $engineLockFile -Raw | ConvertFrom-Json + +if ($readiness.Phase -ne 'P0-5g' -or $readiness.Verdict -ne 'READY_TO_PROMOTE') { + throw 'Production promotion requires a P0-5g READY_TO_PROMOTE readiness proof.' +} +if ([bool]$readiness.ProductionSwitchEnabled) { + throw 'Production switch was already enabled before promotion authority creation.' +} +if (@($readiness.Blockers).Count -ne 0) { + throw 'Production promotion refuses a readiness proof with blockers.' +} +if (-not [bool]$readiness.EngineHeadIsEvidenceCompatibleDescendant) { + throw 'Engine PR head is not evidence-compatible with the physical discovery baseline.' +} +if ([string]$readiness.EngineHeadCiConclusion -ne 'success') { + throw 'Engine PR head CI is not green.' +} +Assert-PhysicalAuthorityProvenance $physical +if ($target.Phase -ne 'P0-5g') { throw 'Promotion target is not P0-5g authority.' } + +$baseline = ([string]$target.EvidenceEngineBaselineCommit).ToLowerInvariant() +if (([string]$physical.EngineCommit).ToLowerInvariant() -ne $baseline) { + throw 'Physical P0-5f authority engine commit differs from the P0-5g evidence baseline.' +} +if (([string]$engineLock.commit).ToLowerInvariant() -ne $baseline) { + throw 'ARSAS engine lock differs from the P0-5g evidence baseline.' +} +Assert-Commit ([string]$readiness.ArsasHeadCommit) 'Readiness ARSAS head commit' +Assert-Commit ([string]$readiness.EngineHeadCommit) 'Readiness engine head commit' + +$readinessHash = (Get-FileHash -LiteralPath $readinessFile -Algorithm SHA256).Hash.ToLowerInvariant() +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() +$physicalHash = (Get-FileHash -LiteralPath $physicalFile -Algorithm SHA256).Hash.ToLowerInvariant() +$engineLockHash = (Get-FileHash -LiteralPath $engineLockFile -Algorithm SHA256).Hash.ToLowerInvariant() + +$authority = [ordered]@{ + SchemaVersion = 2 + Phase = 'P0-5g-authority' + Status = 'production-promoted' + DeviceIdentity = [string]$physical.DeviceIdentity + PhysicalAuthorityFileName = [IO.Path]::GetFileName($physicalFile) + PhysicalAuthoritySha256 = $physicalHash + ReadinessFileName = [IO.Path]::GetFileName($readinessFile) + ReadinessSha256 = $readinessHash + PromotionTargetSha256 = $targetHash + EngineLockSha256 = $engineLockHash + EvidenceEngineBaselineCommit = $baseline + EngineHeadCommit = ([string]$readiness.EngineHeadCommit).ToLowerInvariant() + EngineHeadCiConclusion = 'success' + ArsasValidatedHeadCommit = ([string]$readiness.ArsasHeadCommit).ToLowerInvariant() + EngineHeadEvidenceCompatible = $true + DiscoveryCriticalChanges = @($readiness.DiscoveryCriticalChanges) + IndependentPhysicalAssociations = [int]$physical.IndependentAssociations + GoldenConsensus = $physical.Consensus + P05fGoldenLockSha256 = ([string]$physical.GoldenLockSha256).ToLowerInvariant() + P05fRepeatTargetSha256 = ([string]$physical.RepeatTargetSha256).ToLowerInvariant() + P05fFinalizationSha256 = ([string]$physical.FinalizationSha256).ToLowerInvariant() +} + +$authorityDirectory = Split-Path -Parent $OutputAuthorityPath +if ($authorityDirectory) { New-Item -ItemType Directory -Force $authorityDirectory | Out-Null } +$authority | ConvertTo-Json -Depth 18 | Set-Content -LiteralPath $OutputAuthorityPath -Encoding utf8 +$authorityHash = (Get-FileHash -LiteralPath $OutputAuthorityPath -Algorithm SHA256).Hash.ToLowerInvariant() + +$propsDirectory = Split-Path -Parent $OutputPropsPath +if ($propsDirectory) { New-Item -ItemType Directory -Force $propsDirectory | Out-Null } +$props = @" + + + true + $baseline + P0-5g + $authorityHash + $(([string]$readiness.EngineHeadCommit).ToLowerInvariant()) + + +"@ +[IO.File]::WriteAllText($OutputPropsPath, $props, (New-Object Text.UTF8Encoding($false))) + +Write-Host 'P0-5g golden discovery production promotion: AUTHORIZED' +Write-Host " physical authority SHA256: $physicalHash" +Write-Host " evidence engine baseline: $baseline" +Write-Host " validated engine PR head: $($readiness.EngineHeadCommit)" +Write-Host " promotion authority SHA256: $authorityHash" +Write-Host " authority file: $OutputAuthorityPath" +Write-Host " production props: $OutputPropsPath" diff --git a/scripts/new-smart-discovery-repeat-run-authority.ps1 b/scripts/new-smart-discovery-repeat-run-authority.ps1 new file mode 100644 index 000000000..a20ec372b --- /dev/null +++ b/scripts/new-smart-discovery-repeat-run-authority.ps1 @@ -0,0 +1,98 @@ +param( + [Parameter(Mandatory=$true)][string]$GoldenLockPath, + [Parameter(Mandatory=$true)][string]$RepeatTargetPath, + [Parameter(Mandatory=$true)][string]$FinalizationJson, + [Parameter(Mandatory=$true)][string[]]$RunBundlePaths, + [Parameter(Mandatory=$true)][string]$OutputPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +$goldenFile = Resolve-File $GoldenLockPath 'P0-5e golden lock' +$targetFile = Resolve-File $RepeatTargetPath 'P0-5f repeat target' +$finalFile = Resolve-File $FinalizationJson 'P0-5f finalization JSON' +$golden = Get-Content -LiteralPath $goldenFile -Raw | ConvertFrom-Json +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json +$final = Get-Content -LiteralPath $finalFile -Raw | ConvertFrom-Json + +if ($golden.Phase -ne 'P0-5e' -or $golden.Status -ne 'locked') { throw 'Physical authority requires an active P0-5e golden lock.' } +if (-not [bool]$golden.GoldenSource.RawCaptureReverified) { throw 'Physical authority rejects a fixture/non-reverified P0-5e golden lock.' } +if ($target.Phase -ne 'P0-5f') { throw 'Repeat target is not P0-5f authority.' } +if ($final.Phase -ne 'P0-5f' -or $final.Verdict -ne 'PASS') { throw 'Physical authority requires a P0-5f PASS finalization.' } +if ([int]$final.SchemaVersion -lt 2) { throw 'Physical authority requires P0-5f finalization schema v2 or newer.' } + +$goldenHash = (Get-FileHash -LiteralPath $goldenFile -Algorithm SHA256).Hash.ToLowerInvariant() +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() +$finalHash = (Get-FileHash -LiteralPath $finalFile -Algorithm SHA256).Hash.ToLowerInvariant() +if ([string]$final.GoldenLockSha256 -ne $goldenHash) { throw 'Finalization is bound to a different P0-5e golden lock.' } +if ([string]$final.RepeatTargetSha256 -ne $targetHash) { throw 'Finalization is bound to a different P0-5f repeat target.' } +if ([string]$final.DeviceIdentity -ne [string]$target.DeviceIdentity -or [string]$final.DeviceIdentity -ne [string]$golden.DeviceIdentity) { + throw 'Device identity differs across golden lock, repeat target, and finalization.' +} +if ([string]$final.ArsasCommit -ne [string]$golden.GoldenSource.ArsasCommit) { throw 'Finalization ARSAS commit differs from golden authority.' } +if ([string]$final.EngineCommit -ne [string]$golden.GoldenSource.EngineCommit) { throw 'Finalization engine commit differs from golden authority.' } + +$minimumRuns = [int]$target.MinimumIndependentAssociations +if ([int]$final.ObservedRunBundles -lt $minimumRuns -or $RunBundlePaths.Count -lt $minimumRuns) { + throw "Physical authority requires at least $minimumRuns independent run bundles." +} +if ([int]$final.ObservedRunBundles -ne $RunBundlePaths.Count) { + throw 'Supplied run-bundle count differs from the reviewed finalization.' +} + +$expectedBundleHashes = @($final.Runs | ForEach-Object { [string]$_.BundleSha256 } | Sort-Object) +$observedBundleHashes = @() +$associationGenerations = @() +$captureHashes = @() +$runtimeHashes = @() +foreach ($path in $RunBundlePaths) { + $bundleFile = Resolve-File $path 'P0-5f run bundle' + $bundle = Get-Content -LiteralPath $bundleFile -Raw | ConvertFrom-Json + if ($bundle.Phase -ne 'P0-5f-run-bundle' -or $bundle.Verdict -ne 'PASS') { throw "Run bundle '$bundleFile' is not PASS evidence." } + if ([bool]$bundle.FixtureEvidence) { throw "Physical authority rejects fixture run bundle '$bundleFile'." } + if ([string]$bundle.GoldenLockSha256 -ne $goldenHash) { throw "Run bundle '$bundleFile' is bound to a different golden lock." } + if ([string]$bundle.DeviceIdentity -ne [string]$golden.DeviceIdentity) { throw "Run bundle '$bundleFile' device identity mismatch." } + $observedBundleHashes += (Get-FileHash -LiteralPath $bundleFile -Algorithm SHA256).Hash.ToLowerInvariant() + $associationGenerations += [long]$bundle.Runtime.AssociationGeneration + $captureHashes += [string]$bundle.Provenance.CaptureSha256 + $runtimeHashes += [string]$bundle.Provenance.RuntimeEvidenceSha256 +} +$observedBundleHashes = @($observedBundleHashes | Sort-Object) +if (($expectedBundleHashes -join '|') -ne ($observedBundleHashes -join '|')) { throw 'Supplied run bundles do not exactly match the reviewed finalization bundle hashes.' } +if (@($associationGenerations | Sort-Object -Unique).Count -ne $RunBundlePaths.Count) { throw 'Physical authority rejects reused association generations.' } +if (@($captureHashes | Sort-Object -Unique).Count -ne $RunBundlePaths.Count) { throw 'Physical authority rejects reused raw capture evidence.' } +if (@($runtimeHashes | Sort-Object -Unique).Count -ne $RunBundlePaths.Count) { throw 'Physical authority rejects reused runtime evidence.' } + +$authority = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5f-authority' + Status = 'physical-finalized' + DeviceIdentity = [string]$golden.DeviceIdentity + ArsasCommit = [string]$golden.GoldenSource.ArsasCommit + EngineCommit = [string]$golden.GoldenSource.EngineCommit + GoldenLockSha256 = $goldenHash + RepeatTargetSha256 = $targetHash + FinalizationFileName = [IO.Path]::GetFileName($finalFile) + FinalizationSha256 = $finalHash + IndependentAssociations = $RunBundlePaths.Count + AssociationGenerations = @($associationGenerations) + Consensus = $final.Consensus + RunBundleSha256 = @($observedBundleHashes) + CaptureSha256 = @($captureHashes) + RuntimeEvidenceSha256 = @($runtimeHashes) +} + +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$authority | ConvertTo-Json -Depth 18 | Set-Content -LiteralPath $OutputPath -Encoding utf8 +Write-Host 'P0-5f physical repeat-run authority: FINALIZED' +Write-Host " independent associations: $($RunBundlePaths.Count)" +Write-Host " finalization SHA256: $finalHash" +Write-Host " authority file: $OutputPath" diff --git a/scripts/new-smart-discovery-repeat-run-bundle.ps1 b/scripts/new-smart-discovery-repeat-run-bundle.ps1 new file mode 100644 index 000000000..6513f805c --- /dev/null +++ b/scripts/new-smart-discovery-repeat-run-bundle.ps1 @@ -0,0 +1,222 @@ +param( + [Parameter(Mandatory=$true)][string]$GoldenLockPath, + [Parameter(Mandatory=$true)][string]$ProofJson, + [Parameter(Mandatory=$true)][string]$CapturePath, + [Parameter(Mandatory=$true)][string]$RuntimeEvidenceJson, + [Parameter(Mandatory=$true)][string]$BuildManifestPath, + [Parameter(Mandatory=$true)][string]$TargetPath, + [Parameter(Mandatory=$true)][string]$DeviceIdentity, + [Parameter(Mandatory=$true)][string]$ArsasCommit, + [Parameter(Mandatory=$true)][string]$EngineCommit, + [Parameter(Mandatory=$true)][string]$OutputPath, + [switch]$AllowFixtureEvidence +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} + +function Get-Int($Object, [string]$Name) { + if ($null -eq $Object) { return 0 } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return 0 } + return [int]$property.Value +} + +function Get-ServiceMap($Object) { + $map = [ordered]@{} + if ($null -eq $Object) { return $map } + foreach ($property in @($Object.PSObject.Properties | Sort-Object Name)) { + $map[$property.Name] = [int]$property.Value + } + return $map +} + +function Assert-SameServiceMap($Expected, $Observed, [string]$Label) { + $a = Get-ServiceMap $Expected + $b = Get-ServiceMap $Observed + $names = @($a.Keys + $b.Keys | Sort-Object -Unique) + foreach ($name in $names) { + $av = if ($a.Contains($name)) { [int]$a[$name] } else { 0 } + $bv = if ($b.Contains($name)) { [int]$b[$name] } else { 0 } + if ($av -ne $bv) { throw "${Label}: service '$name' differs: supplied=$av observed=$bv." } + } +} + +$goldenLockFile = Resolve-File $GoldenLockPath 'P0-5e golden lock' +$proofFile = Resolve-File $ProofJson 'P0-5d proof' +$captureFile = Resolve-File $CapturePath 'repeat raw capture' +$runtimeFile = Resolve-File $RuntimeEvidenceJson 'P0-5f runtime evidence' +$manifestFile = Resolve-File $BuildManifestPath 'field build manifest' +$targetFile = Resolve-File $TargetPath 'same-IED semantic target' +Assert-Commit $ArsasCommit 'ARSAS commit' +Assert-Commit $EngineCommit 'Engine commit' + +$arsasCommitNormalized = $ArsasCommit.ToLowerInvariant() +$engineCommitNormalized = $EngineCommit.ToLowerInvariant() +$lock = Get-Content -LiteralPath $goldenLockFile -Raw | ConvertFrom-Json +$proof = Get-Content -LiteralPath $proofFile -Raw | ConvertFrom-Json +$runtime = Get-Content -LiteralPath $runtimeFile -Raw | ConvertFrom-Json +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json + +if ($lock.Phase -ne 'P0-5e' -or $lock.Status -ne 'locked') { throw 'P0-5f requires an active P0-5e golden lock.' } +if (-not $AllowFixtureEvidence -and -not [bool]$lock.GoldenSource.RawCaptureReverified) { + throw 'Production P0-5f requires a P0-5e lock created from independently reverified physical capture evidence.' +} +if ($lock.DeviceIdentity -ne $DeviceIdentity -or $target.DeviceIdentity -ne $DeviceIdentity) { + throw 'Repeat-run device identity does not match golden/target authority.' +} +if ([string]$lock.GoldenSource.ArsasCommit -ne $arsasCommitNormalized) { throw 'Repeat-run ARSAS commit differs from the golden lock.' } +if ([string]$lock.GoldenSource.EngineCommit -ne $engineCommitNormalized) { throw 'Repeat-run engine commit differs from the golden lock.' } +if ([string]$target.EngineCommit -ne $engineCommitNormalized) { throw 'Repeat-run engine commit differs from the same-IED target.' } + +$manifestHash = (Get-FileHash -LiteralPath $manifestFile -Algorithm SHA256).Hash.ToLowerInvariant() +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() +if ([string]$lock.GoldenSource.BuildManifestSha256 -ne $manifestHash) { throw 'Field build manifest hash differs from the golden lock.' } +if ([string]$lock.GoldenSource.SemanticTargetSha256 -ne $targetHash) { throw 'Semantic target hash differs from the golden lock.' } + +$manifestText = Get-Content -LiteralPath $manifestFile -Raw +$manifestArsas = [regex]::Match($manifestText, '(?im)^ARSAS commit:\s*([0-9a-f]{40})\s*$') +$manifestEngine = [regex]::Match($manifestText, '(?im)^ARIEC61850 commit:\s*([0-9a-f]{40})\s*$') +if (-not $manifestArsas.Success -or -not $manifestEngine.Success) { throw 'Build manifest is missing exact ARSAS/engine commit identity.' } +if ($manifestArsas.Groups[1].Value -ne $arsasCommitNormalized -or $manifestEngine.Groups[1].Value -ne $engineCommitNormalized) { + throw 'Build manifest commit identity does not match repeat-run inputs.' +} + +if ($proof.Phase -ne 'P0-5d' -or $proof.Verdict -ne 'PASS') { throw 'Repeat run requires a P0-5d PASS proof.' } +if ($runtime.Phase -ne 'P0-5f-run' -or $runtime.SchemaVersion -ne 1) { throw 'Runtime evidence is not a supported P0-5f-run snapshot.' } +if ([string]$runtime.DeviceIdentity -ne $DeviceIdentity) { throw 'Runtime evidence device identity mismatch.' } +if ([string]$runtime.EngineCommit -ne $engineCommitNormalized) { throw 'Runtime evidence engine commit mismatch.' } +if ($null -eq $runtime.SmartDiscoveryKpi -or -not [bool]$runtime.SmartDiscoveryKpi.WireAccountingComplete) { + throw 'Runtime evidence does not have complete smart-discovery wire accounting.' +} +if ((Get-Int $runtime.SmartDiscoveryKpi 'DuplicateRequests') -ne 0) { throw 'Runtime engine KPI reports duplicate smart-discovery requests.' } +if ([string]::IsNullOrWhiteSpace([string]$runtime.SmartDiscoveryKpi.DeterministicSignature)) { throw 'Runtime engine KPI deterministic signature is missing.' } +if ([string]::IsNullOrWhiteSpace([string]$runtime.Model.DirectoryModelSignature) -or + [string]::IsNullOrWhiteSpace([string]$runtime.Model.ProjectionSignature)) { + throw 'Runtime model/projection signature is missing.' +} +if ($null -eq $runtime.TypeProbeBudget) { throw 'Runtime hierarchy type-probe budget is missing.' } + +# Re-validate the P0-5d proof against the locked request budget and exact build identity. +$goldenVerifier = Join-Path $PSScriptRoot 'verify-smart-discovery-golden-lock.ps1' +if (-not (Test-Path -LiteralPath $goldenVerifier -PathType Leaf)) { throw 'P0-5e golden verifier is missing.' } +$goldenAcceptance = Join-Path ([IO.Path]::GetTempPath()) ("p0-5f-golden-{0}.json" -f [Guid]::NewGuid().ToString('N')) +try { + & $goldenVerifier ` + -LockPath $goldenLockFile ` + -ProofJson $proofFile ` + -DeviceIdentity $DeviceIdentity ` + -CandidateArsasCommit $arsasCommitNormalized ` + -CandidateEngineCommit $engineCommitNormalized ` + -TargetPath $targetFile ` + -OutputJson $goldenAcceptance ` + -NoFailExit + $accepted = Get-Content -LiteralPath $goldenAcceptance -Raw | ConvertFrom-Json + if ($accepted.Verdict -ne 'PASS') { + throw "Repeat run exceeds the P0-5e golden lock: $(@($accepted.AcceptanceFailures) -join '; ')" + } +} +finally { + Remove-Item -LiteralPath $goldenAcceptance -Force -ErrorAction SilentlyContinue +} + +# Production default independently decodes the supplied raw capture again. This binds +# the run bundle to real wire evidence rather than trusting a detached proof JSON. +$extension = [IO.Path]::GetExtension($captureFile).ToLowerInvariant() +if (-not $AllowFixtureEvidence -and $extension -notin @('.pcap', '.pcapng')) { throw 'Production repeat evidence requires raw .pcap/.pcapng input.' } +if (-not $AllowFixtureEvidence) { + $wireVerifier = Join-Path $PSScriptRoot 'verify-smart-discovery-pcap.ps1' + $reproofPath = Join-Path ([IO.Path]::GetTempPath()) ("p0-5f-reproof-{0}.json" -f [Guid]::NewGuid().ToString('N')) + try { + & $wireVerifier -PcapPath $captureFile -OutputJson $reproofPath -NoFailExit + $reproof = Get-Content -LiteralPath $reproofPath -Raw | ConvertFrom-Json + if ($reproof.Verdict -ne 'PASS') { throw 'Raw repeat capture does not independently reproduce a P0-5d PASS.' } + foreach ($name in @('ConfirmedRequests','DuplicateSemanticRequests','DuplicateGetNameListRequests','DuplicateGvaRequests','PeakOutstandingRequests','InvokeIdReuseWhileOutstanding','OrphanResponses','UnansweredRequestsAtCaptureEnd')) { + $a = Get-Int $proof.ArsasCapture $name + $b = Get-Int $reproof.ArsasCapture $name + if ($a -ne $b) { throw "Proof/raw-capture mismatch for ${name}: proof=$a redecoded=$b." } + } + Assert-SameServiceMap $proof.ArsasCapture.ServiceCounts $reproof.ArsasCapture.ServiceCounts 'Proof/raw-capture mismatch' + } + finally { + Remove-Item -LiteralPath $reproofPath -Force -ErrorAction SilentlyContinue + } +} + +$wireRequests = Get-Int $proof.ArsasCapture 'ConfirmedRequests' +$engineRequests = Get-Int $runtime.SmartDiscoveryKpi 'TotalRequests' +if ($wireRequests -ne $engineRequests) { + throw "Wire/engine request accounting mismatch: PCAP=$wireRequests engine=$engineRequests." +} +if ((Get-Int $proof.ArsasCapture 'DuplicateSemanticRequests') -ne 0 -or + (Get-Int $proof.ArsasCapture 'DuplicateGetNameListRequests') -ne 0 -or + (Get-Int $proof.ArsasCapture 'DuplicateGvaRequests') -ne 0 -or + [bool]$proof.ArsasCapture.SecondGetNameListSweepDetected) { + throw 'Repeat wire proof contains duplicate or second-sweep traffic.' +} + +$captureHash = (Get-FileHash -LiteralPath $captureFile -Algorithm SHA256).Hash.ToLowerInvariant() +$proofHash = (Get-FileHash -LiteralPath $proofFile -Algorithm SHA256).Hash.ToLowerInvariant() +$runtimeHash = (Get-FileHash -LiteralPath $runtimeFile -Algorithm SHA256).Hash.ToLowerInvariant() +$goldenHash = (Get-FileHash -LiteralPath $goldenLockFile -Algorithm SHA256).Hash.ToLowerInvariant() + +$bundle = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5f-run-bundle' + Verdict = 'PASS' + DeviceIdentity = $DeviceIdentity + ArsasCommit = $arsasCommitNormalized + EngineCommit = $engineCommitNormalized + GoldenLockSha256 = $goldenHash + BuildManifestSha256 = $manifestHash + SemanticTargetSha256 = $targetHash + FixtureEvidence = [bool]$AllowFixtureEvidence + Provenance = [ordered]@{ + CaptureFileName = [IO.Path]::GetFileName($captureFile) + CaptureSha256 = $captureHash + ProofFileName = [IO.Path]::GetFileName($proofFile) + ProofSha256 = $proofHash + RuntimeEvidenceFileName = [IO.Path]::GetFileName($runtimeFile) + RuntimeEvidenceSha256 = $runtimeHash + } + Wire = [ordered]@{ + ClientIp = $proof.ArsasCapture.ClientIp + ServerIp = $proof.ArsasCapture.ServerIp + ConfirmedRequests = $wireRequests + ServiceCounts = $proof.ArsasCapture.ServiceCounts + PeakOutstandingRequests = Get-Int $proof.ArsasCapture 'PeakOutstandingRequests' + NegotiatedMaxOutstandingCalling = $proof.ArsasCapture.NegotiatedMaxOutstandingCalling + DuplicateSemanticRequests = Get-Int $proof.ArsasCapture 'DuplicateSemanticRequests' + DuplicateGetNameListRequests = Get-Int $proof.ArsasCapture 'DuplicateGetNameListRequests' + DuplicateGvaRequests = Get-Int $proof.ArsasCapture 'DuplicateGvaRequests' + SecondGetNameListSweepDetected = [bool]$proof.ArsasCapture.SecondGetNameListSweepDetected + } + Runtime = [ordered]@{ + AssociationGeneration = [long]$runtime.AssociationGeneration + DirectoryModelSignature = [string]$runtime.Model.DirectoryModelSignature + ProjectionSignature = [string]$runtime.Model.ProjectionSignature + Model = $runtime.Model + SmartDiscoveryKpi = $runtime.SmartDiscoveryKpi + TypeProbeBudget = $runtime.TypeProbeBudget + } +} + +$outputDirectory = Split-Path -Parent $OutputPath +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$bundle | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $OutputPath -Encoding utf8 +Write-Host "P0-5f repeat-run bundle: PASS" +Write-Host " output: $OutputPath" +Write-Host " requests: $wireRequests" +Write-Host " KPI signature: $($runtime.SmartDiscoveryKpi.DeterministicSignature)" +Write-Host " directory signature: $($runtime.Model.DirectoryModelSignature)" +Write-Host " projection signature: $($runtime.Model.ProjectionSignature)" diff --git a/scripts/verify-smart-discovery-golden-lock.ps1 b/scripts/verify-smart-discovery-golden-lock.ps1 new file mode 100644 index 000000000..e58f6340b --- /dev/null +++ b/scripts/verify-smart-discovery-golden-lock.ps1 @@ -0,0 +1,153 @@ +param( + [Parameter(Mandatory=$true)][string]$LockPath, + [Parameter(Mandatory=$true)][string]$ProofJson, + [Parameter(Mandatory=$true)][string]$DeviceIdentity, + [Parameter(Mandatory=$true)][string]$CandidateArsasCommit, + [Parameter(Mandatory=$true)][string]$CandidateEngineCommit, + [Parameter(Mandatory=$true)][string]$TargetPath, + [switch]$AllowDifferentArsasCommit, + [switch]$AllowDifferentEngineCommit, + [string]$OutputJson, + [switch]$NoFailExit +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +function Get-IntProperty($Object, [string]$Name) { + if ($null -eq $Object) { return 0 } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return 0 } + return [int]$property.Value +} + +$lockFile = Resolve-File $LockPath 'P0-5e golden lock' +$proofFile = Resolve-File $ProofJson 'P0-5d proof JSON' +$targetFile = Resolve-File $TargetPath 'same-IED semantic target' +$lock = Get-Content -LiteralPath $lockFile -Raw | ConvertFrom-Json +$proof = Get-Content -LiteralPath $proofFile -Raw | ConvertFrom-Json +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json +$failures = [System.Collections.Generic.List[string]]::new() + +if ($lock.Phase -ne 'P0-5e' -or $lock.Status -ne 'locked') { $failures.Add('Golden lock is not an active P0-5e locked contract.') } +if ($proof.Phase -ne 'P0-5d' -or $proof.Verdict -ne 'PASS') { $failures.Add('Candidate evidence must be a P0-5d PASS proof.') } +if ($lock.DeviceIdentity -ne $DeviceIdentity) { $failures.Add("Device identity mismatch: '$DeviceIdentity' != '$($lock.DeviceIdentity)'.") } +if ($target.Phase -ne 'P0-5e' -or $target.DeviceIdentity -ne $DeviceIdentity) { $failures.Add('Candidate semantic target identity does not match the golden device.') } + +if ($CandidateArsasCommit -notmatch '^[0-9a-fA-F]{40}$') { + $failures.Add('Candidate ARSAS commit is not a full 40-character SHA.') +} elseif (-not $AllowDifferentArsasCommit -and $CandidateArsasCommit.ToLowerInvariant() -ne [string]$lock.GoldenSource.ArsasCommit) { + $failures.Add('Candidate ARSAS commit differs from the golden build baseline. Use -AllowDifferentArsasCommit only for an intentional regression comparison.') +} + +if ($CandidateEngineCommit -notmatch '^[0-9a-fA-F]{40}$') { + $failures.Add('Candidate engine commit is not a full 40-character SHA.') +} elseif (-not $AllowDifferentEngineCommit -and $CandidateEngineCommit.ToLowerInvariant() -ne [string]$lock.GoldenSource.EngineCommit) { + $failures.Add('Candidate engine commit differs from the golden engine baseline. Use -AllowDifferentEngineCommit only for an intentional regression comparison.') +} + +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() +if ($null -eq $lock.GoldenSource.PSObject.Properties['SemanticTargetSha256']) { + $failures.Add('Golden lock does not carry semantic-target provenance hash.') +} elseif ($targetHash -ne [string]$lock.GoldenSource.SemanticTargetSha256) { + $failures.Add('Semantic target hash differs from the golden lock authority.') +} +if ($null -eq $lock.GoldenSource.PSObject.Properties['BuildManifestSha256']) { + $failures.Add('Golden lock does not carry exact build-manifest provenance.') +} +if ($null -eq $lock.SemanticTarget) { + $failures.Add('Golden lock does not carry same-IED semantic authority.') +} else { + $lockedSemantic = $lock.SemanticTarget | ConvertTo-Json -Compress -Depth 8 + $targetSemantic = $target.SemanticTarget | ConvertTo-Json -Compress -Depth 8 + if ($lockedSemantic -ne $targetSemantic) { $failures.Add('Semantic target content differs from the golden lock.') } +} + +$actual = $proof.ArsasCapture +$budget = $lock.HardRequestBudget +if ($null -eq $actual -or $null -eq $budget) { + $failures.Add('Candidate proof or hard request budget is missing.') +} else { + if ((Get-IntProperty $actual 'ConfirmedRequests') -gt [int]$budget.MaxConfirmedRequests) { + $failures.Add("Confirmed request budget exceeded: $($actual.ConfirmedRequests) > $($budget.MaxConfirmedRequests).") + } + if ((Get-IntProperty $actual 'DuplicateSemanticRequests') -gt [int]$budget.MaxDuplicateSemanticRequests) { $failures.Add('Semantic duplicate budget exceeded.') } + if ((Get-IntProperty $actual 'DuplicateGetNameListRequests') -gt [int]$budget.MaxDuplicateGetNameListRequests) { $failures.Add('GetNameList duplicate budget exceeded.') } + if ((Get-IntProperty $actual 'DuplicateGvaRequests') -gt [int]$budget.MaxDuplicateGvaRequests) { $failures.Add('GVA duplicate budget exceeded.') } + if ((Get-IntProperty $actual 'InvokeIdReuseWhileOutstanding') -gt [int]$budget.MaxInvokeIdReuseWhileOutstanding) { $failures.Add('Invoke-ID reuse budget exceeded.') } + if ((Get-IntProperty $actual 'OrphanResponses') -gt [int]$budget.MaxOrphanResponses) { $failures.Add('Orphan response budget exceeded.') } + if ((Get-IntProperty $actual 'UnansweredRequestsAtCaptureEnd') -gt [int]$budget.MaxUnansweredRequestsAtCaptureEnd) { $failures.Add('Unanswered request budget exceeded.') } + if ((Get-IntProperty $actual 'PeakOutstandingRequests') -gt [int]$budget.MaxPeakOutstandingRequests) { + $failures.Add("Peak outstanding budget exceeded: $($actual.PeakOutstandingRequests) > $($budget.MaxPeakOutstandingRequests).") + } + if ([bool]$budget.ForbidSecondGetNameListSweep -and [bool]$actual.SecondGetNameListSweepDetected) { $failures.Add('Second GetNameList sweep is forbidden by the golden lock.') } + + $allowed = $budget.MaxServiceRequests + if ($null -eq $allowed) { $failures.Add('Golden lock has no service budget.') } + else { + $candidateProperties = if ($null -ne $actual.ServiceCounts) { @($actual.ServiceCounts.PSObject.Properties) } else { @() } + foreach ($property in $candidateProperties) { + $allowedProperty = $allowed.PSObject.Properties[$property.Name] + if ($null -eq $allowedProperty) { + if ([bool]$budget.ForbidUnexpectedServices -and [int]$property.Value -gt 0) { + $failures.Add("Unexpected MMS service '$($property.Name)' is not present in the golden budget.") + } + continue + } + if ([int]$property.Value -gt [int]$allowedProperty.Value) { + $failures.Add("Service budget exceeded for '$($property.Name)': $($property.Value) > $($allowedProperty.Value).") + } + } + } +} + +$result = [ordered]@{ + SchemaVersion = 2 + Phase = 'P0-5e' + Verdict = if ($failures.Count -eq 0) { 'PASS' } else { 'FAIL' } + DeviceIdentity = $DeviceIdentity + GoldenLock = [ordered]@{ + Path = $lockFile + CaptureSha256 = $lock.GoldenSource.CaptureSha256 + ProofSha256 = $lock.GoldenSource.ProofSha256 + BuildManifestSha256 = $lock.GoldenSource.BuildManifestSha256 + SemanticTargetSha256 = $lock.GoldenSource.SemanticTargetSha256 + ArsasCommit = $lock.GoldenSource.ArsasCommit + EngineCommit = $lock.GoldenSource.EngineCommit + MaxConfirmedRequests = $lock.HardRequestBudget.MaxConfirmedRequests + MaxServiceRequests = $lock.HardRequestBudget.MaxServiceRequests + } + Candidate = [ordered]@{ + ProofPath = $proofFile + ArsasCommit = $CandidateArsasCommit + EngineCommit = $CandidateEngineCommit + SemanticTargetSha256 = $targetHash + ConfirmedRequests = if ($null -ne $actual) { $actual.ConfirmedRequests } else { $null } + ServiceCounts = if ($null -ne $actual) { $actual.ServiceCounts } else { $null } + PeakOutstandingRequests = if ($null -ne $actual) { $actual.PeakOutstandingRequests } else { $null } + } + AcceptanceFailures = @($failures) +} + +if ([string]::IsNullOrWhiteSpace($OutputJson)) { + $base = [IO.Path]::GetFileNameWithoutExtension($proofFile) + $OutputJson = Join-Path ([IO.Path]::GetDirectoryName($proofFile)) "P0-5E-$base-acceptance.json" +} +$result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputJson -Encoding utf8 + +Write-Host "P0-5e golden capture acceptance: $($result.Verdict)" +Write-Host " device: $DeviceIdentity" +Write-Host " candidate ARSAS: $CandidateArsasCommit" +Write-Host " candidate engine: $CandidateEngineCommit" +Write-Host " candidate requests: $($result.Candidate.ConfirmedRequests); hard max: $($result.GoldenLock.MaxConfirmedRequests)" +Write-Host " acceptance JSON: $OutputJson" +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Error $failure -ErrorAction Continue } + if (-not $NoFailExit) { exit 1 } +} diff --git a/scripts/verify-smart-discovery-live-merge-preflight.ps1 b/scripts/verify-smart-discovery-live-merge-preflight.ps1 new file mode 100644 index 000000000..3c5fab9de --- /dev/null +++ b/scripts/verify-smart-discovery-live-merge-preflight.ps1 @@ -0,0 +1,97 @@ +param( + [Parameter(Mandatory=$true)][string]$MergeManifestPath, + [Parameter(Mandatory=$true)][string]$ArsasRepositoryPath, + [Parameter(Mandatory=$true)][string]$LiveArsasHeadCommit, + [Parameter(Mandatory=$true)][string]$CurrentArsasBaseCommit, + [Parameter(Mandatory=$true)][string]$LiveEngineHeadCommit, + [Parameter(Mandatory=$true)][string]$CurrentEngineBaseCommit, + [Parameter(Mandatory=$true)][string]$OutputJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} +function Resolve-Directory([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Container)) { throw "$Label is not a directory: $Path" } + return $resolved.Path +} +function Test-GitAncestor([string]$RepositoryPath, [string]$Ancestor, [string]$Descendant) { + & git -C $RepositoryPath merge-base --is-ancestor $Ancestor $Descendant 2>$null | Out-Null + return $LASTEXITCODE -eq 0 +} +function Get-GitChangedPaths([string]$RepositoryPath, [string]$BaseCommit, [string]$HeadCommit) { + $lines = @(& git -C $RepositoryPath diff --name-only "$BaseCommit..$HeadCommit" -- 2>$null) + if ($LASTEXITCODE -ne 0) { throw "git diff failed for $BaseCommit..$HeadCommit." } + return @($lines | ForEach-Object { ([string]$_).Trim().Replace('\\','/') } | Where-Object { $_ } | Sort-Object -Unique) +} + +$manifestFile = Resolve-File $MergeManifestPath 'P0-5h merge manifest' +$repo = Resolve-Directory $ArsasRepositoryPath 'ARSAS repository' +foreach ($entry in @( + @{ Value = $LiveArsasHeadCommit; Label = 'live ARSAS head' }, + @{ Value = $CurrentArsasBaseCommit; Label = 'current ARSAS base' }, + @{ Value = $LiveEngineHeadCommit; Label = 'live engine head' }, + @{ Value = $CurrentEngineBaseCommit; Label = 'current engine base' })) { Assert-Commit $entry.Value $entry.Label } + +$manifest = Get-Content -LiteralPath $manifestFile -Raw | ConvertFrom-Json +if ($manifest.Phase -ne 'P0-5h-merge-manifest' -or $manifest.Status -ne 'authorized-for-ordered-merge') { throw 'Live merge preflight requires an authorized P0-5h merge manifest.' } +if ($manifest.MergeMethod -ne 'merge' -or (@($manifest.MergeOrder) -join ',') -ne 'engine,arsas') { throw 'Live merge preflight requires merge-commit method and engine-first order.' } + +$validatedArsas = ([string]$manifest.Arsas.ValidatedHeadSha).ToLowerInvariant() +$liveArsas = $LiveArsasHeadCommit.ToLowerInvariant() +$currentArsasBase = $CurrentArsasBaseCommit.ToLowerInvariant() +$expectedEngine = ([string]$manifest.Engine.ExpectedHeadSha).ToLowerInvariant() +$liveEngine = $LiveEngineHeadCommit.ToLowerInvariant() +$currentEngineBase = $CurrentEngineBaseCommit.ToLowerInvariant() +Assert-Commit $validatedArsas 'manifest validated ARSAS head' +Assert-Commit $expectedEngine 'manifest engine head' + +$failures = [System.Collections.Generic.List[string]]::new() +if (([string]$manifest.Arsas.BaseShaAtAuthorization).ToLowerInvariant() -ne $currentArsasBase) { $failures.Add('ARSAS base SHA changed after merge authorization.') } +if (([string]$manifest.Engine.BaseShaAtAuthorization).ToLowerInvariant() -ne $currentEngineBase) { $failures.Add('Engine base SHA changed after merge authorization.') } +if ($expectedEngine -ne $liveEngine) { $failures.Add('Engine PR head changed after merge authorization.') } +if (-not (Test-GitAncestor $repo $validatedArsas $liveArsas)) { $failures.Add('Live ARSAS PR head is not a descendant of the P0-5g validated head.') } + +$changed = @() +if ($failures.Count -eq 0 -or (Test-GitAncestor $repo $validatedArsas $liveArsas)) { + $changed = @(Get-GitChangedPaths $repo $validatedArsas $liveArsas) + $allowed = @($manifest.Arsas.AllowedPostAuthorizationPaths | ForEach-Object { [string]$_ }) + $unexpected = @($changed | Where-Object { $allowed -notcontains $_ }) + if ($unexpected.Count -gt 0) { $failures.Add("ARSAS changed after P0-5g validation outside merge-manifest allowlist: $($unexpected -join ', ').") } + if ($changed.Count -eq 0 -or $changed -notcontains 'evidence/smart-discovery-mainline-merge-manifest.json') { $failures.Add('Live ARSAS head does not contain the tracked P0-5h merge manifest change.') } +} + +$result = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5h-live-preflight' + Verdict = if ($failures.Count -eq 0) { 'PASS' } else { 'FAIL' } + MergeMethod = 'merge' + ValidatedArsasHead = $validatedArsas + LiveArsasHead = $liveArsas + ArsasBaseSha = $currentArsasBase + ExpectedEngineHead = $expectedEngine + LiveEngineHead = $liveEngine + EngineBaseSha = $currentEngineBase + ArsasPostAuthorizationChangedPaths = @($changed) + AcceptanceFailures = @($failures) +} +$outputDirectory = Split-Path -Parent $OutputJson +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $OutputJson -Encoding utf8 +Write-Host "P0-5h live merge preflight: $($result.Verdict)" +Write-Host " live engine head: $liveEngine" +Write-Host " live ARSAS head: $liveArsas" +Write-Host " changed paths after validation: $($changed -join ', ')" +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Error $failure -ErrorAction Continue } + exit 1 +} diff --git a/scripts/verify-smart-discovery-pcap.ps1 b/scripts/verify-smart-discovery-pcap.ps1 new file mode 100644 index 000000000..fba5eb523 --- /dev/null +++ b/scripts/verify-smart-discovery-pcap.ps1 @@ -0,0 +1,349 @@ +param( + [string]$PcapPath, + [string]$DecodedRowsPath, + [string]$ReferencePcapPath, + [string]$TsharkPath = "tshark", + [string]$ClientIp, + [string]$ServerIp, + [int]$MaxConfirmedRequests = 0, + [int]$MaxGvaRequests = 0, + [switch]$RequireNoMoreRequestsThanReference, + [string]$OutputJson, + [switch]$NoFailExit +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-OptionalFile([string]$Path, [string]$Label) { + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { + throw "$Label is not a file: $Path" + } + return $resolved.Path +} + +function Normalize-Cell([object]$Value) { + if ($null -eq $Value) { return "" } + return ([string]$Value).Trim() +} + +function Get-RowValue($Row, [string]$Name) { + if ($null -eq $Row) { return "" } + $property = $Row.PSObject.Properties[$Name] + if ($null -eq $property) { return "" } + return Normalize-Cell $property.Value +} + +function Get-EndpointSource($Row) { + $ipv4 = Get-RowValue $Row "ip.src" + if ($ipv4) { return $ipv4 } + return Get-RowValue $Row "ipv6.src" +} + +function Get-EndpointDestination($Row) { + $ipv4 = Get-RowValue $Row "ip.dst" + if ($ipv4) { return $ipv4 } + return Get-RowValue $Row "ipv6.dst" +} + +function Test-Present($Row, [string]$Field) { + return -not [string]::IsNullOrWhiteSpace((Get-RowValue $Row $Field)) +} + +function Sum-IntProperty($Items, [string]$PropertyName) { + $sum = 0 + foreach ($item in @($Items)) { + if ($null -eq $item) { continue } + $property = $item.PSObject.Properties[$PropertyName] + if ($null -ne $property -and $null -ne $property.Value) { + $sum += [int]$property.Value + } + } + return $sum +} + +function Get-ServiceName($Row) { + if (Test-Present $Row "mms.getNameList_element") { return "GetNameList" } + if (Test-Present $Row "mms.getVariableAccessAttributes_element") { return "GetVariableAccessAttributes" } + if (Test-Present $Row "mms.getNamedVariableListAttributes_element") { return "GetNamedVariableListAttributes" } + if (Test-Present $Row "mms.read_element") { return "Read" } + if (Test-Present $Row "mms.identify_element") { return "Identify" } + if (Test-Present $Row "mms.write_element") { return "Write" } + + $service = Get-RowValue $Row "mms.confirmedServiceRequest" + if ($service) { return "ConfirmedService:$service" } + return "UnknownConfirmedService" +} + +function Get-RequestFingerprint($Row) { + # Invoke-ID is deliberately excluded. Reissuing the same logical request with a + # different invoke-ID must still be detected as duplicate wire work. + $parts = [ordered]@{ + service = Get-ServiceName $Row + objectClass = Get-RowValue $Row "mms.objectClass" + objectScope = Get-RowValue $Row "mms.objectScope" + domainId = Get-RowValue $Row "mms.domainId" + itemId = Get-RowValue $Row "mms.itemId" + objectItemId = Get-RowValue $Row "mms.objectName_domain_specific_itemId" + domainSpecific = Get-RowValue $Row "mms.domainSpecific" + vmdSpecific = Get-RowValue $Row "mms.vmd_specific" + variableListName = Get-RowValue $Row "mms.variableListName" + continueAfter = Get-RowValue $Row "mms.continueAfter" + getNameListContinueAfter = Get-RowValue $Row "mms.getNameList-Request_continueAfter" + nameToStartAfter = Get-RowValue $Row "mms.nameToStartAfter" + } + return (($parts.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "|") +} + +function Get-TsharkFieldSet([string]$Executable) { + $lines = & $Executable -G fields 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "TShark field discovery failed with exit code $LASTEXITCODE." + } + + $set = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($line in $lines) { + $parts = [string]$line -split "`t" + if ($parts.Length -ge 3 -and $parts[0] -eq "F" -and $parts[2]) { + [void]$set.Add($parts[2]) + } + } + return $set +} + +function Decode-MmsRows([string]$Capture, [string]$Executable, [System.Collections.Generic.HashSet[string]]$AvailableFields) { + $candidateFields = @( + "frame.number", "frame.time_epoch", "ip.src", "ip.dst", "ipv6.src", "ipv6.dst", "tcp.stream", + "mms.invokeID", "mms.confirmed_requestPDU", "mms.confirmed_responsePDU", "mms.confirmed_errorPDU", + "mms.confirmedServiceRequest", "mms.getNameList_element", "mms.getVariableAccessAttributes_element", + "mms.getNamedVariableListAttributes_element", "mms.read_element", "mms.identify_element", "mms.write_element", + "mms.objectClass", "mms.objectScope", "mms.domainId", "mms.itemId", "mms.objectName_domain_specific_itemId", + "mms.domainSpecific", "mms.vmd_specific", "mms.variableListName", "mms.continueAfter", + "mms.getNameList-Request_continueAfter", "mms.nameToStartAfter", + "mms.negociatedMaxServOutstandingCalling", "mms.negociatedMaxServOutstandingCalled" + ) + + $fields = @($candidateFields | Where-Object { $AvailableFields.Contains($_) }) + foreach ($required in @("frame.number", "frame.time_epoch", "tcp.stream", "mms.invokeID", "mms.confirmed_requestPDU", "mms.confirmed_responsePDU", "mms.confirmed_errorPDU")) { + if ($fields -notcontains $required) { throw "Installed TShark does not expose required field '$required'." } + } + + $args = @("-r", $Capture, "-Y", "mms", "-T", "fields", "-E", "header=y", "-E", "quote=d", "-E", "occurrence=a", "-E", "aggregator=,") + foreach ($field in $fields) { $args += @("-e", $field) } + + $lines = & $Executable @args 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "TShark failed to decode '$Capture' with exit code $LASTEXITCODE. Output: $($lines -join [Environment]::NewLine)" + } + if (-not $lines -or $lines.Count -lt 2) { + throw "No MMS rows were decoded from '$Capture'." + } + return @($lines | ConvertFrom-Csv -Delimiter "`t") +} + +function Import-DecodedRows([string]$Path) { + $rows = @(Import-Csv -LiteralPath $Path -Delimiter "`t") + if ($rows.Count -eq 0) { throw "Decoded-row fixture '$Path' is empty." } + return $rows +} + +function Analyze-Rows($Rows, [string]$Label, [string]$RequestedClientIp, [string]$RequestedServerIp) { + $requestRowsAll = @($Rows | Where-Object { Test-Present $_ "mms.confirmed_requestPDU" }) + if ($requestRowsAll.Count -eq 0) { throw "No MMS confirmed-request PDU was found in '$Label'." } + + $client = $RequestedClientIp + $server = $RequestedServerIp + if ([string]::IsNullOrWhiteSpace($client)) { $client = Get-EndpointSource $requestRowsAll[0] } + if ([string]::IsNullOrWhiteSpace($server)) { $server = Get-EndpointDestination $requestRowsAll[0] } + if (-not $client -or -not $server) { throw "Could not infer client/server endpoints for '$Label'." } + + $directionRows = @($Rows | Where-Object { + $src = Get-EndpointSource $_ + $dst = Get-EndpointDestination $_ + (($src -eq $client -and $dst -eq $server) -or ($src -eq $server -and $dst -eq $client)) + }) + $requests = @($directionRows | Where-Object { + (Get-EndpointSource $_) -eq $client -and (Get-EndpointDestination $_) -eq $server -and (Test-Present $_ "mms.confirmed_requestPDU") + }) + $responses = @($directionRows | Where-Object { + (Get-EndpointSource $_) -eq $server -and (Get-EndpointDestination $_) -eq $client -and + ((Test-Present $_ "mms.confirmed_responsePDU") -or (Test-Present $_ "mms.confirmed_errorPDU")) + }) + + $requestRecords = @($requests | ForEach-Object { + [pscustomobject]@{ + Frame = [int](Get-RowValue $_ "frame.number") + TcpStream = Get-RowValue $_ "tcp.stream" + InvokeId = Get-RowValue $_ "mms.invokeID" + Service = Get-ServiceName $_ + Fingerprint = Get-RequestFingerprint $_ + } + }) + + $duplicateGroups = @($requestRecords | Group-Object Fingerprint | Where-Object Count -gt 1 | + Sort-Object -Property @{ Expression = "Count"; Descending = $true }, @{ Expression = "Name"; Descending = $false }) + $duplicateDetails = @($duplicateGroups | ForEach-Object { + $records = @($_.Group | Sort-Object Frame) + [pscustomobject]@{ + Service = $records[0].Service + DuplicateAttempts = $_.Count - 1 + Frames = @($records.Frame) + Fingerprint = $_.Name + } + }) + $duplicateRequests = Sum-IntProperty $duplicateDetails "DuplicateAttempts" + + $serviceCounts = [ordered]@{} + foreach ($group in ($requestRecords | Group-Object Service | Sort-Object Name)) { $serviceCounts[$group.Name] = $group.Count } + + $events = @() + foreach ($row in $requests) { + $events += [pscustomobject]@{ Frame = [int](Get-RowValue $row "frame.number"); Kind = "request"; InvokeId = Get-RowValue $row "mms.invokeID" } + } + foreach ($row in $responses) { + $events += [pscustomobject]@{ Frame = [int](Get-RowValue $row "frame.number"); Kind = "response"; InvokeId = Get-RowValue $row "mms.invokeID" } + } + + $outstanding = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $peakOutstanding = 0 + $invokeReuseWhileOutstanding = 0 + $orphanResponses = 0 + foreach ($event in ($events | Sort-Object Frame)) { + if (-not $event.InvokeId) { continue } + if ($event.Kind -eq "request") { + if (-not $outstanding.Add($event.InvokeId)) { $invokeReuseWhileOutstanding++ } + $peakOutstanding = [Math]::Max($peakOutstanding, $outstanding.Count) + } elseif (-not $outstanding.Remove($event.InvokeId)) { + $orphanResponses++ + } + } + + $negotiated = @($directionRows | ForEach-Object { Get-RowValue $_ "mms.negociatedMaxServOutstandingCalling" } | + Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ }) + $negotiatedCalling = if ($negotiated.Count -gt 0) { $negotiated[0] } else { $null } + $requestStreams = @($requestRecords.TcpStream | Where-Object { $_ } | Sort-Object -Unique) + $gnlDuplicates = @($duplicateDetails | Where-Object Service -eq "GetNameList") + $gvaDuplicates = @($duplicateDetails | Where-Object Service -eq "GetVariableAccessAttributes") + + return [pscustomobject]@{ + Capture = $Label + ClientIp = $client + ServerIp = $server + RequestTcpStreams = $requestStreams + ConfirmedRequests = $requestRecords.Count + ConfirmedResponsesOrErrors = $responses.Count + ServiceCounts = [pscustomobject]$serviceCounts + DuplicateSemanticRequests = $duplicateRequests + DuplicateGetNameListRequests = Sum-IntProperty $gnlDuplicates "DuplicateAttempts" + DuplicateGvaRequests = Sum-IntProperty $gvaDuplicates "DuplicateAttempts" + DuplicateDetails = $duplicateDetails + SecondGetNameListSweepDetected = $gnlDuplicates.Count -gt 0 + PeakOutstandingRequests = $peakOutstanding + NegotiatedMaxOutstandingCalling = $negotiatedCalling + InvokeIdReuseWhileOutstanding = $invokeReuseWhileOutstanding + OrphanResponses = $orphanResponses + UnansweredRequestsAtCaptureEnd = $outstanding.Count + } +} + +function Analyze-Pcap([string]$Capture, [string]$RequestedClientIp, [string]$RequestedServerIp) { + $command = Get-Command $TsharkPath -ErrorAction Stop + $fields = Get-TsharkFieldSet $command.Source + $rows = Decode-MmsRows $Capture $command.Source $fields + return Analyze-Rows $rows $Capture $RequestedClientIp $RequestedServerIp +} + +$pcap = Resolve-OptionalFile $PcapPath "P0-5d capture" +$decoded = Resolve-OptionalFile $DecodedRowsPath "P0-5d decoded rows" +$reference = Resolve-OptionalFile $ReferencePcapPath "Reference capture" +if (($null -eq $pcap) -eq ($null -eq $decoded)) { + throw "Supply exactly one of -PcapPath or -DecodedRowsPath." +} + +$actual = if ($decoded) { + Analyze-Rows (Import-DecodedRows $decoded) $decoded $ClientIp $ServerIp +} else { + Analyze-Pcap $pcap $ClientIp $ServerIp +} +$referenceAnalysis = if ($reference) { Analyze-Pcap $reference "" "" } else { $null } + +$failures = [System.Collections.Generic.List[string]]::new() +if ($actual.RequestTcpStreams.Count -ne 1) { $failures.Add("Expected exactly one MMS request TCP stream; observed $($actual.RequestTcpStreams.Count).") } +if ($actual.DuplicateSemanticRequests -ne 0) { $failures.Add("Duplicate semantic confirmed requests detected: $($actual.DuplicateSemanticRequests).") } +if ($actual.SecondGetNameListSweepDetected) { $failures.Add("A duplicate GetNameList semantic request was observed; this is evidence of a second/repeated naming sweep.") } +if ($actual.DuplicateGvaRequests -ne 0) { $failures.Add("Duplicate GetVariableAccessAttributes semantic requests detected: $($actual.DuplicateGvaRequests).") } +if ($actual.InvokeIdReuseWhileOutstanding -ne 0) { $failures.Add("Invoke-ID reuse while the previous request was still outstanding: $($actual.InvokeIdReuseWhileOutstanding).") } +if ($actual.OrphanResponses -ne 0) { $failures.Add("Responses/errors without an observed matching request: $($actual.OrphanResponses). Capture may be incomplete.") } +if ($actual.UnansweredRequestsAtCaptureEnd -ne 0) { $failures.Add("Confirmed requests still outstanding at capture end: $($actual.UnansweredRequestsAtCaptureEnd). Capture may have ended too early.") } +if ($null -ne $actual.NegotiatedMaxOutstandingCalling -and $actual.PeakOutstandingRequests -gt $actual.NegotiatedMaxOutstandingCalling) { + $failures.Add("Peak outstanding $($actual.PeakOutstandingRequests) exceeded negotiated maxOutstandingCalling $($actual.NegotiatedMaxOutstandingCalling).") +} +if ($MaxConfirmedRequests -gt 0 -and $actual.ConfirmedRequests -gt $MaxConfirmedRequests) { + $failures.Add("Confirmed request budget exceeded: $($actual.ConfirmedRequests) > $MaxConfirmedRequests.") +} +$gvaCount = 0 +if ($actual.ServiceCounts.PSObject.Properties["GetVariableAccessAttributes"]) { $gvaCount = [int]$actual.ServiceCounts.GetVariableAccessAttributes } +if ($MaxGvaRequests -gt 0 -and $gvaCount -gt $MaxGvaRequests) { $failures.Add("GVA request budget exceeded: $gvaCount > $MaxGvaRequests.") } +if ($RequireNoMoreRequestsThanReference -and $referenceAnalysis -and $actual.ConfirmedRequests -gt $referenceAnalysis.ConfirmedRequests) { + $failures.Add("ARSAS confirmed-request count $($actual.ConfirmedRequests) exceeds reference count $($referenceAnalysis.ConfirmedRequests).") +} + +$comparison = if ($referenceAnalysis) { + [pscustomobject]@{ + ReferenceCapture = $referenceAnalysis.Capture + ArsasConfirmedRequests = $actual.ConfirmedRequests + ReferenceConfirmedRequests = $referenceAnalysis.ConfirmedRequests + ConfirmedRequestDelta = $actual.ConfirmedRequests - $referenceAnalysis.ConfirmedRequests + ConfirmedRequestRatio = if ($referenceAnalysis.ConfirmedRequests -gt 0) { [Math]::Round($actual.ConfirmedRequests / $referenceAnalysis.ConfirmedRequests, 4) } else { $null } + ArsasPeakOutstanding = $actual.PeakOutstandingRequests + ReferencePeakOutstanding = $referenceAnalysis.PeakOutstandingRequests + ArsasDuplicateSemanticRequests = $actual.DuplicateSemanticRequests + ReferenceDuplicateSemanticRequests = $referenceAnalysis.DuplicateSemanticRequests + ArsasServiceCounts = $actual.ServiceCounts + ReferenceServiceCounts = $referenceAnalysis.ServiceCounts + } +} else { $null } + +$result = [pscustomobject]@{ + SchemaVersion = 1 + Phase = "P0-5d" + Verdict = if ($failures.Count -eq 0) { "PASS" } else { "FAIL" } + AcceptanceFailures = @($failures) + ArsasCapture = $actual + ReferenceComparison = $comparison + ProofContract = [pscustomobject]@{ + ExactlyOneMmsRequestStream = $true + DuplicateSemanticRequests = 0 + DuplicateGetNameListRequests = 0 + DuplicateGvaRequests = 0 + InvokeIdReuseWhileOutstanding = 0 + OrphanResponses = 0 + UnansweredRequestsAtCaptureEnd = 0 + PeakOutstandingMustNotExceedNegotiatedCallingLimit = $true + MaxConfirmedRequests = if ($MaxConfirmedRequests -gt 0) { $MaxConfirmedRequests } else { $null } + MaxGvaRequests = if ($MaxGvaRequests -gt 0) { $MaxGvaRequests } else { $null } + RequireNoMoreRequestsThanReference = [bool]$RequireNoMoreRequestsThanReference + } +} + +if ([string]::IsNullOrWhiteSpace($OutputJson)) { + $sourcePath = if ($pcap) { $pcap } else { $decoded } + $base = [IO.Path]::GetFileNameWithoutExtension($sourcePath) + $OutputJson = Join-Path ([IO.Path]::GetDirectoryName($sourcePath)) "P0-5D-$base-proof.json" +} +$result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputJson -Encoding utf8 + +Write-Host "P0-5d physical capture proof: $($result.Verdict)" +Write-Host " association: $($actual.ClientIp) -> $($actual.ServerIp); TCP stream(s): $($actual.RequestTcpStreams -join ', ')" +Write-Host " confirmed requests: $($actual.ConfirmedRequests); peak outstanding: $($actual.PeakOutstandingRequests); negotiated calling: $($actual.NegotiatedMaxOutstandingCalling)" +Write-Host " duplicates: semantic=$($actual.DuplicateSemanticRequests), GetNameList=$($actual.DuplicateGetNameListRequests), GVA=$($actual.DuplicateGvaRequests)" +Write-Host " service budget: $($actual.ServiceCounts | ConvertTo-Json -Compress)" +if ($comparison) { Write-Host " reference requests: $($comparison.ReferenceConfirmedRequests); delta=$($comparison.ConfirmedRequestDelta); ratio=$($comparison.ConfirmedRequestRatio)" } +Write-Host " proof JSON: $OutputJson" + +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Error $failure -ErrorAction Continue } + if (-not $NoFailExit) { exit 1 } +} diff --git a/scripts/verify-smart-discovery-post-merge-production.ps1 b/scripts/verify-smart-discovery-post-merge-production.ps1 new file mode 100644 index 000000000..b757cae26 --- /dev/null +++ b/scripts/verify-smart-discovery-post-merge-production.ps1 @@ -0,0 +1,105 @@ +param( + [Parameter(Mandatory=$true)][string]$MergeManifestPath, + [Parameter(Mandatory=$true)][string]$PromotionAuthorityPath, + [Parameter(Mandatory=$true)][string]$PromotionPropsPath, + [Parameter(Mandatory=$true)][string]$ArsasRepositoryPath, + [Parameter(Mandatory=$true)][string]$EngineRepositoryPath, + [Parameter(Mandatory=$true)][string]$OutputJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} +function Resolve-Directory([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Container)) { throw "$Label is not a directory: $Path" } + return $resolved.Path +} +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} +function Test-GitAncestor([string]$RepositoryPath, [string]$Ancestor, [string]$Descendant) { + & git -C $RepositoryPath merge-base --is-ancestor $Ancestor $Descendant 2>$null | Out-Null + return $LASTEXITCODE -eq 0 +} +function Get-GitHead([string]$RepositoryPath) { + $value = (& git -C $RepositoryPath rev-parse HEAD 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $value) { throw "Could not resolve Git HEAD for '$RepositoryPath'." } + return ([string]$value).Trim().ToLowerInvariant() +} +function Get-XmlChildText($Parent, [string]$Name) { + $node = @($Parent.ChildNodes | Where-Object { $_.Name -eq $Name } | Select-Object -First 1) + if ($node.Count -eq 0) { return '' } + return ([string]$node[0].InnerText).Trim() +} + +$manifestFile = Resolve-File $MergeManifestPath 'P0-5h merge manifest' +$promotionFile = Resolve-File $PromotionAuthorityPath 'P0-5g promotion authority' +$propsFile = Resolve-File $PromotionPropsPath 'production promotion props' +$arsasRepo = Resolve-Directory $ArsasRepositoryPath 'ARSAS main checkout' +$engineRepo = Resolve-Directory $EngineRepositoryPath 'ARIEC61850 main checkout' +$manifest = Get-Content -LiteralPath $manifestFile -Raw | ConvertFrom-Json +$promotion = Get-Content -LiteralPath $promotionFile -Raw | ConvertFrom-Json + +if ($manifest.Phase -ne 'P0-5h-merge-manifest' -or $manifest.Status -ne 'authorized-for-ordered-merge') { throw 'Post-merge verification requires an authorized P0-5h merge manifest.' } +if ($promotion.Phase -ne 'P0-5g-authority' -or $promotion.Status -ne 'production-promoted') { throw 'Post-merge verification requires production-promoted P0-5g authority.' } +if ([string]$manifest.MergeMethod -ne 'merge' -or @($manifest.MergeOrder) -join ',' -ne 'engine,arsas') { throw 'P0-5h requires merge-commit semantics and engine-first order.' } + +$validatedArsasHead = ([string]$manifest.Arsas.ValidatedHeadSha).ToLowerInvariant() +$expectedEngineHead = ([string]$manifest.Engine.ExpectedHeadSha).ToLowerInvariant() +Assert-Commit $validatedArsasHead 'manifest validated ARSAS head' +Assert-Commit $expectedEngineHead 'manifest engine head' +$currentArsasMain = Get-GitHead $arsasRepo +$currentEngineMain = Get-GitHead $engineRepo +$failures = [System.Collections.Generic.List[string]]::new() + +$engineAncestor = Test-GitAncestor $engineRepo $expectedEngineHead $currentEngineMain +$arsasAncestor = Test-GitAncestor $arsasRepo $validatedArsasHead $currentArsasMain +if (-not $engineAncestor) { $failures.Add('Validated engine PR head is not an ancestor of engine main.') } +if (-not $arsasAncestor) { $failures.Add('Validated ARSAS PR head is not an ancestor of ARSAS main.') } +if (([string]$promotion.EngineHeadCommit).ToLowerInvariant() -ne $expectedEngineHead) { $failures.Add('P0-5g promotion authority engine head differs from P0-5h merge manifest.') } +if (([string]$promotion.ArsasValidatedHeadCommit).ToLowerInvariant() -ne $validatedArsasHead) { $failures.Add('P0-5g promotion authority ARSAS head differs from P0-5h merge manifest.') } + +$promotionHash = (Get-FileHash -LiteralPath $promotionFile -Algorithm SHA256).Hash.ToLowerInvariant() +$manifestHash = (Get-FileHash -LiteralPath $manifestFile -Algorithm SHA256).Hash.ToLowerInvariant() +[xml]$props = Get-Content -LiteralPath $propsFile -Raw +$group = $props.Project.PropertyGroup +$promoted = (Get-XmlChildText $group 'SmartDiscoveryProductionPromoted').ToLowerInvariant() +$propsAuthority = (Get-XmlChildText $group 'SmartDiscoveryPromotionAuthoritySha256').ToLowerInvariant() +$propsEngine = (Get-XmlChildText $group 'SmartDiscoveryValidatedEngineHead').ToLowerInvariant() +if ($promoted -ne 'true') { $failures.Add('Production smart-discovery switch is not enabled on main.') } +if ($propsAuthority -ne $promotionHash) { $failures.Add('Production props are not bound to the merged P0-5g promotion authority.') } +if ($propsEngine -ne $expectedEngineHead) { $failures.Add('Production props validated engine head differs from merge manifest.') } + +$result = [ordered]@{ + SchemaVersion = 1 + Phase = 'P0-5h-post-merge' + Verdict = if ($failures.Count -eq 0) { 'PASS' } else { 'FAIL' } + MergeManifestSha256 = $manifestHash + PromotionAuthoritySha256 = $promotionHash + ValidatedArsasHead = $validatedArsasHead + CurrentArsasMainHead = $currentArsasMain + ExpectedEngineHead = $expectedEngineHead + CurrentEngineMainHead = $currentEngineMain + EngineHeadIsAncestorOfMain = $engineAncestor + ArsasValidatedHeadIsAncestorOfMain = $arsasAncestor + ProductionSwitchEnabled = $promoted -eq 'true' + AcceptanceFailures = @($failures) +} + +$outputDirectory = Split-Path -Parent $OutputJson +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputJson -Encoding utf8 +Write-Host "P0-5h post-merge production verification: $($result.Verdict)" +Write-Host " engine main: $currentEngineMain" +Write-Host " ARSAS main: $currentArsasMain" +Write-Host " attestation: $OutputJson" +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Error $failure -ErrorAction Continue } + exit 1 +} diff --git a/scripts/verify-smart-discovery-production-readiness.ps1 b/scripts/verify-smart-discovery-production-readiness.ps1 new file mode 100644 index 000000000..dba5d280d --- /dev/null +++ b/scripts/verify-smart-discovery-production-readiness.ps1 @@ -0,0 +1,303 @@ +param( + [Parameter(Mandatory=$true)][string]$TargetPath, + [Parameter(Mandatory=$true)][string]$EngineLockPath, + [Parameter(Mandatory=$true)][string]$PromotionPropsPath, + [Parameter(Mandatory=$true)][string]$ArsasRepositoryPath, + [Parameter(Mandatory=$true)][string]$EngineRepositoryPath, + [Parameter(Mandatory=$true)][string]$ArsasHeadCommit, + [Parameter(Mandatory=$true)][string]$EngineHeadCommit, + [Parameter(Mandatory=$true)][string]$EngineHeadCiConclusion, + [string]$PhysicalAuthorityPath, + [string]$PromotionAuthorityPath, + [string]$OutputJson, + [switch]$NoFailExit +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-File([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Leaf)) { throw "$Label is not a file: $Path" } + return $resolved.Path +} + +function Resolve-Directory([string]$Path, [string]$Label) { + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop + if (-not (Test-Path -LiteralPath $resolved.Path -PathType Container)) { throw "$Label is not a directory: $Path" } + return $resolved.Path +} + +function Assert-Commit([string]$Value, [string]$Label) { + if ($Value -notmatch '^[0-9a-fA-F]{40}$') { throw "$Label must be a full 40-character Git commit SHA." } +} + +function Get-GitHead([string]$RepositoryPath) { + $value = (& git -C $RepositoryPath rev-parse HEAD 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $value) { throw "Could not resolve Git HEAD for '$RepositoryPath'." } + return ([string]$value).Trim().ToLowerInvariant() +} + +function Test-GitAncestor([string]$RepositoryPath, [string]$Ancestor, [string]$Descendant) { + & git -C $RepositoryPath merge-base --is-ancestor $Ancestor $Descendant 2>$null | Out-Null + return $LASTEXITCODE -eq 0 +} + +function Get-GitChangedPaths([string]$RepositoryPath, [string]$BaseCommit, [string]$HeadCommit, [string[]]$PathFilters) { + $args = @('-C', $RepositoryPath, 'diff', '--name-only', "$BaseCommit..$HeadCommit", '--') + @($PathFilters) + $lines = @(& git @args 2>$null) + if ($LASTEXITCODE -ne 0) { throw "git diff failed for $BaseCommit..$HeadCommit." } + return @($lines | ForEach-Object { ([string]$_).Trim().Replace('\\','/') } | Where-Object { $_ } | Sort-Object -Unique) +} + +function Get-XmlChildText($Group, [string]$Name) { + if ($null -eq $Group) { return '' } + $node = @($Group.ChildNodes | Where-Object { $_.Name -eq $Name } | Select-Object -First 1) + if ($node.Count -eq 0 -or $null -eq $node[0]) { return '' } + return ([string]$node[0].InnerText).Trim() +} + +function Get-PromotionProps([string]$PropsFile) { + [xml]$xml = Get-Content -LiteralPath $PropsFile -Raw + $group = $xml.Project.PropertyGroup + $promotedText = (Get-XmlChildText $group 'SmartDiscoveryProductionPromoted').ToLowerInvariant() + if ([string]::IsNullOrWhiteSpace($promotedText)) { throw 'Promotion props does not define SmartDiscoveryProductionPromoted.' } + if ($promotedText -notin @('true','false')) { throw 'SmartDiscoveryProductionPromoted is not a boolean.' } + return [pscustomobject]@{ + Promoted = $promotedText -eq 'true' + EvidenceEngineCommit = (Get-XmlChildText $group 'SmartDiscoveryEvidenceEngineCommit').ToLowerInvariant() + Phase = Get-XmlChildText $group 'SmartDiscoveryPromotionPhase' + AuthoritySha256 = (Get-XmlChildText $group 'SmartDiscoveryPromotionAuthoritySha256').ToLowerInvariant() + ValidatedEngineHead = (Get-XmlChildText $group 'SmartDiscoveryValidatedEngineHead').ToLowerInvariant() + } +} + +function Get-SafeProperty($Object, [string]$Name) { + if ($null -eq $Object) { return $null } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + return $property.Value +} + +function Get-PhysicalAuthorityProvenanceErrors($Physical) { + $errors = [System.Collections.Generic.List[string]]::new() + $schema = Get-SafeProperty $Physical 'SchemaVersion' + $phase = [string](Get-SafeProperty $Physical 'Phase') + $status = [string](Get-SafeProperty $Physical 'Status') + if ($null -eq $schema -or [int]$schema -lt 1 -or $phase -ne 'P0-5f-authority' -or $status -ne 'physical-finalized') { + $errors.Add('P0-5f authority is not physical-finalized production evidence.') + return @($errors) + } + + $arsasCommit = [string](Get-SafeProperty $Physical 'ArsasCommit') + $engineCommit = [string](Get-SafeProperty $Physical 'EngineCommit') + if ($arsasCommit -notmatch '^[0-9a-fA-F]{40}$') { $errors.Add('P0-5f authority ARSAS commit is invalid.') } + if ($engineCommit -notmatch '^[0-9a-fA-F]{40}$') { $errors.Add('P0-5f authority engine commit is invalid.') } + + foreach ($name in @('GoldenLockSha256','RepeatTargetSha256','FinalizationSha256')) { + $value = [string](Get-SafeProperty $Physical $name) + if ($value -notmatch '^[0-9a-fA-F]{64}$') { $errors.Add("P0-5f authority $name is missing or invalid.") } + } + + $countValue = Get-SafeProperty $Physical 'IndependentAssociations' + $count = if ($null -eq $countValue) { 0 } else { [int]$countValue } + if ($count -lt 3) { $errors.Add('P0-5f authority must contain at least three independent associations.') } + + $sets = @( + @{ Label = 'association generations'; Values = @(Get-SafeProperty $Physical 'AssociationGenerations') }, + @{ Label = 'run bundle hashes'; Values = @(Get-SafeProperty $Physical 'RunBundleSha256') }, + @{ Label = 'capture hashes'; Values = @(Get-SafeProperty $Physical 'CaptureSha256') }, + @{ Label = 'runtime evidence hashes'; Values = @(Get-SafeProperty $Physical 'RuntimeEvidenceSha256') }) + foreach ($set in $sets) { + if ($set.Values.Count -ne $count) { $errors.Add("P0-5f authority $($set.Label) count differs from IndependentAssociations.") } + elseif (@($set.Values | Sort-Object -Unique).Count -ne $count) { $errors.Add("P0-5f authority contains reused $($set.Label).") } + } + foreach ($generation in @($sets[0].Values)) { + if ([long]$generation -le 0) { $errors.Add('P0-5f authority contains an invalid association generation.'); break } + } + foreach ($hash in @($sets[1].Values + $sets[2].Values + $sets[3].Values)) { + if ([string]$hash -notmatch '^[0-9a-fA-F]{64}$') { $errors.Add('P0-5f authority contains an invalid evidence SHA-256.'); break } + } + if ($null -eq (Get-SafeProperty $Physical 'Consensus')) { $errors.Add('P0-5f authority is missing consensus evidence.') } + return @($errors) +} + +$targetFile = Resolve-File $TargetPath 'P0-5g promotion target' +$engineLockFile = Resolve-File $EngineLockPath 'ARIEC61850 engine lock' +$propsFile = Resolve-File $PromotionPropsPath 'P0-5g promotion props' +$arsasRepo = Resolve-Directory $ArsasRepositoryPath 'ARSAS repository' +$engineRepo = Resolve-Directory $EngineRepositoryPath 'ARIEC61850 repository' +Assert-Commit $ArsasHeadCommit 'ARSAS head commit' +Assert-Commit $EngineHeadCommit 'Engine head commit' +$arsasHead = $ArsasHeadCommit.ToLowerInvariant() +$engineHead = $EngineHeadCommit.ToLowerInvariant() + +$target = Get-Content -LiteralPath $targetFile -Raw | ConvertFrom-Json +$engineLock = Get-Content -LiteralPath $engineLockFile -Raw | ConvertFrom-Json +$promotionProps = Get-PromotionProps $propsFile +$targetHash = (Get-FileHash -LiteralPath $targetFile -Algorithm SHA256).Hash.ToLowerInvariant() +$engineLockHash = (Get-FileHash -LiteralPath $engineLockFile -Algorithm SHA256).Hash.ToLowerInvariant() +$blockers = [System.Collections.Generic.List[string]]::new() +$warnings = [System.Collections.Generic.List[string]]::new() + +if ($target.Phase -ne 'P0-5g') { $blockers.Add('Promotion target is not P0-5g authority.') } +if ([string]$target.EngineRepository -ne 'masarray/ARIEC61850' -or [int]$target.EnginePullRequest -ne 134) { + $blockers.Add('Promotion target engine repository/PR authority changed unexpectedly.') +} +$baseline = ([string]$target.EvidenceEngineBaselineCommit).ToLowerInvariant() +Assert-Commit $baseline 'Evidence engine baseline commit' + +if ((Get-GitHead $arsasRepo) -ne $arsasHead) { $blockers.Add('ARSAS repository HEAD differs from the supplied readiness head.') } +if ((Get-GitHead $engineRepo) -ne $engineHead) { $blockers.Add('Engine repository HEAD differs from the supplied PR head.') } +if ([string]$engineLock.repository -ne [string]$target.EngineRepository) { $blockers.Add('ARSAS engine lock repository differs from the promotion target.') } +if (([string]$engineLock.commit).ToLowerInvariant() -ne $baseline) { + $blockers.Add('ARSAS engine lock no longer points at the physical-evidence engine baseline.') +} +if ($promotionProps.EvidenceEngineCommit -and $promotionProps.EvidenceEngineCommit -ne $baseline) { + $blockers.Add('Promotion props evidence engine commit differs from the P0-5g baseline.') +} +if ($promotionProps.Phase -and $promotionProps.Phase -ne 'P0-5g') { + $blockers.Add('Promotion props phase differs from P0-5g.') +} + +$engineIsDescendant = Test-GitAncestor $engineRepo $baseline $engineHead +if (-not $engineIsDescendant) { + $blockers.Add('Engine PR head is not a descendant of the physical-evidence engine baseline.') +} + +$criticalPaths = @($target.DiscoveryCriticalEnginePaths | ForEach-Object { [string]$_ }) +$criticalChanges = @() +if ($engineIsDescendant) { + $criticalChanges = @(Get-GitChangedPaths $engineRepo $baseline $engineHead $criticalPaths) + if ($criticalChanges.Count -gt 0) { + $blockers.Add("Engine head changed discovery-critical evidence paths after the physical baseline: $($criticalChanges -join ', ').") + } +} + +if ($EngineHeadCiConclusion.Trim().ToLowerInvariant() -ne 'success') { + $blockers.Add("Engine PR head CI is not green: '$EngineHeadCiConclusion'.") +} + +$productionSwitch = [bool]$promotionProps.Promoted +$physicalAuthority = $null +$physicalAuthorityFile = $null +if ([string]::IsNullOrWhiteSpace($PhysicalAuthorityPath) -or -not (Test-Path -LiteralPath $PhysicalAuthorityPath -PathType Leaf)) { + $blockers.Add('P0-5f physical-finalized authority is missing.') +} else { + $physicalAuthorityFile = Resolve-File $PhysicalAuthorityPath 'P0-5f physical authority' + $physicalAuthority = Get-Content -LiteralPath $physicalAuthorityFile -Raw | ConvertFrom-Json + foreach ($error in @(Get-PhysicalAuthorityProvenanceErrors $physicalAuthority)) { $blockers.Add($error) } + $physicalEngine = [string](Get-SafeProperty $physicalAuthority 'EngineCommit') + if ($physicalEngine -and $physicalEngine.ToLowerInvariant() -ne $baseline) { + $blockers.Add('P0-5f authority engine commit differs from the evidence baseline.') + } + + $authorityArsas = [string](Get-SafeProperty $physicalAuthority 'ArsasCommit') + if ($authorityArsas -notmatch '^[0-9a-fA-F]{40}$') { + $blockers.Add('P0-5f authority does not contain a valid ARSAS commit.') + } elseif (-not (Test-GitAncestor $arsasRepo $authorityArsas.ToLowerInvariant() $arsasHead)) { + $blockers.Add('Current ARSAS head is not a descendant of the physically validated ARSAS commit.') + } else { + $allChanged = @(Get-GitChangedPaths $arsasRepo $authorityArsas.ToLowerInvariant() $arsasHead @('.')) + $allowed = @($target.AllowedPostPhysicalAuthorityPaths | ForEach-Object { ([string]$_).Replace('\\','/') }) + $notAllowed = @($allChanged | Where-Object { $allowed -notcontains $_ }) + if ($notAllowed.Count -gt 0) { + $blockers.Add("Runtime/source changed after physical authority outside the promotion-only allowlist: $($notAllowed -join ', ').") + } + } +} + +$promotionAuthority = $null +$promotionAuthorityFile = $null +if (-not [string]::IsNullOrWhiteSpace($PromotionAuthorityPath) -and (Test-Path -LiteralPath $PromotionAuthorityPath -PathType Leaf)) { + $promotionAuthorityFile = Resolve-File $PromotionAuthorityPath 'P0-5g production authority' + $promotionAuthority = Get-Content -LiteralPath $promotionAuthorityFile -Raw | ConvertFrom-Json + if ($promotionAuthority.Phase -ne 'P0-5g-authority' -or $promotionAuthority.Status -ne 'production-promoted') { + $blockers.Add('P0-5g promotion authority is not production-promoted.') + } + if (([string]$promotionAuthority.EvidenceEngineBaselineCommit).ToLowerInvariant() -ne $baseline) { + $blockers.Add('P0-5g promotion authority evidence baseline differs from the current target.') + } + if (([string]$promotionAuthority.PromotionTargetSha256).ToLowerInvariant() -ne $targetHash) { + $blockers.Add('P0-5g promotion authority is bound to a different promotion target.') + } + if (([string]$promotionAuthority.EngineLockSha256).ToLowerInvariant() -ne $engineLockHash) { + $blockers.Add('P0-5g promotion authority is bound to a different engine lock.') + } + if ($null -eq $physicalAuthorityFile) { + $blockers.Add('P0-5g promotion authority exists without P0-5f physical authority.') + } else { + $physicalHash = (Get-FileHash -LiteralPath $physicalAuthorityFile -Algorithm SHA256).Hash.ToLowerInvariant() + if (([string]$promotionAuthority.PhysicalAuthoritySha256).ToLowerInvariant() -ne $physicalHash) { + $blockers.Add('P0-5g promotion authority is bound to a different P0-5f physical authority.') + } + } + if (([string]$promotionAuthority.EngineHeadCommit).ToLowerInvariant() -ne $engineHead) { + $blockers.Add('P0-5g promotion authority is bound to a different engine PR head.') + } + if (-not $productionSwitch) { + $blockers.Add('P0-5g authority exists but the tracked production switch is still false.') + } else { + $authorityHash = (Get-FileHash -LiteralPath $promotionAuthorityFile -Algorithm SHA256).Hash.ToLowerInvariant() + if ($promotionProps.AuthoritySha256 -notmatch '^[0-9a-f]{64}$') { + $blockers.Add('Production promotion props do not contain a valid promotion-authority SHA-256.') + } elseif ($promotionProps.AuthoritySha256 -ne $authorityHash) { + $blockers.Add('Production promotion props are bound to a different P0-5g promotion authority.') + } + if ($promotionProps.ValidatedEngineHead -notmatch '^[0-9a-f]{40}$') { + $blockers.Add('Production promotion props do not contain a valid validated engine head.') + } elseif ($promotionProps.ValidatedEngineHead -ne $engineHead) { + $blockers.Add('Production promotion props are bound to a different validated engine head.') + } + } +} elseif ($productionSwitch) { + $blockers.Add('Production switch is true without a tracked P0-5g promotion authority.') +} + +$status = 'BLOCKED' +if ($blockers.Count -eq 0) { + $status = if ($null -ne $promotionAuthority) { 'READY_FOR_REVIEW' } else { 'READY_TO_PROMOTE' } +} +if ($status -eq 'READY_TO_PROMOTE' -and $productionSwitch) { + $blockers.Add('Production switch must remain false until promotion authority is generated.') + $status = 'BLOCKED' +} + +$result = [ordered]@{ + SchemaVersion = 4 + Phase = 'P0-5g' + Verdict = $status + ArsasHeadCommit = $arsasHead + EngineEvidenceBaselineCommit = $baseline + EngineHeadCommit = $engineHead + EngineHeadCiConclusion = $EngineHeadCiConclusion + EngineHeadIsEvidenceCompatibleDescendant = $engineIsDescendant -and $criticalChanges.Count -eq 0 + DiscoveryCriticalChanges = @($criticalChanges) + PromotionTargetSha256 = $targetHash + EngineLockSha256 = $engineLockHash + ProductionSwitchEnabled = $productionSwitch + PromotionAuthoritySha256 = $promotionProps.AuthoritySha256 + PromotionValidatedEngineHead = $promotionProps.ValidatedEngineHead + PhysicalAuthorityPath = $physicalAuthorityFile + PromotionAuthorityPath = $promotionAuthorityFile + Blockers = @($blockers) + Warnings = @($warnings) +} + +if ([string]::IsNullOrWhiteSpace($OutputJson)) { + $OutputJson = Join-Path $arsasRepo 'P0-5G-production-readiness.json' +} +$outputDirectory = Split-Path -Parent $OutputJson +if ($outputDirectory) { New-Item -ItemType Directory -Force $outputDirectory | Out-Null } +$result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputJson -Encoding utf8 + +Write-Host "P0-5g production readiness: $($result.Verdict)" +Write-Host " ARSAS head: $arsasHead" +Write-Host " engine baseline: $baseline" +Write-Host " engine head: $engineHead" +Write-Host " critical engine changes: $($criticalChanges.Count)" +Write-Host " production switch: $productionSwitch" +foreach ($blocker in $blockers) { Write-Host " BLOCKER: $blocker" } +Write-Host " readiness JSON: $OutputJson" + +if ($result.Verdict -eq 'BLOCKED' -and -not $NoFailExit) { exit 1 } diff --git a/tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs b/tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs new file mode 100644 index 000000000..af1a6147f --- /dev/null +++ b/tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs @@ -0,0 +1,131 @@ +namespace ARSAS.Tests; + +public sealed class RcbExportEvidenceAcquisitionRegressionTests +{ + [Fact] + public void LiveExport_SelfAcquiresFcdaEvidence_WhenBoundDataSetIsIncomplete() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.RcbExport.cs")); + + Assert.Contains("FCDA evidence missing", source, StringComparison.Ordinal); + Assert.Contains("_rcbAvailabilityProbe", source, StringComparison.Ordinal); + Assert.Contains(".CheckAsync(device, evidenceTimeout.Token)", source, StringComparison.Ordinal); + Assert.Contains("evidenceTimeout.CancelAfter(TimeSpan.FromSeconds(30))", source, StringComparison.Ordinal); + Assert.Contains("MergeSelectedDataSetDirectory", source, StringComparison.Ordinal); + + Assert.DoesNotContain( + "Run Check Availability to browse DataSet directories, then export again.", + source, + StringComparison.Ordinal); + } + + [Fact] + public void LiveExport_ReusesExistingAvailability_BeforeOpeningFallbackAudit() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.RcbExport.cs")); + + var reuseIndex = source.IndexOf( + "if (effectiveAvailability != null)", + StringComparison.Ordinal); + var fallbackProbeIndex = source.IndexOf( + "effectiveAvailability = await _rcbAvailabilityProbe", + StringComparison.Ordinal); + + Assert.True(reuseIndex >= 0, "Existing availability evidence must be considered first."); + Assert.True(fallbackProbeIndex > reuseIndex, + "Fallback MMS audit must only occur after existing availability evidence has been considered."); + } + + [Fact] + public void LiveExport_RefusesSerialization_WhenAuthoritativeDirectoryStillHasNoFcdaMembers() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.RcbExport.cs")); + + var rejectionIndex = source.IndexOf( + "authoritative read-only MMS audit did not return any FCDA members. The CID was not written.", + StringComparison.Ordinal); + var serializationIndex = source.IndexOf( + "AuthoritativeLiveIedSclExporter.WriteFiles", + StringComparison.Ordinal); + + Assert.True(rejectionIndex >= 0, "Incomplete FCDA evidence must have an explicit export rejection gate."); + Assert.True(serializationIndex > rejectionIndex, + "The incomplete-evidence rejection gate must execute before SCL serialization."); + } + + [Fact] + public void LiveExport_ReResolvesDataSetReference_AfterLiveRcbEvidenceMerge() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.RcbExport.cs")); + + var mergeIndex = source.IndexOf( + "MergeSelectedReportControlEvidence", + StringComparison.Ordinal); + var resolveIndex = source.IndexOf( + "ResolveExportDataSetReference(exportModel, row)", + StringComparison.Ordinal); + + Assert.True(mergeIndex >= 0, "Live RCB evidence must be merged into the export model."); + Assert.True(resolveIndex > mergeIndex, + "The effective DataSet reference must be resolved from the merged live model, not stale UI evidence."); + } + + [Fact] + public void LiveExport_DoesNotResurrectStaleRowBinding_WhenLiveDatSetIsEmpty() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.RcbExport.cs")); + + Assert.Contains( + "return selectedReportControl is not null", + source, + StringComparison.Ordinal); + Assert.Contains( + "? (selectedReportControl.DataSetReference ?? string.Empty).Trim()", + source, + StringComparison.Ordinal); + Assert.DoesNotContain( + "return !string.IsNullOrWhiteSpace(selectedReportControl?.DataSetReference)", + source, + StringComparison.Ordinal); + } + + [Fact] + public void LiveExport_RefreshesResolvedDataSet_AfterFinalAuthoritativeRcbMerge() + { + var source = File.ReadAllText(FindRepoFile("MainWindow.RcbExport.cs")); + const string finalMergeComment = "If the probe proved a dynamic RCB is currently unbound"; + + var finalMergeIndex = source.IndexOf(finalMergeComment, StringComparison.Ordinal); + var resolveIndex = source.IndexOf( + "effectiveDataSetReference = ResolveExportDataSetReference(exportModel, row);", + finalMergeIndex, + StringComparison.Ordinal); + var findIndex = source.IndexOf( + "selectedDataSet = FindExportDataSet(exportModel, effectiveDataSetReference);", + resolveIndex, + StringComparison.Ordinal); + var serializationIndex = source.IndexOf( + "AuthoritativeLiveIedSclExporter.WriteFiles", + StringComparison.Ordinal); + + Assert.True(finalMergeIndex >= 0, "Final authoritative RCB merge guard must remain present."); + Assert.True(resolveIndex > finalMergeIndex, "DataSet reference must be recalculated after the final live merge."); + Assert.True(findIndex > resolveIndex, "DataSet membership must be recalculated from the final reference."); + Assert.True(serializationIndex > findIndex, "Final binding refresh must happen before serialization."); + } + + private static string FindRepoFile(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( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs new file mode 100644 index 000000000..c358e93f3 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs @@ -0,0 +1,73 @@ +using System.Text.Json; + +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryAssociationSingleFlightRegressionTests +{ + private const string P05bEngineCommit = "4467124775d8d9d76f3db194f9fbfd97144767a8"; + + [Fact] + public void P05c_CompleteEnrichmentChain_IsAssociationScopedSingleFlight() + { + var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); + var lifecycle = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs")); + + Assert.Contains("GetOrCreateSmartDiscoveryAssociationFlight", capture, StringComparison.Ordinal); + Assert.Contains("RunSmartDiscoveryAssociationFlightAsync", capture, StringComparison.Ordinal); + Assert.Contains("flight.WaitAsync(cancellationToken)", capture, StringComparison.Ordinal); + Assert.Contains("_mmsIoGate.WaitAsync(CancellationToken.None)", capture, StringComparison.Ordinal); + Assert.Contains("DiscoverSmartSingleFlightAsync(smartOptions, CancellationToken.None)", capture, StringComparison.Ordinal); + Assert.Contains("ProbeSmartAsync(_session, discovery.IedDirectory, smartOptions, CancellationToken.None)", capture, StringComparison.Ordinal); + Assert.DoesNotContain("ProbeSmartAsync(_session, discovery.IedDirectory, smartOptions, cancellationToken)", capture, StringComparison.Ordinal); + + Assert.Contains("_smartDiscoveryAssociationFlight", lifecycle, StringComparison.Ordinal); + Assert.Contains("_smartDiscoveryFlightGeneration", lifecycle, StringComparison.Ordinal); + Assert.Contains("ownerFactory(generation)", lifecycle, StringComparison.Ordinal); + Assert.Contains("ReferenceEquals(_smartDiscoveryAssociationFlight, flight)", lifecycle, StringComparison.Ordinal); + } + + [Fact] + public void P05c_ReconnectAndDispose_InvalidateGenerationAndBlockStalePublish() + { + var lifecycle = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs")); + var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); + var patcher = File.ReadAllText(FindRepoFile("scripts/enable-smart-discovery-capture.ps1")); + + Assert.Contains("_smartDiscoveryAssociationGeneration++", lifecycle, StringComparison.Ordinal); + Assert.Contains("generation != _smartDiscoveryAssociationGeneration", lifecycle, StringComparison.Ordinal); + Assert.Contains("TryPublishSmartDiscoveryAuthority", lifecycle, StringComparison.Ordinal); + Assert.Contains("IsCurrentSmartDiscoveryAssociationGeneration", capture, StringComparison.Ordinal); + Assert.Contains("__P0_5C_CONNECT_RESET__", patcher, StringComparison.Ordinal); + Assert.Contains("__P0_5C_DISPOSE_RESET__", patcher, StringComparison.Ordinal); + Assert.Contains("ResetSmartDiscoveryAuthority(); // __P0_5C_DISPOSE_RESET__", patcher, StringComparison.Ordinal); + } + + [Fact] + public void P05c_EnginePin_IsExactP05bBudgetConvergenceCommit() + { + var lockPath = FindRepoFile("engines/ARIEC61850.lock.json"); + using var document = JsonDocument.Parse(File.ReadAllText(lockPath)); + var commit = document.RootElement.GetProperty("commit").GetString(); + var workflow = File.ReadAllText(FindRepoFile(".github/workflows/smart-discovery-capture-build.yml")); + + Assert.Equal(P05bEngineCommit, commit); + Assert.Contains(P05bEngineCommit, workflow, StringComparison.OrdinalIgnoreCase); + Assert.Contains("LastSmartTypeProbeBudget", workflow, StringComparison.Ordinal); + Assert.Contains("SuppressedExactRepeatRequests", workflow, StringComparison.Ordinal); + } + + private static string FindRepoFile(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( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/SmartDiscoveryGoldenBudgetLockRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryGoldenBudgetLockRegressionTests.cs new file mode 100644 index 000000000..28ffe4a75 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryGoldenBudgetLockRegressionTests.cs @@ -0,0 +1,84 @@ +using System.Text.Json; + +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryGoldenBudgetLockRegressionTests +{ + private const string P05bEngineCommit = "4467124775d8d9d76f3db194f9fbfd97144767a8"; + + [Fact] + public void P05e_TargetLocksSameIedSemanticAuthorityWithoutInventingWireBudget() + { + var path = FindRepoFile("evidence/smart-discovery-golden-target.json"); + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var root = document.RootElement; + + Assert.Equal("P0-5e", root.GetProperty("Phase").GetString()); + Assert.Equal("awaiting-fresh-physical-budget-lock", root.GetProperty("Status").GetString()); + Assert.Equal("AA1E1F06R4", root.GetProperty("DeviceIdentity").GetString()); + Assert.Equal(P05bEngineCommit, root.GetProperty("EngineCommit").GetString()); + + var target = root.GetProperty("SemanticTarget"); + Assert.Equal(32, target.GetProperty("LogicalDevices").GetInt32()); + Assert.Equal(119, target.GetProperty("LogicalNodes").GetInt32()); + Assert.Equal(4925, target.GetProperty("SemanticLeaves").GetInt32()); + Assert.Equal(2, target.GetProperty("DataSets").GetInt32()); + Assert.Equal(58, target.GetProperty("OrderedFcdaMembers").GetInt32()); + Assert.Equal(32, target.GetProperty("LogicalReportControls").GetInt32()); + Assert.Equal(34, target.GetProperty("RuntimeReportControlInstances").GetInt32()); + Assert.Equal(2, target.GetProperty("IndexedBufferedFamilyMax").GetInt32()); + Assert.Equal(2, target.GetProperty("IndexedUnbufferedFamilyMax").GetInt32()); + Assert.Equal(0, target.GetProperty("SyntheticReportControlInstancesAllowed").GetInt32()); + + var budgetAuthority = root.GetProperty("BudgetAuthority"); + Assert.Equal(JsonValueKind.Null, budgetAuthority.GetProperty("BudgetValues").ValueKind); + Assert.Equal("P0-5d", budgetAuthority.GetProperty("RequiredProofPhase").GetString()); + Assert.Equal("PASS", budgetAuthority.GetProperty("RequiredProofVerdict").GetString()); + Assert.True(budgetAuthority.GetProperty("RequireRawCaptureSha256").GetBoolean()); + } + + [Fact] + public void P05e_LockWriterDerivesBudgetFromPassProofAndHashesRawCapture() + { + var source = File.ReadAllText(FindRepoFile("scripts/new-smart-discovery-golden-lock.ps1")); + + Assert.Contains("P0-5d", source, StringComparison.Ordinal); + Assert.Contains("$proof.Verdict -ne 'PASS'", source, StringComparison.Ordinal); + Assert.Contains("Get-FileHash -LiteralPath $capture -Algorithm SHA256", source, StringComparison.Ordinal); + Assert.Contains("Get-FileHash -LiteralPath $proofPath -Algorithm SHA256", source, StringComparison.Ordinal); + Assert.Contains("MaxConfirmedRequests = $confirmedRequests", source, StringComparison.Ordinal); + Assert.Contains("MaxServiceRequests = $serviceBudget", source, StringComparison.Ordinal); + Assert.Contains("ForbidUnexpectedServices = $true", source, StringComparison.Ordinal); + Assert.Contains("ForbidSecondGetNameListSweep = $true", source, StringComparison.Ordinal); + Assert.DoesNotContain("MaxConfirmedRequests = 100", source, StringComparison.Ordinal); + } + + [Fact] + public void P05e_AcceptanceRejectsBudgetGrowthUnexpectedServicesAndIdentityMismatch() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-golden-lock.ps1")); + + Assert.Contains("Confirmed request budget exceeded", source, StringComparison.Ordinal); + Assert.Contains("Service budget exceeded", source, StringComparison.Ordinal); + Assert.Contains("Unexpected MMS service", source, StringComparison.Ordinal); + Assert.Contains("Second GetNameList sweep is forbidden", source, StringComparison.Ordinal); + Assert.Contains("Device identity mismatch", source, StringComparison.Ordinal); + Assert.Contains("AllowDifferentEngineCommit", source, StringComparison.Ordinal); + Assert.Contains("Candidate evidence must be a P0-5d PASS proof", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(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( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/SmartDiscoveryGoldenProvenanceRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryGoldenProvenanceRegressionTests.cs new file mode 100644 index 000000000..376e5c9c4 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryGoldenProvenanceRegressionTests.cs @@ -0,0 +1,51 @@ +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryGoldenProvenanceRegressionTests +{ + [Fact] + public void P05e_WriterReverifiesRawCaptureAndBindsExactBuildAndTarget() + { + var source = File.ReadAllText(FindRepoFile("scripts/new-smart-discovery-golden-lock.ps1")); + + Assert.Contains("verify-smart-discovery-pcap.ps1", source, StringComparison.Ordinal); + Assert.Contains("Assert-EquivalentWireProof", source, StringComparison.Ordinal); + Assert.Contains("Production golden lock requires a raw .pcap or .pcapng capture", source, StringComparison.Ordinal); + Assert.Contains("BuildManifestPath", source, StringComparison.Ordinal); + Assert.Contains("ARSAS commit:", source, StringComparison.Ordinal); + Assert.Contains("ARIEC61850 commit:", source, StringComparison.Ordinal); + Assert.Contains("BuildManifestSha256", source, StringComparison.Ordinal); + Assert.Contains("SemanticTargetSha256", source, StringComparison.Ordinal); + Assert.Contains("RawCaptureReverified", source, StringComparison.Ordinal); + Assert.Contains("AllowFixtureEvidence", source, StringComparison.Ordinal); + } + + [Fact] + public void P05e_VerifierRequiresExactArsasEngineAndSemanticTargetByDefault() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-golden-lock.ps1")); + + Assert.Contains("CandidateArsasCommit", source, StringComparison.Ordinal); + Assert.Contains("CandidateEngineCommit", source, StringComparison.Ordinal); + Assert.Contains("Candidate ARSAS commit differs from the golden build baseline", source, StringComparison.Ordinal); + Assert.Contains("Candidate engine commit differs from the golden engine baseline", source, StringComparison.Ordinal); + Assert.Contains("Semantic target hash differs from the golden lock authority", source, StringComparison.Ordinal); + Assert.Contains("BuildManifestSha256", source, StringComparison.Ordinal); + Assert.Contains("AllowDifferentArsasCommit", source, StringComparison.Ordinal); + Assert.Contains("AllowDifferentEngineCommit", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(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( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs new file mode 100644 index 000000000..edcdf327a --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryMainlineMergeExecutionRegressionTests.cs @@ -0,0 +1,97 @@ +using System.Text.Json; + +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryMainlineMergeExecutionRegressionTests +{ + [Fact] + public void P05h_TargetLocksOrderedMergeCommitExecution() + { + using var document = JsonDocument.Parse(File.ReadAllText(FindRepoFile("evidence/smart-discovery-mainline-merge-target.json"))); + var root = document.RootElement; + + Assert.Equal("P0-5h", root.GetProperty("Phase").GetString()); + Assert.Equal("merge", root.GetProperty("MergeMethod").GetString()); + Assert.Equal(new[] { "engine", "arsas" }, root.GetProperty("MergeOrder").EnumerateArray().Select(x => x.GetString()).ToArray()); + + var contract = root.GetProperty("ExecutionContract"); + Assert.True(contract.GetProperty("RequireP05gReadyForReview").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactExpectedHeadShaOnMerge").GetBoolean()); + Assert.True(contract.GetProperty("RequireBaseShaUnchangedFromMergeManifest").GetBoolean()); + Assert.True(contract.GetProperty("RequireEngineMergeBeforeArsasMerge").GetBoolean()); + Assert.True(contract.GetProperty("RequireMergeCommitMethod").GetBoolean()); + Assert.True(contract.GetProperty("RequireOnlyMergeManifestChangeAfterValidatedArsasHead").GetBoolean()); + Assert.True(contract.GetProperty("ForbidFixtureOrForceBypass").GetBoolean()); + } + + [Fact] + public void P05h_ManifestWriterHasNoBypassAndRequiresProductionAuthorities() + { + var source = File.ReadAllText(FindRepoFile("scripts/new-smart-discovery-mainline-merge-manifest.ps1")); + + Assert.Contains("READY_FOR_REVIEW", source, StringComparison.Ordinal); + Assert.Contains("physical-finalized", source, StringComparison.Ordinal); + Assert.Contains("production-promoted", source, StringComparison.Ordinal); + Assert.Contains("ValidatedHeadSha", source, StringComparison.Ordinal); + Assert.Contains("BaseShaAtAuthorization", source, StringComparison.Ordinal); + Assert.Contains("LiveMergeHeadSha = $null", source, StringComparison.Ordinal); + Assert.Contains("AllowedPostAuthorizationPaths", source, StringComparison.Ordinal); + Assert.Contains("MergeMethod = 'merge'", source, StringComparison.Ordinal); + Assert.Contains("arsas-live-head-resolved-at-execution", source, StringComparison.Ordinal); + Assert.Contains("engine-merge-first", source, StringComparison.Ordinal); + Assert.DoesNotContain("AllowFixtureEvidence", source, StringComparison.Ordinal); + } + + [Fact] + public void P05h_LivePreflightRejectsHeadBaseAndPostAuthorizationDrift() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-live-merge-preflight.ps1")); + + Assert.Contains("ARSAS base SHA changed after merge authorization", source, StringComparison.Ordinal); + Assert.Contains("Engine base SHA changed after merge authorization", source, StringComparison.Ordinal); + Assert.Contains("Engine PR head changed after merge authorization", source, StringComparison.Ordinal); + Assert.Contains("Live ARSAS PR head is not a descendant", source, StringComparison.Ordinal); + Assert.Contains("outside merge-manifest allowlist", source, StringComparison.Ordinal); + Assert.Contains("P0-5h-live-preflight", source, StringComparison.Ordinal); + Assert.Contains("git -C $RepositoryPath diff --name-only", source, StringComparison.Ordinal); + } + + [Fact] + public void P05h_PostMergeVerifierRequiresValidatedHeadsOnMainAndAuthorityBinding() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-post-merge-production.ps1")); + + Assert.Contains("merge-base --is-ancestor", source, StringComparison.Ordinal); + Assert.Contains("Validated engine PR head is not an ancestor of engine main", source, StringComparison.Ordinal); + Assert.Contains("Validated ARSAS PR head is not an ancestor of ARSAS main", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryProductionPromoted", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryPromotionAuthoritySha256", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryValidatedEngineHead", source, StringComparison.Ordinal); + Assert.Contains("P0-5h-post-merge", source, StringComparison.Ordinal); + } + + [Fact] + public void P05g_PostPhysicalAllowlistPermitsOnlyTheP05hManifestAddition() + { + using var document = JsonDocument.Parse(File.ReadAllText(FindRepoFile("evidence/smart-discovery-production-promotion-target.json"))); + var paths = document.RootElement.GetProperty("AllowedPostPhysicalAuthorityPaths") + .EnumerateArray().Select(x => x.GetString()).ToArray(); + + Assert.Contains("evidence/smart-discovery-mainline-merge-manifest.json", paths); + Assert.DoesNotContain(paths, path => path is not null && path.StartsWith("Services/", StringComparison.Ordinal)); + } + + private static string FindRepoFile(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($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/ARSAS.Tests/SmartDiscoveryPhysicalCaptureProofRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryPhysicalCaptureProofRegressionTests.cs new file mode 100644 index 000000000..d00be15b4 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryPhysicalCaptureProofRegressionTests.cs @@ -0,0 +1,79 @@ +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryPhysicalCaptureProofRegressionTests +{ + [Fact] + public void P05d_Verifier_FingerprintsSemanticRequestsAndRejectsDuplicateDiscoveryTraffic() + { + var verifier = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-pcap.ps1")); + + Assert.Contains("mms.confirmed_requestPDU", verifier, StringComparison.Ordinal); + Assert.Contains("mms.confirmed_responsePDU", verifier, StringComparison.Ordinal); + Assert.Contains("mms.confirmed_errorPDU", verifier, StringComparison.Ordinal); + Assert.Contains("mms.getNameList_element", verifier, StringComparison.Ordinal); + Assert.Contains("mms.getVariableAccessAttributes_element", verifier, StringComparison.Ordinal); + Assert.Contains("mms.getNamedVariableListAttributes_element", verifier, StringComparison.Ordinal); + Assert.Contains("mms.read_element", verifier, StringComparison.Ordinal); + Assert.Contains("Get-RequestFingerprint", verifier, StringComparison.Ordinal); + Assert.Contains("DuplicateSemanticRequests", verifier, StringComparison.Ordinal); + Assert.Contains("DuplicateGetNameListRequests", verifier, StringComparison.Ordinal); + Assert.Contains("DuplicateGvaRequests", verifier, StringComparison.Ordinal); + Assert.Contains("SecondGetNameListSweepDetected", verifier, StringComparison.Ordinal); + + var fingerprintStart = verifier.IndexOf("function Get-RequestFingerprint", StringComparison.Ordinal); + var fingerprintEnd = verifier.IndexOf("function Decode-MmsRows", fingerprintStart, StringComparison.Ordinal); + Assert.True(fingerprintStart >= 0 && fingerprintEnd > fingerprintStart); + var fingerprint = verifier[fingerprintStart..fingerprintEnd]; + Assert.DoesNotContain("mms.invokeID", fingerprint, StringComparison.Ordinal); + Assert.Contains("mms.domainId", fingerprint, StringComparison.Ordinal); + Assert.Contains("mms.objectClass", fingerprint, StringComparison.Ordinal); + Assert.Contains("mms.getNameList-Request_continueAfter", fingerprint, StringComparison.Ordinal); + } + + [Fact] + public void P05d_Verifier_ProvesOutstandingWindowAndCompleteAssociationCapture() + { + var verifier = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-pcap.ps1")); + + Assert.Contains("PeakOutstandingRequests", verifier, StringComparison.Ordinal); + Assert.Contains("mms.negociatedMaxServOutstandingCalling", verifier, StringComparison.Ordinal); + Assert.Contains("InvokeIdReuseWhileOutstanding", verifier, StringComparison.Ordinal); + Assert.Contains("OrphanResponses", verifier, StringComparison.Ordinal); + Assert.Contains("UnansweredRequestsAtCaptureEnd", verifier, StringComparison.Ordinal); + Assert.Contains("Expected exactly one MMS request TCP stream", verifier, StringComparison.Ordinal); + Assert.Contains("Peak outstanding", verifier, StringComparison.Ordinal); + Assert.Contains("exceeded negotiated maxOutstandingCalling", verifier, StringComparison.Ordinal); + } + + [Fact] + public void P05d_Verifier_SupportsSameIedReferenceAndExplicitBudgetGates() + { + var verifier = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-pcap.ps1")); + var contract = File.ReadAllText(FindRepoFile("docs/P0-5D_PHYSICAL_CAPTURE_PROOF.md")); + + Assert.Contains("ReferencePcapPath", verifier, StringComparison.Ordinal); + Assert.Contains("MaxConfirmedRequests", verifier, StringComparison.Ordinal); + Assert.Contains("MaxGvaRequests", verifier, StringComparison.Ordinal); + Assert.Contains("RequireNoMoreRequestsThanReference", verifier, StringComparison.Ordinal); + Assert.Contains("ConfirmedRequestDelta", verifier, StringComparison.Ordinal); + Assert.Contains("ConfirmedRequestRatio", verifier, StringComparison.Ordinal); + Assert.Contains("P0-5D-", verifier, StringComparison.Ordinal); + Assert.Contains("The JSON is derived evidence; the PCAP remains authoritative", contract, StringComparison.Ordinal); + Assert.Contains("Do not transfer an older R1/R2 capture result", contract, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs new file mode 100644 index 000000000..9f75a51a3 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryProductionPromotionRegressionTests.cs @@ -0,0 +1,139 @@ +using System.Text.Json; +using System.Xml.Linq; + +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryProductionPromotionRegressionTests +{ + private const string EvidenceEngineCommit = "4467124775d8d9d76f3db194f9fbfd97144767a8"; + + [Fact] + public void P05g_TargetKeepsProductionPromotionFailClosed() + { + using var document = JsonDocument.Parse(File.ReadAllText(FindRepoFile("evidence/smart-discovery-production-promotion-target.json"))); + var root = document.RootElement; + + Assert.Equal(2, root.GetProperty("SchemaVersion").GetInt32()); + Assert.Equal("P0-5g", root.GetProperty("Phase").GetString()); + Assert.Equal(EvidenceEngineCommit, root.GetProperty("EvidenceEngineBaselineCommit").GetString()); + Assert.Equal(134, root.GetProperty("EnginePullRequest").GetInt32()); + Assert.Equal(324, root.GetProperty("ArsasPullRequest").GetInt32()); + Assert.Equal(JsonValueKind.Null, root.GetProperty("PromotionAuthority").ValueKind); + + var contract = root.GetProperty("ProductionPromotionContract"); + Assert.True(contract.GetProperty("RequireP05fPhysicalAuthority").GetBoolean()); + Assert.True(contract.GetProperty("RequirePhysicalAuthorityProductionEvidenceOnly").GetBoolean()); + Assert.True(contract.GetProperty("RequireEngineHeadCiSuccess").GetBoolean()); + Assert.True(contract.GetProperty("AllowEngineHeadDescendantWhenCriticalDiscoveryPathsUnchanged").GetBoolean()); + Assert.True(contract.GetProperty("RequireProductionSwitchFalseUntilAuthority").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactPromotionAuthoritySha256Binding").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactValidatedEngineHeadBinding").GetBoolean()); + Assert.True(contract.GetProperty("RequireGenericBuildSuccessBeforeReadyForReview").GetBoolean()); + Assert.True(contract.GetProperty("RequireNoUnresolvedReviewThreadsBeforeReadyForReview").GetBoolean()); + Assert.True(contract.GetProperty("RequireDedicatedMainlineReadinessGateSuccess").GetBoolean()); + Assert.True(contract.GetProperty("RequirePrRemainDraftUntilAllReadyGatesPass").GetBoolean()); + } + + [Fact] + public void P05g_DefaultBuildDoesNotPromoteFieldRoute() + { + var props = XDocument.Load(FindRepoFile("evidence/SmartDiscoveryPromotion.props")); + var promoted = props.Descendants("SmartDiscoveryProductionPromoted").Single().Value.Trim(); + Assert.Equal("false", promoted, ignoreCase: true); + Assert.Empty(props.Descendants("SmartDiscoveryPromotionAuthoritySha256")); + Assert.Empty(props.Descendants("SmartDiscoveryValidatedEngineHead")); + + var targets = File.ReadAllText(FindRepoFile("Directory.Build.targets")); + Assert.Contains("GITHUB_WORKFLOW", targets, StringComparison.Ordinal); + Assert.Contains("Smart Discovery Field Capture Build", targets, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryProductionPromoted", targets, StringComparison.Ordinal); + Assert.Contains("EnableSmartDiscoveryCaptureRoute", targets, StringComparison.Ordinal); + Assert.Contains(">false", targets, StringComparison.Ordinal); + } + + [Fact] + public void P05g_ReadinessAllowsMissingPromotionBindingsWhileFailClosed() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-production-readiness.ps1")); + + Assert.Contains("Get-XmlChildText", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryPromotionAuthoritySha256", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryValidatedEngineHead", source, StringComparison.Ordinal); + Assert.DoesNotContain("$group.SmartDiscoveryPromotionAuthoritySha256", source, StringComparison.Ordinal); + Assert.DoesNotContain("$group.SmartDiscoveryValidatedEngineHead", source, StringComparison.Ordinal); + } + + [Fact] + public void P05g_DedicatedMainlineGateRequiresReadyForReviewAndRealAuthorities() + { + var workflow = File.ReadAllText(FindRepoFile(".github/workflows/smart-discovery-mainline-readiness.yml")); + + Assert.Contains("Smart Discovery Mainline Readiness", workflow, StringComparison.Ordinal); + Assert.Contains("smart-discovery-repeat-run.authority.json", workflow, StringComparison.Ordinal); + Assert.Contains("smart-discovery-production-promotion-authority.json", workflow, StringComparison.Ordinal); + Assert.Contains("READY_FOR_REVIEW", workflow, StringComparison.Ordinal); + Assert.Contains("ProductionSwitchEnabled", workflow, StringComparison.Ordinal); + Assert.Contains("-NoFailExit", workflow, StringComparison.Ordinal); + Assert.Contains("if-no-files-found: error", workflow, StringComparison.Ordinal); + } + + [Fact] + public void P05g_ReadinessBindsAllPromotionProvenance() + { + var source = File.ReadAllText(FindRepoFile("scripts/verify-smart-discovery-production-readiness.ps1")); + + Assert.Contains("P0-5f physical-finalized authority is missing", source, StringComparison.Ordinal); + Assert.Contains("Get-PhysicalAuthorityProvenanceErrors", source, StringComparison.Ordinal); + Assert.Contains("at least three independent associations", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("contains reused", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("merge-base --is-ancestor", source, StringComparison.Ordinal); + Assert.Contains("DiscoveryCriticalEnginePaths", source, StringComparison.Ordinal); + Assert.Contains("Engine PR head CI is not green", source, StringComparison.Ordinal); + Assert.Contains("AllowedPostPhysicalAuthorityPaths", source, StringComparison.Ordinal); + Assert.Contains("READY_TO_PROMOTE", source, StringComparison.Ordinal); + Assert.Contains("READY_FOR_REVIEW", source, StringComparison.Ordinal); + Assert.Contains("production-promoted", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryPromotionAuthoritySha256", source, StringComparison.Ordinal); + Assert.Contains("Production promotion props are bound to a different P0-5g promotion authority", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryValidatedEngineHead", source, StringComparison.Ordinal); + Assert.Contains("Production promotion props are bound to a different validated engine head", source, StringComparison.Ordinal); + Assert.Contains("promotion authority is bound to a different promotion target", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("promotion authority is bound to a different engine lock", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PromotionTargetSha256", source, StringComparison.Ordinal); + Assert.Contains("EngineLockSha256", source, StringComparison.Ordinal); + Assert.Contains("SchemaVersion = 4", source, StringComparison.Ordinal); + } + + [Fact] + public void P05g_PromotionWriterHasNoFixtureBypassAndRequiresProductionPhysicalProvenance() + { + var source = File.ReadAllText(FindRepoFile("scripts/new-smart-discovery-production-promotion-authority.ps1")); + + Assert.Contains("READY_TO_PROMOTE", source, StringComparison.Ordinal); + Assert.Contains("Assert-PhysicalAuthorityProvenance", source, StringComparison.Ordinal); + Assert.Contains("at least three independent associations", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("GoldenLockSha256", source, StringComparison.Ordinal); + Assert.Contains("RepeatTargetSha256", source, StringComparison.Ordinal); + Assert.Contains("FinalizationSha256", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryProductionPromoted>true", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryPromotionAuthoritySha256", source, StringComparison.Ordinal); + Assert.Contains("SmartDiscoveryValidatedEngineHead", source, StringComparison.Ordinal); + Assert.Contains("P0-5g-authority", source, StringComparison.Ordinal); + Assert.Contains("SchemaVersion = 2", source, StringComparison.Ordinal); + Assert.DoesNotContain("AllowFixtureEvidence", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(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($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs new file mode 100644 index 000000000..c74609f5e --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoveryRepeatRunStabilityRegressionTests.cs @@ -0,0 +1,108 @@ +using System.Text.Json; + +namespace ARSAS.Tests; + +public sealed class SmartDiscoveryRepeatRunStabilityRegressionTests +{ + private const string EngineCommit = "4467124775d8d9d76f3db194f9fbfd97144767a8"; + + [Fact] + public void P05f_TargetRequiresThreeFreshIndependentAssociations() + { + using var document = JsonDocument.Parse(File.ReadAllText(FindRepoFile("evidence/smart-discovery-repeat-run-target.json"))); + var root = document.RootElement; + + Assert.Equal("P0-5f", root.GetProperty("Phase").GetString()); + Assert.Equal("AA1E1F06R4", root.GetProperty("DeviceIdentity").GetString()); + Assert.Equal(EngineCommit, root.GetProperty("EngineCommit").GetString()); + Assert.True(root.GetProperty("MinimumIndependentAssociations").GetInt32() >= 3); + Assert.Equal(JsonValueKind.Null, root.GetProperty("FinalizationAuthority").ValueKind); + + var contract = root.GetProperty("RepeatRunContract"); + Assert.True(contract.GetProperty("RequireFreshAssociationGenerationPerRun").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactConfirmedRequestCountAcrossRuns").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactServiceMixAcrossRuns").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactEngineKpiSignatureAcrossRuns").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactDirectoryModelSignatureAcrossRuns").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactProjectionSignatureAcrossRuns").GetBoolean()); + Assert.True(contract.GetProperty("RequireExactTypeProbeBudgetAcrossRuns").GetBoolean()); + } + + [Fact] + public void P05f_RuntimeEvidenceIsFreshAssociationOnlyAndZeroTraffic() + { + var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); + var evidence = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryRepeatRunEvidence.cs")); + + Assert.Contains("cached branch", capture, StringComparison.OrdinalIgnoreCase); + Assert.Contains("TryWriteSmartDiscoveryRepeatRunEvidence", capture, StringComparison.Ordinal); + Assert.Contains("after a fresh", capture, StringComparison.OrdinalIgnoreCase); + Assert.Contains("RefreshSmartDiscoveryModelKpi", evidence, StringComparison.Ordinal); + Assert.Contains("DirectoryModelSignature", evidence, StringComparison.Ordinal); + Assert.Contains("ProjectionSignature", evidence, StringComparison.Ordinal); + Assert.Contains("DeterministicSignature", evidence, StringComparison.Ordinal); + Assert.Contains("AssociationGeneration", evidence, StringComparison.Ordinal); + Assert.DoesNotContain("GetVariableAccessAttributesAsync", evidence, StringComparison.Ordinal); + Assert.DoesNotContain("GetNameList", evidence, StringComparison.Ordinal); + } + + [Fact] + public void P05f_FinalizerRejectsAssociationReuseAndCrossRunDrift() + { + var source = File.ReadAllText(FindRepoFile("scripts/finalize-smart-discovery-repeat-run-stability.ps1")); + + Assert.Contains("Repeat set reuses an association generation", source, StringComparison.Ordinal); + Assert.Contains("wire/engine request accounting mismatch", source, StringComparison.Ordinal); + Assert.Contains("Confirmed-request drift", source, StringComparison.Ordinal); + Assert.Contains("MMS service-mix drift", source, StringComparison.Ordinal); + Assert.Contains("Engine KPI deterministic-signature drift", source, StringComparison.Ordinal); + Assert.Contains("Directory model signature drift", source, StringComparison.Ordinal); + Assert.Contains("ARSAS signal projection signature drift", source, StringComparison.Ordinal); + Assert.Contains("Hierarchy type-probe budget drift", source, StringComparison.Ordinal); + Assert.Contains("Discovered model-count drift", source, StringComparison.Ordinal); + Assert.Contains("RepeatTargetSha256", source, StringComparison.Ordinal); + Assert.Contains("SchemaVersion = 2", source, StringComparison.Ordinal); + } + + [Fact] + public void P05f_RunBundleReverifiesGoldenBudgetAndRawCaptureByDefault() + { + var source = File.ReadAllText(FindRepoFile("scripts/new-smart-discovery-repeat-run-bundle.ps1")); + + Assert.Contains("verify-smart-discovery-golden-lock.ps1", source, StringComparison.Ordinal); + Assert.Contains("verify-smart-discovery-pcap.ps1", source, StringComparison.Ordinal); + Assert.Contains("Production repeat evidence requires raw .pcap/.pcapng input", source, StringComparison.Ordinal); + Assert.Contains("Wire/engine request accounting mismatch", source, StringComparison.Ordinal); + Assert.Contains("GoldenLockSha256", source, StringComparison.Ordinal); + Assert.Contains("RuntimeEvidenceSha256", source, StringComparison.Ordinal); + Assert.Contains("FixtureEvidence", source, StringComparison.Ordinal); + } + + [Fact] + public void P05f_PhysicalAuthorityCannotPromoteFixtures() + { + var source = File.ReadAllText(FindRepoFile("scripts/new-smart-discovery-repeat-run-authority.ps1")); + + Assert.Contains("RawCaptureReverified", source, StringComparison.Ordinal); + Assert.Contains("Physical authority rejects fixture run bundle", source, StringComparison.Ordinal); + Assert.Contains("RunBundlePaths", source, StringComparison.Ordinal); + Assert.Contains("BundleSha256", source, StringComparison.Ordinal); + Assert.Contains("reused association generations", source, StringComparison.Ordinal); + Assert.Contains("physical-finalized", source, StringComparison.Ordinal); + Assert.Contains("FinalizationSha256", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(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($"Could not locate repository file '{relativePath}'."); + } +} diff --git a/tests/ARSAS.Tests/SmartDiscoverySemanticEnrichmentRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoverySemanticEnrichmentRegressionTests.cs new file mode 100644 index 000000000..7ed1bbc97 --- /dev/null +++ b/tests/ARSAS.Tests/SmartDiscoverySemanticEnrichmentRegressionTests.cs @@ -0,0 +1,47 @@ +namespace ARSAS.Tests; + +public sealed class SmartDiscoverySemanticEnrichmentRegressionTests +{ + [Fact] + public void SmartCapture_KeepsDataSetAndReportEnrichmentEnabled() + { + var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); + + Assert.Contains("ProbeReportAttributes = true", capture, StringComparison.Ordinal); + Assert.Contains("MaxReportAttributeProbes = 64", capture, StringComparison.Ordinal); + Assert.Contains("ReadDataSetDirectories = true", capture, StringComparison.Ordinal); + Assert.Contains("MaxDataSetDirectoryReads = 64", capture, StringComparison.Ordinal); + + Assert.DoesNotContain("ProbeReportAttributes = false", capture, StringComparison.Ordinal); + Assert.DoesNotContain("ReadDataSetDirectories = false", capture, StringComparison.Ordinal); + Assert.DoesNotContain("eager report attributes, DataSet directories", capture, StringComparison.Ordinal); + } + + [Fact] + public void SmartCapture_StillDefersOnlyBroadFallbackPasses() + { + var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs")); + + Assert.Contains( + "Deferred: supplemental GetNameList, reflection fallback, adaptive sibling/equipment/reference/unit probes.", + capture, + StringComparison.Ordinal); + Assert.Contains("association flight=single-owner", capture, StringComparison.Ordinal); + Assert.Contains("app MMS gate=exclusive", capture, StringComparison.Ordinal); + } + + private static string FindRepoFile(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( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +}