From 570641aa2a902a2496b7e0b9b98fa9f8c44bbf12 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 10:00:37 +0700 Subject: [PATCH 1/9] fix(interop): prevent false-positive SCL matches on single sample, ambiguous sources or incomplete replay --- .../Interop/PcapInteropInspector.cs | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs b/src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs index ea12645a..e78b384f 100644 --- a/src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs +++ b/src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs @@ -28,11 +28,15 @@ public PcapInteropReport Inspect( var observed = new Dictionary(StringComparer.Ordinal); var ptp = new PtpHealthTracker(); long frames = 0, sv = 0, ptpCount = 0, goose = 0, rejected = 0, capped = 0; + var frameLimitReached = false; DateTime? first = null, last = null; foreach (var record in _reader.Read(pcap, cancellationToken)) { if (frames >= maximumFrames) + { + frameLimitReached = true; // More records exist but were not evaluated. break; + } frames++; first ??= record.CaptureTimeUtc; last = record.CaptureTimeUtc; @@ -90,6 +94,18 @@ public PcapInteropReport Inspect( .Select(x => x.Finish()).ToArray(); var expected = expectations ?? Array.Empty(); var comparison = expected.Select(x => Compare(x, facts)).ToArray(); + if (capped > 0 || frameLimitReached) + { + // The reader did not examine every identity/frame. A partial + // capture must not be reported as a complete positive comparison. + comparison = comparison.Select(x => x with + { + Status = "CAPTURE_INCOMPLETE", + Explanation = $"{x.Status}: {x.Explanation}. " + + (frameLimitReached ? "Frame limit reached. " : "") + + (capped > 0 ? "Stream catalog saturated. " : "") + }).ToArray(); + } var status = ptp.CreateSnapshot(last ?? DateTime.UtcNow); return new PcapInteropReport( @@ -99,7 +115,7 @@ public PcapInteropReport Inspect( status.UnmatchedFollowUpCount, "Capture-file screening only. VLAN visibility depends on capture hardware; " + "PTP visibility is not clock lock. Independent TAP/SPAN, hardware timing " + - "and subscriber/IED acceptance remain unverified."); + "and subscriber/IED acceptance remain unverified.", frameLimitReached); } private static SclInteropComparison Compare(SclSvStreamModel expectation, IReadOnlyList observed) @@ -123,7 +139,12 @@ private static SclInteropComparison Compare(SclSvStreamModel expectation, IReadO var candidatesWithMismatches = candidates .Select(x => (Stream: x, Errors: CompareFields(expectation, x))).ToArray(); - var exact = candidatesWithMismatches.FirstOrDefault(x => x.Errors.Count == 0); + var matching = candidatesWithMismatches.Where(x => x.Errors.Count == 0).ToArray(); + if (matching.Length > 1) + return new(expectation.ControlBlockReference, "AMBIGUOUS_WIRE", + $"{matching.Length} independent observed publisher identities match the same SCL stream; select and investigate the true source"); + + var exact = matching.FirstOrDefault(); if (exact.Stream is not null) { // Missing SCL fields cannot be turned into verified equality. @@ -132,10 +153,25 @@ private static SclInteropComparison Compare(SclSvStreamModel expectation, IReadO expectation.ConfRev <= 0 || expectation.NofAsdu <= 0 || (string.IsNullOrWhiteSpace(expectation.VlanId) && exact.Stream.VlanId.HasValue); - return new(expectation.ControlBlockReference, - incomplete ? "INCOMPLETE_INTENT" : "WIRE_FIELDS_MATCH", - incomplete ? "Matching observed fields, but mandatory SCL intent is incomplete" - : "Observed frame fields match configured identity; capture path and IED not verified", + if (incomplete) + return new(expectation.ControlBlockReference, "INCOMPLETE_INTENT", + "Matching observed fields, but mandatory SCL intent is incomplete", exact.Stream.Key); + + if (exact.Stream.AsduCount < 2) + return new(expectation.ControlBlockReference, "LIMITED_EVIDENCE", + "One ASDU cannot establish sequence progression; record more than one sample", exact.Stream.Key); + + if (exact.Stream.DuplicateCounters > 0 || exact.Stream.NonUnitCounterSteps > 0) + return new(expectation.ControlBlockReference, "SEQUENCE_WARNING", + $"Observed duplicates={exact.Stream.DuplicateCounters}, non-unit steps={exact.Stream.NonUnitCounterSteps}. " + + "Do not silently accept interrupted/ambiguous sample continuity", exact.Stream.Key); + + if (exact.Stream.MinPayloadBytes != exact.Stream.MaxPayloadBytes) + return new(expectation.ControlBlockReference, "LAYOUT_DRIFT", + $"Observed sample payload size changed: {exact.Stream.MinPayloadBytes}–{exact.Stream.MaxPayloadBytes} bytes", exact.Stream.Key); + + return new(expectation.ControlBlockReference, "WIRE_FIELDS_MATCH", + "Observed identity fields agree and sequential evidence exists; capture path and IED still unverified", exact.Stream.Key); } @@ -279,7 +315,7 @@ public sealed record PcapInteropReport( IReadOnlyList SvStreams, IReadOnlyList Comparisons, int PtpSources, long MatchedTwoStep, long UnmatchedFollowUp, - string EvidenceLimitations) + string EvidenceLimitations, bool FrameLimitReached = false) { public bool IsPhysicallyAccepted => false; // offline software cannot certify this } From aee421f9e00b0a58b39a855ff9694b4eb73682be Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 10:01:11 +0700 Subject: [PATCH 2/9] test(interop): guard single ASDU, gaps, duplicate publishers and cut-off PCAP files --- .../M4ArstackInteropAcceptanceTests.cs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/ProcessBus.Tests/M4ArstackInteropAcceptanceTests.cs b/tests/ProcessBus.Tests/M4ArstackInteropAcceptanceTests.cs index 2f340804..c7cb8b23 100644 --- a/tests/ProcessBus.Tests/M4ArstackInteropAcceptanceTests.cs +++ b/tests/ProcessBus.Tests/M4ArstackInteropAcceptanceTests.cs @@ -146,6 +146,67 @@ public void Observed4000CounterResetRemainsCandidateNotAutomaticModulusProof() Assert.Equal(0, evidence.NonUnitCounterSteps); } + [Fact] + [Trait("Category", "M4Interop")] + public void SingleObservedAsduDoesNotClaimSequenceEvidence() + { + using var capture = Pcap([(Epoch, Canonical(1))]); + var comparison = Assert.Single(new PcapInteropInspector().Inspect(capture, [Expected()]).Comparisons); + Assert.Equal("LIMITED_EVIDENCE", comparison.Status); + } + + [Fact] + [Trait("Category", "M4Interop")] + public void RepeatedAndSkippedCountersMustNotYieldCleanWireMatch() + { + using var capture = Pcap(new[] + { + (Epoch, Canonical(1)), + (Epoch.AddTicks(2500), Canonical(1)), + (Epoch.AddTicks(5000), Canonical(4)) + }); + var result = new PcapInteropInspector().Inspect(capture, [Expected()]); + Assert.Equal("SEQUENCE_WARNING", result.Comparisons[0].Status); + Assert.Equal(1, result.SvStreams[0].DuplicateCounters); + Assert.Equal(1, result.SvStreams[0].NonUnitCounterSteps); + } + + [Fact] + [Trait("Category", "M4Interop")] + public void TwoPublishersWithSameSclIdentityRequireOperatorResolution() + { + var other = Canonical(2); + other[6] = 0x99; // different source MAC; same SCL svID/APPID/destination + using var capture = Pcap(new[] + { + (Epoch, Canonical(1)), + (Epoch.AddTicks(100), other), + (Epoch.AddTicks(2500), Canonical(2)), + (Epoch.AddTicks(2600), Canonical(3) is { } canonical ? WithOtherSource(canonical) : throw new Exception()) + }); + var result = new PcapInteropInspector().Inspect(capture, [Expected()]); + Assert.Equal(2, result.SvStreams.Count); + Assert.Equal("AMBIGUOUS_WIRE", result.Comparisons[0].Status); + } + + private static byte[] WithOtherSource(byte[] frame) + { + frame[6] = 0x99; + return frame; + } + + [Fact] + [Trait("Category", "M4Interop")] + public void ReplayFrameLimitDowngradesOtherwiseCleanComparison() + { + using var capture = Pcap(Enumerable.Range(0, 10) + .Select(i => (Epoch.AddTicks(i * 2500), Canonical((ushort)i)))); + var result = new PcapInteropInspector().Inspect(capture, [Expected()], maximumFrames: 2); + Assert.True(result.FrameLimitReached); + Assert.Equal(2, result.FramesRead); + Assert.Equal("CAPTURE_INCOMPLETE", result.Comparisons[0].Status); + } + [Fact] [Trait("Category", "M4Interop")] public void InvalidCaptureDoesNotBecomePositivePtpEvidence() From c98b63ae953ab6ec4a0f14db4909c681c52e9787 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 10:01:26 +0700 Subject: [PATCH 3/9] fix(interop): surface observed 4000/4800 counter wrap as unresolved policy not automatic conformance --- src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs b/src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs index e78b384f..845da17f 100644 --- a/src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs +++ b/src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs @@ -170,6 +170,12 @@ private static SclInteropComparison Compare(SclSvStreamModel expectation, IReadO return new(expectation.ControlBlockReference, "LAYOUT_DRIFT", $"Observed sample payload size changed: {exact.Stream.MinPayloadBytes}–{exact.Stream.MaxPayloadBytes} bytes", exact.Stream.Key); + if (exact.Stream.Candidate4000Wraps > 0 || exact.Stream.Candidate4800Wraps > 0) + return new(expectation.ControlBlockReference, "COUNTER_POLICY_UNRESOLVED", + $"Observed {exact.Stream.Candidate4000Wraps} × 4000 and {exact.Stream.Candidate4800Wraps} × 4800 smpCnt wrap candidates. " + + "Sampling rate alone cannot authorize a counter modulus; validate applicable profile/capture authority", + exact.Stream.Key); + return new(expectation.ControlBlockReference, "WIRE_FIELDS_MATCH", "Observed identity fields agree and sequential evidence exists; capture path and IED still unverified", exact.Stream.Key); From 602ec9acf146812e721cebd97128483a09cfcf0a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 10:02:02 +0700 Subject: [PATCH 4/9] feat(interop): throttle progress reporting to 16k packets with nullable file-size authority --- .../Interop/PcapInteropInspector.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs b/src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs index 845da17f..44749e26 100644 --- a/src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs +++ b/src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs @@ -20,7 +20,8 @@ public PcapInteropReport Inspect( Stream pcap, IReadOnlyList? expectations = null, int maximumFrames = 1_000_000, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + IProgress? progress = null) { if (maximumFrames < 1 || maximumFrames > 50_000_000) throw new ArgumentOutOfRangeException(nameof(maximumFrames)); @@ -38,6 +39,13 @@ public PcapInteropReport Inspect( break; } frames++; + if ((frames & 0x3FFF) == 0) + { + // One progress notification per 16k frames; not per sample. + // The caller owns UI scheduling and may cancel independently. + progress?.Report(new PcapInspectionProgress(frames, pcap.CanSeek ? pcap.Position : null, + pcap.CanSeek ? pcap.Length : null)); + } first ??= record.CaptureTimeUtc; last = record.CaptureTimeUtc; @@ -107,6 +115,8 @@ public PcapInteropReport Inspect( }).ToArray(); } var status = ptp.CreateSnapshot(last ?? DateTime.UtcNow); + progress?.Report(new PcapInspectionProgress(frames, pcap.CanSeek ? pcap.Position : null, + pcap.CanSeek ? pcap.Length : null)); return new PcapInteropReport( frames, sv, goose, ptpCount, rejected, capped, @@ -325,3 +335,11 @@ public sealed record PcapInteropReport( { public bool IsPhysicallyAccepted => false; // offline software cannot certify this } + +/// Throttled offline PCAP progress; bytes are nullable on non-seekable streams. +public readonly record struct PcapInspectionProgress(long FramesRead, long? BytesRead, long? TotalBytes) +{ + public int? Percent => BytesRead.HasValue && TotalBytes > 0 + ? (int)Math.Clamp(BytesRead.Value * 100.0 / TotalBytes.Value, 0, 100) + : null; +} From e1ed17bf1f0e18a0bb2c6167f1df6266af1d8a76 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 10:02:29 +0700 Subject: [PATCH 5/9] feat(ux): cancellation and coalesced percent progress for long offline PCAP inspections --- .../ViewModels/MainWindowViewModel.cs | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index e6b3afa9..bccedbc5 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -691,6 +691,7 @@ public MainWindowViewModel() CopyEvidenceCommand = new RelayCommand(CopyEvidenceSnapshot); LoadSclCommand = new RelayCommand(LoadSclProject); InspectPcapCommand = new AsyncRelayCommand(InspectLocalPcapAsync); + CancelInspectPcapCommand = new RelayCommand(() => _m4CaptureInspectCts?.Cancel()); ClearSclCommand = new RelayCommand(ClearSclProject); SwitchToRawCommand = new AsyncRelayCommand(async () => @@ -934,6 +935,8 @@ private void ApplySelectedStreamScale(string? streamId) public ICommand CopyEvidenceCommand { get; } public ICommand LoadSclCommand { get; } public ICommand InspectPcapCommand { get; } + public ICommand CancelInspectPcapCommand { get; } + public Visibility M4CancelVisibility => _m4CaptureInspectInProgress ? Visibility.Visible : Visibility.Collapsed; public string M4CaptureSummaryText => _m4CaptureSummary; public string M4CaptureDetailText => _m4CaptureDetail; public bool CanInspectPcap => !_m4CaptureInspectInProgress && !_isShuttingDown; @@ -2784,6 +2787,7 @@ private async Task InspectLocalPcapAsync() _m4CaptureSummary = "Reading local capture…"; _m4CaptureDetail = "Analysis runs off the UI dispatcher. The file stays on this computer."; OnPropertyChanged(nameof(CanInspectPcap)); + OnPropertyChanged(nameof(M4CancelVisibility)); OnPropertyChanged(nameof(M4CaptureSummaryText)); OnPropertyChanged(nameof(M4CaptureDetailText)); @@ -2792,11 +2796,32 @@ private async Task InspectLocalPcapAsync() try { var path = dialog.FileName; + var lastPercent = -1; + // Progress captures the WPF dispatcher context here, while + // parsing continues entirely on a background worker. + var progress = new Progress(step => + { + if (!_m4CaptureInspectInProgress || _isShuttingDown || + !ReferenceEquals(_m4CaptureInspectCts, cts)) + return; + + if (step.Percent is { } percent) + { + if (percent == lastPercent) + return; + lastPercent = percent; + _m4CaptureSummary = $"Inspecting local PCAP… {percent}%"; + } + else + _m4CaptureSummary = $"Inspecting local PCAP… {step.FramesRead:N0} frames"; + OnPropertyChanged(nameof(M4CaptureSummaryText)); + }); + var report = await Task.Run(() => { using var capture = File.OpenRead(path); return new PcapInteropInspector().Inspect( - capture, expected, maximumFrames: 50_000_000, cts.Token); + capture, expected, maximumFrames: 50_000_000, cts.Token, progress); }, cts.Token); if (_isShuttingDown) @@ -2832,6 +2857,7 @@ private async Task InspectLocalPcapAsync() _m4CaptureInspectInProgress = false; if (!_isShuttingDown) { + OnPropertyChanged(nameof(M4CancelVisibility)); OnPropertyChanged(nameof(CanInspectPcap)); OnPropertyChanged(nameof(M4CaptureSummaryText)); OnPropertyChanged(nameof(M4CaptureDetailText)); From c68ada3fca2fbbd83ac44835d0f3623cb2cc1b07 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 10:02:42 +0700 Subject: [PATCH 6/9] feat(ux): one unobtrusive Cancel control visible only during PCAP inspection --- src/ProcessBus.App.Wpf/MainWindow.xaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 9752e2ea..4b673d44 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -3619,6 +3619,14 @@ +