Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/M4_ARSTACK_INTEROP_ACCEPTANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,20 @@ dotnet test .\ProcessBusSuite.sln -c Release --no-build --filter Category=M4Inte
dotnet run --project .\tools\ProcessBus.InteropCli -c Release -- --pcap C:\lab\arstack-canonical.pcap --scl C:\lab\current-profile.scd --max-frames 20000000 --json C:\lab\interop-report.json
```

The tool supports classic Ethernet PCAP 2.4 (microsecond/nanosecond). If the
The tool supports classic Ethernet PCAP 2.4 (microsecond/nanosecond).
The WPF **Validation → Inspect PCAP** action runs on a background worker,
shows throttled file progress and exposes **Cancel** while reading. Progress
never represents guaranteed accuracy or physical capture completeness. If the
capture application creates PCAPNG, export/copy the packet recording as
**classic PCAP** with full Ethernet headers before inspecting it.

`--scl` is optional for wire fingerprint only; mandatory for automated
expected-vs-observed verdicts. The CLI prints a short operator summary,
keeps raw details in optional local JSON, and returns:

- **0**: screening executed and no specified SCL comparison failed; **not**
- **0**: screening executed and no specified SCL comparison failed; at least
two ASDUs are required for a clean field match, and data-limit, ambiguity,
counter-wrap, sequence and layout concerns downgrade positive verdicts; **not**
certification and **not** proof of capture-path visibility. For a capture
without SCL, there are no configured fields to validate.
- **3**: observed/configured mismatch, unresolved SCL identity or expected
Expand All @@ -59,6 +64,12 @@ on the authorized **local machine**; do not push them to GitHub.
| `CONFLICT` | APPID/MAC/VID/PCP/confRev/nofASDU differs | Inspect deployed SCL and exact wire identity; do not normalize away |
| `INCOMPLETE_INTENT` | Some observed fields match but SCL lacks authority | Load the deployed full SCL/profile; do not auto-fill defaults |
| `UNRESOLVED` | No trustworthy SCL svID | Correct/resolve engineering intent before claiming match |
| `LIMITED_EVIDENCE` | Only one ASDU seen; no progression evidence | Record more samples of the publisher |
| `SEQUENCE_WARNING` | Duplicate or non-unit sample-counter step | Check capture drops, sender counter policy and restart |
| `COUNTER_POLICY_UNRESOLVED` | 4000/4800 smpCnt reset observed; policy not verified | Compare deployed counter policy and independent capture |
| `LAYOUT_DRIFT` | Sample-data length changed within one wire identity | Investigate publisher dataset revision or malformed traffic |
| `AMBIGUOUS_WIRE` | More than one observed sender satisfies SCL identity | Identify intended source; do not select one silently |
| `CAPTURE_INCOMPLETE` | User frame limit or bounded stream catalog excluded evidence | Inspect complete windows, reduce publisher churn or split capture |

Capture **VLAN absence on a Windows USB-NIC is not proof of untagged wire TX**.
A mirrored `AR_DIAG_SV1`, APPID `0x4F01`, broadcast/untagged stream is diagnostic
Expand Down
8 changes: 8 additions & 0 deletions src/ProcessBus.App.Wpf/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -3619,6 +3619,14 @@
<Border Grid.Column="4" Style="{StaticResource PremiumInsightCardStyle}">
<StackPanel>
<DockPanel LastChildFill="True">
<Button DockPanel.Dock="Right"
Content="Cancel"
Margin="5,0,0,0"
Padding="6,2"
Style="{StaticResource ToolbarGhostButtonStyle}"
Visibility="{Binding M4CancelVisibility}"
Command="{Binding CancelInspectPcapCommand}"
ToolTip="Stop offline inspection without changing the capture file."/>
<Button DockPanel.Dock="Right"
Content="Inspect PCAP"
Style="{StaticResource ToolbarGhostButtonStyle}"
Expand Down
28 changes: 27 additions & 1 deletion src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,7 @@
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 () =>
Expand Down Expand Up @@ -934,6 +935,8 @@
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;
Expand Down Expand Up @@ -2784,6 +2787,7 @@
_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));

Expand All @@ -2792,11 +2796,32 @@
try
{
var path = dialog.FileName;
var lastPercent = -1;
// Progress<T> captures the WPF dispatcher context here, while
// parsing continues entirely on a background worker.
var progress = new Progress<PcapInspectionProgress>(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)
Expand Down Expand Up @@ -2832,6 +2857,7 @@
_m4CaptureInspectInProgress = false;
if (!_isShuttingDown)
{
OnPropertyChanged(nameof(M4CancelVisibility));
OnPropertyChanged(nameof(CanInspectPcap));
OnPropertyChanged(nameof(M4CaptureSummaryText));
OnPropertyChanged(nameof(M4CaptureDetailText));
Expand Down Expand Up @@ -4509,7 +4535,7 @@
public double? DeltaMs { get; init; }
public string SemanticChangedSummaryText { get; init; } = string.Empty;

public string TimeText => Source.LastSeenUtc.ToString("HH:mm:ss.fff");

Check warning on line 4538 in src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

The behavior of 'DateTime.ToString(string)' could vary based on the current user's locale settings. Replace this call in 'GooseTrafficRow.TimeText.get' with a call to 'DateTime.ToString(string, IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
public string DeltaText => DeltaMs.HasValue ? $"{DeltaMs.Value:0.0}" : "-";

public string GoId => Source.GoId;
Expand Down Expand Up @@ -4554,11 +4580,11 @@
}
}
public string AppId => Source.AppId;
public string EntryTimeText => Source.LastSeenUtc.ToLocalTime().ToString("M/d/yyyy h:mm:ss.fff tt");

Check warning on line 4583 in src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

The behavior of 'DateTime.ToString(string)' could vary based on the current user's locale settings. Replace this call in 'GooseTrafficRow.EntryTimeText.get' with a call to 'DateTime.ToString(string, IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
public string NeedsCommissionText => Source.NeedsCommission ? "true" : "false";
public string TestText => Source.IsTest ? "true" : "false";
public int DatasetEntryCount => Source.DataValues.Count;
public string DatasetEntryCountText => DatasetEntryCount.ToString();

Check warning on line 4587 in src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

The behavior of 'int.ToString()' could vary based on the current user's locale settings. Replace this call in 'GooseTrafficRow.DatasetEntryCountText.get' with a call to 'int.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
public string DestinationMac => Source.DestinationMac;
public string SourceMac => Source.SourceMac;
public string VlanId => Source.VlanId;
Expand All @@ -4578,7 +4604,7 @@
return Array.Empty<string>();

var trimmed = valuesText.Trim();
if (trimmed.StartsWith("[", StringComparison.Ordinal) && trimmed.EndsWith("]", StringComparison.Ordinal))

Check warning on line 4607 in src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

Use 'string.StartsWith(char)' instead of 'string.StartsWith(string)' when you have a string with a single char (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1865)
trimmed = trimmed[1..^1];

return trimmed
Expand Down Expand Up @@ -4694,7 +4720,7 @@
var age = DateTime.UtcNow - LastSeenUtc.Value;
return age.TotalSeconds < 60
? $"last {age.TotalSeconds:0.0}s"
: LastSeenUtc.Value.ToLocalTime().ToString("HH:mm:ss");

Check warning on line 4723 in src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

The behavior of 'DateTime.ToString(string)' could vary based on the current user's locale settings. Replace this call in 'TrafficHealthTargetRow.LastSeenText.get' with a call to 'DateTime.ToString(string, IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
}
}

Expand Down Expand Up @@ -4775,7 +4801,7 @@
public string EntryCountText => $"{Entries.Count} entry(s)";
public string SummaryText => $"{IedName} - {TransportText}";
public string ExpectedKey => $"{Protocol}|{SourceFileName}|{IedName}|{ControlBlockReference}|{DisplayName}";
public string ExpectedConfRevText => ConfRev > 0 ? ConfRev.ToString() : "N/A";

Check warning on line 4804 in src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

The behavior of 'int.ToString()' could vary based on the current user's locale settings. Replace this call in 'SclStreamCatalogRow.ExpectedConfRevText.get' with a call to 'int.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

public static SclStreamCatalogRow FromSv(string sourceFileName, SclSvStreamModel stream, string liveStatus)
=> new()
Expand Down Expand Up @@ -5004,7 +5030,7 @@
return ("No evidence captured yet", string.Empty);

var parts = evidence
.Split(new[] { "; " }, StringSplitOptions.RemoveEmptyEntries)

Check warning on line 5033 in src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

Prefer 'static readonly' fields over constant array arguments if the called method is called repeatedly and is not mutating the passed array (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1861)
.Select(part => part.Trim())
.Where(part => part.Length > 0)
.ToArray();
Expand Down
102 changes: 87 additions & 15 deletions src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,34 @@
Stream pcap,
IReadOnlyList<SclSvStreamModel>? expectations = null,
int maximumFrames = 1_000_000,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
IProgress<PcapInspectionProgress>? progress = null)
{
if (maximumFrames < 1 || maximumFrames > 50_000_000)
throw new ArgumentOutOfRangeException(nameof(maximumFrames));

var observed = new Dictionary<string, SvEvidenceBuilder>(StringComparer.Ordinal);
// Value-key identity prevents 4k/4.8k FPS per-ASDU joined-string
// allocations. Only new publisher identities need a display string.
var observed = new Dictionary<SvWireKey, SvEvidenceBuilder>();
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++;
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;

Expand Down Expand Up @@ -61,11 +75,10 @@
{
var vlan = packet.Frame.Ethernet.Vlan;
// This is the SAME strong wire identity domain as the live analyzer.
var key = string.Join("|", packet.Frame.Ethernet.SourceMac,
packet.Frame.Ethernet.DestinationMac,
vlan.HasValue ? $"v{vlan.Value.VlanId}/p{vlan.Value.PriorityCodePoint}" : "untagged",
$"APPID={packet.Frame.AppId:X4}", $"svID={asdu.SvId ?? "unknown-sv"}",
$"confRev={asdu.ConfRev?.ToString() ?? "noconf"}");
var key = new SvWireKey(packet.Frame.Ethernet.SourceMac,
packet.Frame.Ethernet.DestinationMac, packet.Frame.AppId,
asdu.SvId ?? "unknown-sv", asdu.ConfRev,
vlan?.VlanId, vlan?.PriorityCodePoint);

if (!observed.TryGetValue(key, out var state))
{
Expand All @@ -74,7 +87,7 @@
capped++;
continue;
}
state = new SvEvidenceBuilder(key, packet.Frame.Ethernet.SourceMac,
state = new SvEvidenceBuilder(key.DisplayText, packet.Frame.Ethernet.SourceMac,
packet.Frame.Ethernet.DestinationMac, packet.Frame.AppId,
asdu.SvId ?? string.Empty, asdu.ConfRev,
vlan?.VlanId, vlan?.PriorityCodePoint);
Expand All @@ -90,7 +103,21 @@
.Select(x => x.Finish()).ToArray();
var expected = expectations ?? Array.Empty<SclSvStreamModel>();
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);
progress?.Report(new PcapInspectionProgress(frames, pcap.CanSeek ? pcap.Position : null,
pcap.CanSeek ? pcap.Length : null));

return new PcapInteropReport(
frames, sv, goose, ptpCount, rejected, capped,
Expand All @@ -99,7 +126,7 @@
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<SvWireEvidence> observed)
Expand All @@ -123,7 +150,12 @@

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");
Comment on lines +153 to +156

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count distinct senders before reporting ambiguity

When SCL omits a field such as confRev, VLAN ID, or PCP, CompareFields ignores that field, so two strong keys from the same source MAC that differ only in the omitted field both enter matching. This branch then reports “independent observed publisher identities” and AMBIGUOUS_WIRE, even though there may be only one sender whose configuration changed; incomplete intent should remain the diagnosis unless multiple distinct publishers actually match.

AGENTS.md reference: AGENTS.md:L29-L34

Useful? React with 👍 / 👎.


var exact = matching.FirstOrDefault();
if (exact.Stream is not null)
{
// Missing SCL fields cannot be turned into verified equality.
Expand All @@ -132,10 +164,31 @@
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);
Comment on lines +171 to +173

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require actual smpCnt values before claiming progression

When two or more decoded ASDUs omit or contain an unparseable smpCnt, AsduCount still passes this check while Observe records neither duplicates nor non-unit steps, so the comparison eventually returns WIRE_FIELDS_MATCH and claims sequential evidence. SampledValueParser.ReadAsdu explicitly permits SmpCnt to remain null, so track valid counters and downgrade any stream without at least two comparable values rather than treating ASDU count as progression evidence.

AGENTS.md reference: src/AGENTS.md:L19-L21

Useful? React with 👍 / 👎.


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);

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);
}

Expand Down Expand Up @@ -196,6 +249,17 @@
private static string NormalizeMac(string mac) =>
new(mac.Where(c => char.IsAsciiHexDigit(c)).Select(char.ToUpperInvariant).ToArray());

private readonly record struct SvWireKey(
string SourceMac, string DestinationMac, ushort AppId, string SvId,
uint? ConfRev, ushort? VlanId, byte? VlanPriority)
{
public string DisplayText => string.Join("|",
SourceMac, DestinationMac,
VlanId.HasValue ? $"v{VlanId.Value}/p{VlanPriority}" : "untagged",
$"APPID={AppId:X4}", $"svID={SvId}",
$"confRev={ConfRev?.ToString() ?? "noconf"}");
}

private sealed class SvEvidenceBuilder
{
private ushort? _previous;
Expand Down Expand Up @@ -279,7 +343,15 @@
IReadOnlyList<SvWireEvidence> SvStreams,
IReadOnlyList<SclInteropComparison> Comparisons,
int PtpSources, long MatchedTwoStep, long UnmatchedFollowUp,
string EvidenceLimitations)
string EvidenceLimitations, bool FrameLimitReached = false)
{
public bool IsPhysicallyAccepted => false; // offline software cannot certify this

Check warning on line 348 in src/ProcessBus.Iec61850.Raw/Interop/PcapInteropInspector.cs

View workflow job for this annotation

GitHub Actions / Build, test, and repository health

Member 'IsPhysicallyAccepted' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)
}

/// <summary>Throttled offline PCAP progress; bytes are nullable on non-seekable streams.</summary>
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;
}
Loading
Loading