From 5e55fa2022bf3f79f1d747a176e75365306bdcd9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 12:24:23 +0700 Subject: [PATCH] fix(goose): resolve same-host C264 capture NIC by authoritative identity; add fail-closed regressions --- MainWindow.GooseSubscriber.cs | 11 +- MainWindow.IedGooseQuickStart.cs | 82 +++++------ Services/GooseAdapterSelectionPolicy.cs | 133 +++++++++++++++++ Views/GooseSubscriberLiteView.xaml | 2 +- docs/GOOSE_SUBSCRIBER.md | 7 + .../GooseAdapterSelectionPolicyTests.cs | 136 ++++++++++++++++++ 6 files changed, 315 insertions(+), 56 deletions(-) create mode 100644 Services/GooseAdapterSelectionPolicy.cs create mode 100644 tests/ARSAS.Tests/GooseAdapterSelectionPolicyTests.cs diff --git a/MainWindow.GooseSubscriber.cs b/MainWindow.GooseSubscriber.cs index ccab206d3..0c78504a8 100644 --- a/MainWindow.GooseSubscriber.cs +++ b/MainWindow.GooseSubscriber.cs @@ -31,6 +31,7 @@ public partial class MainWindow private string _gooseStatusText = "Select a network adapter, then start the read-only GOOSE subscriber."; private string _gooseBindingText = "No GOOSE model evaluated yet."; private string _gooseCaptureFilter = GooseSubscriberRuntime.DefaultCaptureFilter; + private string _gooseCaptureContextHint = "If no frames arrive, verify publisher, adapter and VLAN."; private long _gooseCapturedFrames; private long _gooseFrames; private long _gooseOtherFrames; @@ -189,7 +190,7 @@ await _gooseSubscriberRuntime.StartAsync( GooseCaptureFilter, _applicationCancellation.Token); IsGooseCapturing = true; - GooseStatusText = $"Listening on {SelectedGooseAdapter.DisplayText}. Waiting for GOOSE frames…"; + GooseStatusText = $"Waiting for actual GOOSE frames on {SelectedGooseAdapter.DisplayText}. {_gooseCaptureContextHint}"; SetStatus("GOOSE Subscriber started in read-only capture mode."); AddLog("INFO", "GOOSE", $"Subscriber started on adapter {SelectedGooseAdapter.Index}: {SelectedGooseAdapter.Description}. Binding: {_gooseBindingCatalog.Summary}"); } @@ -275,12 +276,10 @@ private void RefreshGooseAdapters() var previousName = SelectedGooseAdapter?.Name; var adapters = _gooseSubscriberRuntime.ListAdapters(); GooseAdapters.ReplaceAll(adapters); - var usableAdapters = adapters.Where(adapter => !LooksLikeLoopback(adapter)).ToArray(); - // Never silently choose the first of several network interfaces: an - // unrelated LAN/Wi-Fi adapter can miss all IEC 61850 GOOSE frames. + // Preserve only an explicitly selected capture-device identity. + // Never silently choose the sole unrelated NIC (especially on same-host simulators). SelectedGooseAdapter = adapters.FirstOrDefault(adapter => - adapter.Name.Equals(previousName, StringComparison.OrdinalIgnoreCase)) - ?? (usableAdapters.Length == 1 ? usableAdapters[0] : null); + previousName is not null && adapter.Name.Equals(previousName, StringComparison.OrdinalIgnoreCase)); GooseStatusText = adapters.Count == 0 ? "No Npcap/WinPcap adapters found. Install Npcap, then refresh adapters." : $"{adapters.Count:N0} capture adapter(s) available. Choose the station Ethernet adapter and press Start."; diff --git a/MainWindow.IedGooseQuickStart.cs b/MainWindow.IedGooseQuickStart.cs index 38a793eb2..02270805a 100644 --- a/MainWindow.IedGooseQuickStart.cs +++ b/MainWindow.IedGooseQuickStart.cs @@ -5,6 +5,7 @@ using System.Net.NetworkInformation; using System.Net.Sockets; using System.Text.RegularExpressions; +using ArIED61850Tester.Services; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; @@ -124,23 +125,27 @@ internal void OpenIedGooseSubscriber(Iec61850MonitorDevice device) if (GooseAdapters.Count == 0) RefreshGooseAdapters(); - // MMS route is an initial NIC recommendation, not proof of the - // station GOOSE VLAN. Only auto-start when one non-loopback Npcap - // adapter uniquely matches the actual Windows-routed interface. + // Exact OS/Npcap interface identity selects the candidate, not an + // adapter's display name or the presence of a single remaining NIC. + // The selected NIC is an observation point, NOT proof of GOOSE emission. var proposed = ResolveGooseAdapterForIed(device, out var route); - if (proposed is not null && !LooksLikeLoopback(proposed)) + _gooseCaptureContextHint = route.Contains("Same-PC", StringComparison.Ordinal) + ? "Same-PC simulator: verify it actually publishes Ethernet GOOSE on this adapter." + : "If no frames arrive, check the publisher, station NIC and VLAN."; + AddLog("INFO", "GOOSE", $"Adapter selection: {route}"); + if (proposed is not null) { SelectedGooseAdapter = proposed; - GooseStatusText = $"Listening on suggested adapter {proposed.DisplayText}. Change adapter if GOOSE uses a separate port/VLAN."; - SetStatus($"GOOSE: starting on {proposed.DisplayText}."); + GooseStatusText = $"Matched capture NIC {proposed.DisplayText}. GOOSE frame reception not yet verified."; + SetStatus($"GOOSE: observing on {proposed.DisplayText}."); StartGooseSubscriber_Click(this, new RoutedEventArgs()); return; } SelectedGooseAdapter = null; _pendingIedGooseAutoStart = device; GooseStatusText = GooseAdapters.Count == 0 - ? "No Ethernet capture adapter found. Check Npcap and refresh adapters." - : $"Choose the GOOSE station adapter for {device.Name}; capture begins on selection. {route}"; + ? "No Npcap capture adapter found. Check installation and refresh." + : $"Choose GOOSE adapter for {device.Name}; capture starts on confirmation. {route}"; SetStatus($"GOOSE: select network adapter for {device.Name}."); })); } @@ -157,46 +162,29 @@ internal void OpenIedGooseSubscriber(Iec61850MonitorDevice device) return null; } - var localAddress = ResolveLocalIpv4ForTarget(target); - if (localAddress is not null) + try { - var networkInterface = NetworkInterface.GetAllNetworkInterfaces() - .FirstOrDefault(adapter => adapter.GetIPProperties().UnicastAddresses.Any(unicast => - unicast.Address.AddressFamily == AddressFamily.InterNetwork && - unicast.Address.Equals(localAddress))); - - if (networkInterface is not null) - { - var matches = GooseAdapters - .Where(adapter => CaptureAdapterMatchesNetworkInterface(adapter, networkInterface)) - .ToList(); - if (matches.Count == 1) - { - routeDetail = - $"Windows route {localAddress} → {target} via {networkInterface.Name} ({networkInterface.Description})"; - return matches[0]; - } - - routeDetail = matches.Count == 0 - ? $"Windows selected {networkInterface.Name} ({localAddress}), but no Npcap adapter matched its ID/MAC." - : $"Windows selected {networkInterface.Name} ({localAddress}), but {matches.Count} Npcap adapters matched."; - } - else - { - routeDetail = $"Windows selected local address {localAddress}, but its network interface was not found."; - } + var route = ResolveLocalIpv4ForTarget(target); + var interfaces = NetworkInterface.GetAllNetworkInterfaces() + .Select(nic => new GooseWindowsInterfaceSnapshot( + nic.Id, + nic.Name, + nic.Description, + nic.GetPhysicalAddress().ToString(), + nic.GetIPProperties().UnicastAddresses + .Where(unicast => unicast.Address.AddressFamily == AddressFamily.InterNetwork) + .Select(unicast => unicast.Address) + .ToArray())) + .ToArray(); + var selection = GooseAdapterSelectionPolicy.Resolve(target, route, interfaces, GooseAdapters.ToArray()); + routeDetail = selection.Evidence; + return selection.Adapter; } - - var usable = GooseAdapters - .Where(adapter => !LooksLikeLoopback(adapter)) - .ToList(); - if (usable.Count == 1) + catch (Exception ex) when (ex is NetworkInformationException or SocketException or InvalidOperationException) { - routeDetail += $" Falling back to the only non-loopback capture adapter: {usable[0].DisplayText}."; - return usable[0]; + routeDetail = $"Network adapter identity unavailable: {ex.Message}. Select the capture adapter manually."; + return null; } - - return null; } private static IPAddress? ResolveLocalIpv4ForTarget(IPAddress target) @@ -236,9 +224,5 @@ private static string NormalizeGooseAdapterMac(string? value) => Regex.Replace(value ?? string.Empty, "[^0-9A-Fa-f]", string.Empty).ToUpperInvariant(); private static bool LooksLikeLoopback(GooseAdapterOption adapter) - { - var text = $"{adapter.Name} {adapter.Description} {adapter.FriendlyName}"; - return text.Contains("loopback", StringComparison.OrdinalIgnoreCase) || - text.Contains("npcap loopback", StringComparison.OrdinalIgnoreCase); - } + => GooseAdapterSelectionPolicy.IsNpcapPseudoLoopback(adapter); } diff --git a/Services/GooseAdapterSelectionPolicy.cs b/Services/GooseAdapterSelectionPolicy.cs new file mode 100644 index 000000000..e2c7cbaff --- /dev/null +++ b/Services/GooseAdapterSelectionPolicy.cs @@ -0,0 +1,133 @@ +using System.Net; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// A snapshot of Windows interface identity; deliberately independent of live OS APIs for replay tests. +public sealed record GooseWindowsInterfaceSnapshot( + string Id, + string Name, + string Description, + string MacAddress, + IReadOnlyList Ipv4Addresses); + +public sealed record GooseAdapterRouteDecision( + GooseAdapterOption? Adapter, + bool IsSameHost, + string Evidence) +{ + public bool HasAuthoritativeMatch => Adapter is not null; +} + +/// +/// Pure identity-based GOOSE capture candidate selection. A successful MMS/IP route +/// is NOT evidence that an Ethernet GOOSE publisher exists on the capture interface. +/// The calling UI may start read-only observation only on an exact, unique match. +/// +public static class GooseAdapterSelectionPolicy +{ + public static GooseAdapterRouteDecision Resolve( + IPAddress target, + IPAddress? routedLocalAddress, + IReadOnlyList windowsInterfaces, + IReadOnlyList captureAdapters) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(windowsInterfaces); + ArgumentNullException.ThrowIfNull(captureAdapters); + + var ownsTarget = windowsInterfaces.Where(nic => HasAddress(nic, target)).ToArray(); + var sameHost = IPAddress.IsLoopback(target) || ownsTarget.Length != 0; + if (sameHost) + { + if (ownsTarget.Length != 1) + return new(null, true, + "Same-PC MMS endpoint: no unique Windows adapter owns the target IP. Select a capture adapter manually; IP loopback does not prove Ethernet GOOSE."); + return Match(ownsTarget[0], captureAdapters, true); + } + + if (routedLocalAddress is null) + return new(null, false, "MMS route unavailable. Select the actual GOOSE station adapter; a sole NIC is not sufficient evidence."); + + var routedInterfaces = windowsInterfaces.Where(nic => HasAddress(nic, routedLocalAddress)).ToArray(); + if (routedInterfaces.Length != 1) + return new(null, false, + "Windows MMS route did not resolve to one interface. Choose the GOOSE capture adapter manually."); + + return Match(routedInterfaces[0], captureAdapters, false); + } + + /// The dedicated Npcap software pseudo-device, NOT a Windows KM-TEST virtual Ethernet NIC. + public static bool IsNpcapPseudoLoopback(GooseAdapterOption adapter) + { + var name = adapter.Name.Trim(); + return name.Equals(@"\Device\NPF_Loopback", StringComparison.OrdinalIgnoreCase) || + name.Equals("NPF_Loopback", StringComparison.OrdinalIgnoreCase); + } + + private static GooseAdapterRouteDecision Match( + GooseWindowsInterfaceSnapshot nic, + IReadOnlyList adapters, + bool sameHost) + { + var context = sameHost ? "Same-PC MMS simulator" : "Windows MMS route"; + var candidates = adapters.Where(adapter => !IsNpcapPseudoLoopback(adapter)).ToArray(); + var winGuid = ParseGuid(nic.Id); + if (winGuid is not null) + { + var guidMatches = candidates.Where(adapter => ParseGuid(adapter.Name) == winGuid).ToArray(); + if (guidMatches.Length == 1) + return new(guidMatches[0], sameHost, + $"{context}: exact Npcap/Windows interface GUID for {nic.Name}. GOOSE frames still unverified."); + if (guidMatches.Length > 1) + return new(null, sameHost, + $"{context}: multiple Npcap capture devices share the interface GUID. Choose manually."); + } + + // MAC fallback is safe only when BOTH MACs are valid, the match is + // unique, and a contradictory explicit interface GUID is absent. + var mac = NormalizeValidMac(nic.MacAddress); + if (mac is not null) + { + var macMatches = candidates.Where(adapter => + string.Equals(NormalizeValidMac(adapter.MacAddress), mac, StringComparison.Ordinal) && + (winGuid is null || ParseGuid(adapter.Name) is null)).ToArray(); + if (macMatches.Length == 1) + return new(macMatches[0], sameHost, + $"{context}: one non-conflicting Npcap adapter matched the Windows interface MAC for {nic.Name}. GOOSE frames still unverified."); + if (macMatches.Length > 1) + return new(null, sameHost, + $"{context}: several capture adapters share the MAC. Choose the GOOSE adapter manually."); + } + + return new(null, sameHost, + $"{context}: no unique GUID/MAC-matched Npcap adapter for {nic.Name}. " + + (sameHost + ? "KM-TEST is eligible when Npcap exposes its real Ethernet interface, but the Npcap IP-loopback pseudo-device is not equivalent." + : "The IP route is only a hint; choose the actual station Ethernet adapter.")); + } + + private static bool HasAddress(GooseWindowsInterfaceSnapshot nic, IPAddress address) + => nic.Ipv4Addresses.Any(candidate => candidate.Equals(address)); + + private static Guid? ParseGuid(string? source) + { + if (string.IsNullOrWhiteSpace(source)) + return null; + var first = source.IndexOf('{'); + if (first < 0) + return Guid.TryParse(source, out var standalone) ? standalone : null; + var last = source.IndexOf('}', first + 1); + return last > first && Guid.TryParse(source.AsSpan(first, last - first + 1), out var guid) + ? guid : null; + } + + private static string? NormalizeValidMac(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + return null; + var mac = new string(input.Where(Uri.IsHexDigit).Select(char.ToUpperInvariant).ToArray()); + return mac.Length is 12 or 16 && mac.Any(ch => ch != '0') && mac.Any(ch => ch != 'F') + ? mac : null; + } +} diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml index b0892f12c..533925280 100644 --- a/Views/GooseSubscriberLiteView.xaml +++ b/Views/GooseSubscriberLiteView.xaml @@ -146,7 +146,7 @@ + TextTrimming="CharacterEllipsis" ToolTip="{Binding GooseStatusText}"/> diff --git a/docs/GOOSE_SUBSCRIBER.md b/docs/GOOSE_SUBSCRIBER.md index 5e90bc357..1996f7435 100644 --- a/docs/GOOSE_SUBSCRIBER.md +++ b/docs/GOOSE_SUBSCRIBER.md @@ -94,3 +94,10 @@ Validate the feature with a known publisher and SCL before relying on names in p - Vendor-specific structured DataSet members are rendered compactly as one `allData` item. The grid does not invent nested field names beyond metadata provided by ARIEC61850. - Npcap capture visibility depends on Windows driver installation, adapter binding, VLAN offload behavior, and switch port mirroring/network topology. - This workspace provides engineering evidence, not an IEC 61850 conformance certificate or cybersecurity assurance. + + +## Same-PC C264 adapter selection (P8, draft field candidate) + +The IED-card GOOSE entry now resolves the capture NIC from authoritative Windows/Npcap interface GUID, then a unique non-conflicting valid MAC. For an MMS endpoint hosted on this computer, the **interface owning the target IPv4** takes precedence over the Windows socket route. A KM-TEST virtual Ethernet adapter is not rejected just because its friendly name says "loopback"; only the dedicated Npcap `NPF_Loopback` pseudo-interface is excluded from automatic Ethernet capture. If identity is missing or ambiguous, the operator selects the adapter manually; no random one-NIC fallback occurs. The manual picker still supports explicit Npcap loopback investigation. + +Clicking the IED-card GOOSE action may begin *read-only observation* when an exact unique NIC match exists; it is **not** evidence that a GOOSE publisher emitted a frame. The top bar explains that actual Ethernet GOOSE reception remains pending and suggests checking whether the simulator publishes and whether that adapter receives multicast/VLAN traffic. These conditions must be verified on the operator's PC before claiming capture parity. No new native transport, background adapter scan, per-packet UI timer, or control/write traffic is introduced. diff --git a/tests/ARSAS.Tests/GooseAdapterSelectionPolicyTests.cs b/tests/ARSAS.Tests/GooseAdapterSelectionPolicyTests.cs new file mode 100644 index 000000000..077569478 --- /dev/null +++ b/tests/ARSAS.Tests/GooseAdapterSelectionPolicyTests.cs @@ -0,0 +1,136 @@ +using System.Net; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class GooseAdapterSelectionPolicyTests +{ + private const string KmGuid = "89AAB1E2-3A41-48AC-B32F-5DD8A341BB11"; + private const string OtherGuid = "77AC42AA-5920-421E-80E0-A19BCE61B727"; + private static IPAddress Ip(string address) => IPAddress.Parse(address); + + private static GooseWindowsInterfaceSnapshot Nic( + string id, string name, string mac, params string[] addresses) => + new(id, name, name, mac, addresses.Select(Ip).ToArray()); + + private static GooseAdapterOption Capture( + string name, string friendly, string mac = "", int index = 0) => + new() { Index = index, Name = name, FriendlyName = friendly, Description = friendly, MacAddress = mac }; + + [Fact] + public void SameHostKmTest_UsesOwnedTargetIpAndExactGuid_EvenWhenNameContainsLoopback() + { + var km = Nic(KmGuid, "KM_TEST Microsoft KM-TEST Loopback Adapter #2", + "02-14-20-33-44-55", "192.16.1.13", "192.16.1.33"); + var other = Nic(OtherGuid, "Wi-Fi", "12-22-33-44-55-66", "192.168.1.4"); + var observedKm = Capture(@"\Device\NPF_{" + KmGuid + "}", "KM_TEST Loopback Adapter", index: 3); + var loopback = Capture(@"\Device\NPF_Loopback", "Npcap Loopback Adapter", index: 0); + + var result = GooseAdapterSelectionPolicy.Resolve( + Ip("192.16.1.13"), Ip("192.168.1.4"), + new[] { km, other }, new[] { loopback, observedKm }); + Assert.True(result.IsSameHost); + Assert.Same(observedKm, result.Adapter); + Assert.True(result.HasAuthoritativeMatch); + Assert.Contains("GUID", result.Evidence); + Assert.Contains("unverified", result.Evidence); + Assert.False(GooseAdapterSelectionPolicy.IsNpcapPseudoLoopback(observedKm)); + Assert.True(GooseAdapterSelectionPolicy.IsNpcapPseudoLoopback(loopback)); + } + + [Fact] + public void UnmatchedKmTest_NeverFallsBackToOnlyUnrelatedPhysicalNicOrPseudoLoopback() + { + var km = Nic(KmGuid, "KM_TEST Loopback", "02-14-20-33-44-55", "192.16.1.13"); + var wlan = Capture(@"\Device\NPF_{" + OtherGuid + "}", "Wi-Fi", "AA-BB-CC-DD-EE-22"); + var result = GooseAdapterSelectionPolicy.Resolve( + Ip("192.16.1.13"), Ip("192.16.1.13"), new[] { km }, + new[] { wlan, Capture(@"\Device\NPF_Loopback", "Npcap Loopback Adapter") }); + Assert.Null(result.Adapter); + Assert.True(result.IsSameHost); + Assert.Contains("no unique", result.Evidence); + } + + [Fact] + public void SameHostMultipleAliasesOnOneInterface_RemainsUnambiguousButDuplicateOwnersFailClosed() + { + var primary = Nic(KmGuid, "KM_TEST", "02-14-20-33-44-55", "192.16.1.13", "192.16.1.33"); + var device = Capture(@"\Device\NPF_{" + KmGuid + "}", "KM_TEST"); + Assert.Same(device, GooseAdapterSelectionPolicy.Resolve( + Ip("192.16.1.33"), null, new[] { primary }, new[] { device }).Adapter); + + var clone = Nic(OtherGuid, "Conflicting assigned alias", "22-33-44-55-66-77", "192.16.1.33"); + var result = GooseAdapterSelectionPolicy.Resolve( + Ip("192.16.1.33"), null, new[] { primary, clone }, new[] { device }); + Assert.Null(result.Adapter); + Assert.Contains("no unique Windows adapter", result.Evidence); + } + + [Fact] + public void RemoteIED_UniqueWindowsRouteMayIdentifyAdapter_ButNoRouteMustNotGuess() + { + var ethernet = Nic(KmGuid, "Ethernet", "02:14:20:33:44:55", "10.0.0.20"); + var adapter = Capture(@"\Device\NPF_{" + KmGuid + "}", "Ethernet"); + var matched = GooseAdapterSelectionPolicy.Resolve( + Ip("10.0.0.50"), Ip("10.0.0.20"), new[] { ethernet }, new[] { adapter }); + Assert.Same(adapter, matched.Adapter); + Assert.False(matched.IsSameHost); + + var noRoute = GooseAdapterSelectionPolicy.Resolve( + Ip("10.0.0.50"), null, new[] { ethernet }, new[] { adapter }); + Assert.Null(noRoute.Adapter); + } + + [Fact] + public void UniqueMacFallback_AcceptsUnknownCaptureGuidOnly_RejectsContradictoryGuid() + { + var nic = Nic(KmGuid, "KM_TEST", "02-14-20-33-44-55", "192.16.1.13"); + var noGuid = Capture("capture-opaque", "KM_TEST", "02:14:20:33:44:55"); + Assert.Same(noGuid, GooseAdapterSelectionPolicy.Resolve( + Ip("192.16.1.13"), null, new[] { nic }, new[] { noGuid }).Adapter); + + var wrongGuid = Capture(@"\Device\NPF_{" + OtherGuid + "}", "KM_TEST", "02:14:20:33:44:55"); + Assert.Null(GooseAdapterSelectionPolicy.Resolve( + Ip("192.16.1.13"), null, new[] { nic }, new[] { wrongGuid }).Adapter); + } + + [Fact] + public void DuplicateCaptureGuidAndIpLoopback_FailClosed() + { + var nic = Nic(KmGuid, "KM_TEST", "02-14-20-33-44-55", "192.16.1.13"); + var first = Capture(@"\Device\NPF_{" + KmGuid + "}", "KM_TEST", index: 1); + var second = Capture(@"\Device\NPF_{" + KmGuid + "}", "KM_TEST #2", index: 2); + Assert.Null(GooseAdapterSelectionPolicy.Resolve( + Ip("192.16.1.13"), null, new[] { nic }, new[] { first, second }).Adapter); + Assert.Null(GooseAdapterSelectionPolicy.Resolve( + Ip("127.0.0.1"), Ip("127.0.0.1"), new[] { nic }, new[] { first }).Adapter); + } + + [Fact] + public void CardEntryKeepsReadOnlyCaptureAndNeverEquatesMmsToObservedGoose() + { + var source = Read("MainWindow.IedGooseQuickStart.cs"); + var subscriber = Read("MainWindow.GooseSubscriber.cs"); + Assert.Contains("GooseAdapterSelectionPolicy.Resolve", source); + Assert.Contains("GooseAdapterSelectionPolicy.IsNpcapPseudoLoopback", source); + Assert.DoesNotContain("Where(adapter => !LooksLikeLoopback(adapter))", subscriber); + Assert.Contains("StartGooseSubscriber_Click(this, new RoutedEventArgs())", source); + Assert.Contains("_pendingIedGooseAutoStart = device", source); + Assert.Contains("GooseSubscriberRuntime.DefaultCaptureFilter", subscriber); + Assert.Contains("Waiting for actual GOOSE frames", subscriber); + Assert.DoesNotContain("SetTime", source); + } + + private static string Read(string path) + { + DirectoryInfo? folder = new(AppContext.BaseDirectory); + while (folder is not null) + { + var file = Path.Combine(folder.FullName, path); + if (File.Exists(file)) return File.ReadAllText(file); + folder = folder.Parent; + } + throw new FileNotFoundException(path); + } +}