From b27842a950959028421c96119e3e62bd9fa5c519 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 15:52:33 +0700 Subject: [PATCH 01/18] test(identity): reproduce truncated device-name suffix and unverified confidence --- .../Discovery/LiveIedIdentityResolverTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index 308799b6..3154cc88 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -6,6 +6,38 @@ namespace AR.Iec61850.Tests.Discovery; public sealed class LiveIedIdentityResolverTests { + + [Fact] + public void Resolve_Five_LogicalDevices_Preserves_Full_Common_Ied_Name_Without_Scl() + { + var domains = new[] { "SIEBCUCTRL", "SIEBCUMEAS", "SIEBCUPROT", "SIEBCUUD1", "SIEBCUUD2" }; + var identity = LiveIedIdentityResolver.Resolve(domains, "192.0.2.20"); + + Assert.Equal("SIEBCU", identity.IedName); + Assert.Equal("MmsDomainCommonPrefix", identity.Source); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.Exact, identity.Confidence); + Assert.Equal("CTRL", identity.LogicalDeviceAliases["SIEBCUCTRL"]); + Assert.Equal("UD2", identity.LogicalDeviceAliases["SIEBCUUD2"]); + } + + [Theory] + [InlineData("BCU")] + [InlineData("PROT")] + [InlineData("CTRL")] + [InlineData("MEAS")] + [InlineData("MU")] + public void Resolve_Common_Prefix_Does_Not_Remove_A_Valid_Ied_Name_Ending(string ending) + { + var fullName = "STATION" + ending; + var identity = LiveIedIdentityResolver.Resolve( + [fullName + "Application", fullName + "Reports", fullName + "Custom1"], + "192.0.2.21"); + + Assert.Equal(fullName, identity.IedName); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + } + [Fact] public void Resolve_Preserves_Digits_In_A_Single_Ld0_Domain() { From bcc8e228bc8d610ba3ffb786412a859e9fd57194 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 15:52:59 +0700 Subject: [PATCH 02/18] fix(identity): preserve exact shared prefix and mark unverified domain identity provisional --- .../Discovery/LiveIedIdentityResolver.cs | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs index 8508a176..8a6e2c01 100644 --- a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs +++ b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs @@ -89,9 +89,7 @@ public static LiveIedIdentity Resolve( var commonPrefix = InferCommonPrefix(materialized); if (!string.IsNullOrWhiteSpace(commonPrefix)) { - var confidence = materialized.Length >= 3 - ? LiveIedDiscoveryConfidenceLevel.High - : LiveIedDiscoveryConfidenceLevel.Medium; + var confidence = LiveIedDiscoveryConfidenceLevel.Medium; return Create( commonPrefix, "MmsDomainCommonPrefix", @@ -235,18 +233,10 @@ private static string InferCommonPrefix(IReadOnlyList domains) return string.Empty; } + // The common prefix is evidence of the shared IED-name candidate, not a + // logical-device suffix. Removing a known LD stem here corrupts valid + // names such as SIEBCU, STATIONPROT and STATIONMU. prefix = TrimBoundary(prefix); - foreach (var stem in KnownLogicalDeviceStems) - { - if (prefix.EndsWith(stem, StringComparison.OrdinalIgnoreCase) && prefix.Length > stem.Length) - { - var withoutStem = TrimBoundary(prefix[..^stem.Length]); - if (IsViableName(withoutStem)) - prefix = withoutStem; - break; - } - } - return IsViableName(prefix) ? prefix : string.Empty; } From e1ba425f6c7a94581c3bee1b5c0d7a025ada38e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 15:53:02 +0700 Subject: [PATCH 03/18] test(identity): align pre-existing shared-prefix expectation with provisional confidence --- .../AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index 3154cc88..9812e9a4 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -77,7 +77,7 @@ public void Resolve_Does_Not_Promote_A_Nested_Mu_Domain_To_The_Physical_Ied_Name Assert.Equal("OCR7SJ8", identity.IedName); Assert.Equal("MmsDomainCommonPrefix", identity.Source); - Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); Assert.Equal("Mod2_MU1", identity.LogicalDeviceAliases["OCR7SJ8Mod2_MU1"]); Assert.DoesNotContain("OCR7SJ8Mod2", identity.CandidateNames); } From f2bf05ea6e27c7ae0c650162fc0c7bcf9870f077 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:00:24 +0700 Subject: [PATCH 04/18] test(identity): require all MMS-only and operator identities remain unverified --- .../Discovery/LiveIedIdentityResolverTests.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index 9812e9a4..d90426db 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -57,7 +57,7 @@ public void Resolve_Uses_All_Domains_To_Confirm_The_Ied_Name() "192.0.2.10"); Assert.Equal("OLSF501", identity.IedName); - Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); Assert.False(identity.IsAmbiguous); Assert.Equal("PROT", identity.LogicalDeviceAliases["OLSF501PROT"]); } @@ -101,7 +101,18 @@ public void Resolve_Uses_Explicit_Name_Over_Live_Heuristic() Assert.Equal("COMMISSIONED_IED", identity.IedName); Assert.Equal("ExplicitOverride", identity.Source); - Assert.Equal(LiveIedDiscoveryConfidenceLevel.Exact, identity.Confidence); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + Assert.Equal("ExplicitOverride", identity.Source); + } + + [Fact] + public void Heuristic_Suffix_Is_Never_A_Verified_Identity_Even_With_Five_Lds() + { + var identity = LiveIedIdentityResolver.Resolve( + ["PLANTCTRL", "PLANTMEAS", "PLANTPROT", "PLANTLD0", "PLANTMU1"], "192.0.2.90"); + Assert.Equal("PLANT", identity.IedName); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + Assert.DoesNotContain(identity.CandidateNames, name => name.Equals("PLANTCTRL", StringComparison.Ordinal)); } [Fact] @@ -113,7 +124,7 @@ public void Binding_Identity_Resolver_Uses_The_Live_Domain_Resolver() Assert.Equal("OLSF501", identity.DisplayName); Assert.Equal("MmsDomainKnownLogicalDeviceSuffix", identity.Source); - Assert.Equal("High", identity.Confidence); + Assert.Equal("Medium", identity.Confidence); Assert.Equal("LD1", identity.LogicalDeviceAliases["OLSF501LD1"]); } From 5b30f51fac5cbc34d20232573fc5b127cdf3b809 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:00:42 +0700 Subject: [PATCH 05/18] test(identity): require provisional confidence for suffix and operator alias --- .../Discovery/LiveIedIdentityResolverTests.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index d90426db..8d143c23 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -94,6 +94,28 @@ public void Resolve_Rejects_Conflicting_Domain_Candidates() Assert.Equal(["ALPHA", "BETA"], identity.CandidateNames); } + + [Fact] + public void Resolve_Fully_Known_Ld_Suffixes_Is_Still_Provisional_Without_Scl() + { + var identity = LiveIedIdentityResolver.Resolve( + ["STATIONBCUCTRL", "STATIONBCUPROT", "STATIONBCUMEAS"], + "192.0.2.20"); + Assert.Equal("STATIONBCU", identity.IedName); + Assert.Equal("MmsDomainKnownLogicalDeviceSuffix", identity.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + } + + [Fact] + public void Resolve_Explicit_Alias_Is_Operator_Supplied_Not_Wire_Verified() + { + var identity = LiveIedIdentityResolver.Resolve( + ["WIRELD0"], "192.0.2.20", explicitIedName: "OPERATOR_ALIAS"); + Assert.Equal("OPERATOR_ALIAS", identity.IedName); + Assert.Equal("ExplicitOverride", identity.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + } + [Fact] public void Resolve_Uses_Explicit_Name_Over_Live_Heuristic() { From fbd6c4f304335e1f69082e4f56e472e9bf5057a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:00:45 +0700 Subject: [PATCH 06/18] test(identity): require exact five-LD SCL and fail-closed AP/IP/domain evidence --- .../TrustedSclIedIdentityMatcherTests.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs index 62274a78..32f48d80 100644 --- a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs @@ -93,6 +93,49 @@ public void Ambiguous_Duplicate_AccessPoints_And_Conflicting_Sources_Are_Not_Pro model,unverified)); } + + [Fact] + public void Five_Domain_Bcu_Scl_Promotes_Exact_Identity_With_Logical_Device_Aliases() + { + var scl=Design("SIEBCU","CTRL","192.0.2.20"); + XNamespace ns=SclNs; + var server=Assert.Single(scl.Descendants(ns+"Server")); + foreach (var suffix in new[] {"MEAS","PROT","UD1","UD2"}) + server.Add(new XElement(ns+"LDevice",new XAttribute("inst",suffix), + new XElement(ns+"LN0",new XAttribute("lnType","LLN0_type")))); + var domains=new[] {"SIEBCUCTRL","SIEBCUMEAS","SIEBCUPROT","SIEBCUUD1","SIEBCUUD2"}; + var verified=TrustedSclIedIdentityMatcher.TryMatch( + scl,domains,"192.0.2.20",selectedIedName:"SIEBCU",selectedAccessPointName:"S1"); + Assert.NotNull(verified); + Assert.Equal("SIEBCU",verified.IedName); + Assert.Equal("TrustedSclExactDomainMatch",verified.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High,verified.Confidence); + Assert.Equal("UD2",verified.LogicalDeviceAliases["SIEBCUUD2"]); + Assert.Equal(5,verified.LogicalDeviceAliases.Count); + } + + [Fact] + public void Scl_Endpoint_Address_And_AccessPoint_Are_Mandatory_For_Verified_Identity() + { + XNamespace ns=SclNs; + var scl=Design("STATIONBCU","CTRL","192.0.2.30"); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl,["STATIONBCUCTRL"],"192.0.2.30",selectedAccessPointName:"S2")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl,["STATIONBCUCTRL"],null)); + var noIp=new XDocument(scl); + Assert.Single(noIp.Descendants(ns+"P")).Remove(); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + noIp,["STATIONBCUCTRL"],"192.0.2.30")); + var noConnectedAp=new XDocument(scl); + Assert.Single(noConnectedAp.Descendants(ns+"ConnectedAP")).Remove(); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + noConnectedAp,["STATIONBCUCTRL"],"192.0.2.30")); + var wrongDomainCase=new[] {"stationBCUCTRL"}; + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl,wrongDomainCase,"192.0.2.30")); + } + [Fact] public void Other_Devices_And_Already_Recognized_Multiple_Domains_Are_Unchanged() { From 4cc3bde292ab43d6066fc41419dd32736b87cf49 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:00:53 +0700 Subject: [PATCH 07/18] test(identity): five-LD SCL proof and fail-closed missing endpoint --- .../TrustedSclIedIdentityMatcherTests.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs index 32f48d80..69a8a823 100644 --- a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs @@ -8,6 +8,39 @@ public sealed class TrustedSclIedIdentityMatcherTests { private const string SclNs = "http://www.iec.ch/61850/2003/SCL"; + + [Fact] + public void Five_Ld_Scl_Uniquely_Verifies_Full_Ied_Name_Not_A_Stripped_Prefix() + { + XNamespace ns = SclNs; + var scl = Design("SIEBCU", "CTRL", "192.0.2.55"); + var server = Assert.Single(scl.Descendants(ns + "Server")); + foreach (var inst in new[] { "MEAS", "PROT", "UD1", "UD2" }) + server.Add(new XElement(ns + "LDevice", new XAttribute("inst", inst))); + + var domains = new[] { "SIEBCUCTRL", "SIEBCUMEAS", "SIEBCUPROT", "SIEBCUUD1", "SIEBCUUD2" }; + var identity = TrustedSclIedIdentityMatcher.TryMatch(scl, domains, "192.0.2.55", "SIEBCU"); + + Assert.NotNull(identity); + Assert.Equal("SIEBCU", identity.IedName); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + Assert.Equal("UD2", identity.LogicalDeviceAliases["SIEBCUUD2"]); + Assert.Equal(5, identity.LogicalDeviceAliases.Count); + } + + [Fact] + public void Engineering_Topology_Without_Unique_Confirmed_Endpoint_Cannot_Promote_Identity() + { + XNamespace ns = SclNs; + var scl = Design("SIEBCU", "CTRL", "192.0.2.55"); + scl.Root!.Element(ns + "Communication")!.Remove(); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl, ["SIEBCUCTRL"], "192.0.2.55")); + + var validScl = Design("SIEBCU", "CTRL", "192.0.2.55"); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(validScl, ["SIEBCUCTRL"], null)); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(validScl, ["SIEBCUCTRL"], "not-an-ip")); + } + [Fact] public void GE_F650_Single_Domain_Uses_SCL_Evidence_Not_Vendor_Suffix_Heuristic() { From 10b3584d6c4ba048b80934619940a6bcfa42f653 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:01:04 +0700 Subject: [PATCH 08/18] fix(identity): do not mark operator aliases or MMS suffix heuristics as verified --- src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs index 8a6e2c01..8c586748 100644 --- a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs +++ b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs @@ -38,8 +38,8 @@ public static LiveIedIdentity Resolve( if (!string.IsNullOrWhiteSpace(explicitIedName)) { var name = explicitIedName.Trim(); - return Create(name, "ExplicitOverride", LiveIedDiscoveryConfidenceLevel.Exact, false, [name], materialized, - [$"IED name was supplied explicitly as '{name}'."]); + return Create(name, "ExplicitOverride", LiveIedDiscoveryConfidenceLevel.Medium, false, [name], materialized, + [$"Operator-supplied IED display name '{name}' has not been independently verified against this MMS endpoint."]); } var suffixMatches = materialized @@ -56,7 +56,7 @@ public static LiveIedIdentity Resolve( .OrderBy(candidate => candidate, StringComparer.OrdinalIgnoreCase) .ToArray(); - // A known LD suffix is authoritative only when it explains the complete domain set. + // A known LD suffix is only a provisional naming hint even when it explains the complete domain set. // A single nested domain such as OCR7SJ8Mod2_MU1 must never rename the physical IED // to OCR7SJ8Mod2 while sibling domains prove the shared OCR7SJ8 prefix. if (materialized.Length > 0 && suffixMatches.Length == materialized.Length) @@ -64,9 +64,7 @@ public static LiveIedIdentity Resolve( if (distinctSuffixCandidates.Length == 1) { var name = distinctSuffixCandidates[0]; - var confidence = materialized.Length > 1 - ? LiveIedDiscoveryConfidenceLevel.High - : LiveIedDiscoveryConfidenceLevel.Medium; + var confidence = LiveIedDiscoveryConfidenceLevel.Medium; var evidence = suffixMatches .Select(match => $"MMS domain '{match.Domain}' matched the logical-device suffix pattern for IED '{name}'.") .ToArray(); @@ -110,7 +108,7 @@ public static LiveIedIdentity Resolve( return Create( name, "MmsDomainKnownLogicalDeviceSuffix", - materialized.Length == 1 ? LiveIedDiscoveryConfidenceLevel.Medium : LiveIedDiscoveryConfidenceLevel.High, + LiveIedDiscoveryConfidenceLevel.Medium, false, distinctSuffixCandidates, materialized, From 1f3a601392f08117a059b9bec932d9c6ee45825c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:01:25 +0700 Subject: [PATCH 09/18] fix(identity): mark all MMS suffix inference and operator alias as provisional From f2bdb7f990fbb10fce9b5abeb2ca5a5cb2cdaeba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:01:28 +0700 Subject: [PATCH 10/18] fix(identity): require exact SCL domain/AP and connected endpoint proof --- .../Discovery/TrustedSclIedIdentityMatcher.cs | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs index a3b8918a..86d3b1a7 100644 --- a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -17,7 +17,8 @@ public static class TrustedSclIedIdentityMatcher XDocument trustedScl, IEnumerable observedMmsDomains, string? connectedHost, - string? selectedIedName = null) + string? selectedIedName = null, + string? selectedAccessPointName = null) { ArgumentNullException.ThrowIfNull(trustedScl); ArgumentNullException.ThrowIfNull(observedMmsDomains); @@ -25,8 +26,8 @@ public static class TrustedSclIedIdentityMatcher var observed = observedMmsDomains .Where(s => !string.IsNullOrWhiteSpace(s)) .Select(s => s.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + .Distinct(StringComparer.Ordinal) + .ToHashSet(StringComparer.Ordinal); if (observed.Count == 0) return null; var matches = new List<(string Ied, string Ap, Dictionary Aliases, string? Ip)>(); @@ -35,7 +36,7 @@ public static class TrustedSclIedIdentityMatcher var name = ((string?)ied.Attribute("name") ?? "").Trim(); if (string.IsNullOrWhiteSpace(name) || (!string.IsNullOrWhiteSpace(selectedIedName) && - !name.Equals(selectedIedName.Trim(), StringComparison.OrdinalIgnoreCase))) + !name.Equals(selectedIedName.Trim(), StringComparison.Ordinal))) continue; foreach (var ap in ied.Elements(Scl + "AccessPoint")) @@ -46,7 +47,7 @@ public static class TrustedSclIedIdentityMatcher ?? Array.Empty(); if (devices.Length != observed.Count) continue; - var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + var aliases = new Dictionary(StringComparer.Ordinal); var invalid = false; foreach (var ld in devices) { @@ -68,30 +69,32 @@ public static class TrustedSclIedIdentityMatcher var connected = trustedScl.Root.Element(Scl + "Communication")? .Descendants(Scl + "ConnectedAP") - .Where(x => string.Equals((string?)x.Attribute("iedName"),name,StringComparison.OrdinalIgnoreCase) - && string.Equals((string?)x.Attribute("apName"),apName,StringComparison.OrdinalIgnoreCase)) + .Where(x => string.Equals((string?)x.Attribute("iedName"),name,StringComparison.Ordinal) + && string.Equals((string?)x.Attribute("apName"),apName,StringComparison.Ordinal)) .ToArray() ?? Array.Empty(); - if (connected.Length > 1) continue; - var ipValues = connected.SingleOrDefault()? - .Element(Scl + "Address")?.Elements(Scl + "P") + // SCL topology alone is not endpoint evidence. Require exactly one + // declared ConnectedAP/IP and an exact observed association endpoint. + // Missing/ambiguous endpoints remain provisional, never verified. + if (connected.Length != 1 || string.IsNullOrWhiteSpace(connectedHost)) + continue; + var ipValues = connected[0].Element(Scl + "Address")?.Elements(Scl + "P") .Where(x => string.Equals((string?)x.Attribute("type"),"IP",StringComparison.OrdinalIgnoreCase)) .Select(x => x.Value.Trim()).Take(2).ToArray() ?? Array.Empty(); - if (ipValues.Length > 1) continue; - var ip = ipValues.SingleOrDefault(); - if (!string.IsNullOrWhiteSpace(ip) && !string.IsNullOrWhiteSpace(connectedHost)) - { - if (!IPAddress.TryParse(ip,out var designIp) || - !IPAddress.TryParse(connectedHost.Trim(),out var observedIp) || - !designIp.Equals(observedIp)) - continue; // never promote a model for a different endpoint - } - matches.Add((name, apName, aliases, ip)); + if (ipValues.Length != 1 || + !IPAddress.TryParse(ipValues[0],out var designIp) || + !IPAddress.TryParse(connectedHost.Trim(),out var observedIp) || + !designIp.Equals(observedIp)) + continue; + matches.Add((name, apName, aliases, ipValues[0])); } } // A single IED can have multiple APs and the same LD topology. This // remains ambiguous until AP is independently selected/proven. if (matches.Count != 1) return null; var match=matches[0]; + if (!string.IsNullOrWhiteSpace(selectedAccessPointName) && + !string.Equals(match.Ap, selectedAccessPointName.Trim(), StringComparison.Ordinal)) + return null; return new LiveIedIdentity { IedName=match.Ied, From a662834be11227b1872df011551029fbc24e9e81 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:07:05 +0700 Subject: [PATCH 11/18] fix(identity): fail closed on topology-equivalent AP with missing endpoint evidence --- .../Discovery/TrustedSclIedIdentityMatcher.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs index 86d3b1a7..b7fd9424 100644 --- a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -31,6 +31,7 @@ public static class TrustedSclIedIdentityMatcher if (observed.Count == 0) return null; var matches = new List<(string Ied, string Ap, Dictionary Aliases, string? Ip)>(); + var ambiguousUnboundTopology = false; foreach (var ied in trustedScl.Root.Elements(Scl + "IED")) { var name = ((string?)ied.Attribute("name") ?? "").Trim(); @@ -76,21 +77,28 @@ public static class TrustedSclIedIdentityMatcher // declared ConnectedAP/IP and an exact observed association endpoint. // Missing/ambiguous endpoints remain provisional, never verified. if (connected.Length != 1 || string.IsNullOrWhiteSpace(connectedHost)) + { + ambiguousUnboundTopology = true; continue; + } var ipValues = connected[0].Element(Scl + "Address")?.Elements(Scl + "P") .Where(x => string.Equals((string?)x.Attribute("type"),"IP",StringComparison.OrdinalIgnoreCase)) .Select(x => x.Value.Trim()).Take(2).ToArray() ?? Array.Empty(); if (ipValues.Length != 1 || !IPAddress.TryParse(ipValues[0],out var designIp) || - !IPAddress.TryParse(connectedHost.Trim(),out var observedIp) || - !designIp.Equals(observedIp)) + !IPAddress.TryParse(connectedHost.Trim(),out var observedIp)) + { + ambiguousUnboundTopology = true; + continue; + } + if (!designIp.Equals(observedIp)) continue; matches.Add((name, apName, aliases, ipValues[0])); } } // A single IED can have multiple APs and the same LD topology. This // remains ambiguous until AP is independently selected/proven. - if (matches.Count != 1) return null; + if (matches.Count != 1 || ambiguousUnboundTopology) return null; var match=matches[0]; if (!string.IsNullOrWhiteSpace(selectedAccessPointName) && !string.Equals(match.Ap, selectedAccessPointName.Trim(), StringComparison.Ordinal)) From 9e6b142adf6b09da40ae7ffe520a9ccdf5f9a386 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:08:55 +0700 Subject: [PATCH 12/18] fix(identity): reject all duplicate topology-compatible APs before endpoint filtering --- .../Discovery/TrustedSclIedIdentityMatcher.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs index b7fd9424..51376afd 100644 --- a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -31,6 +31,7 @@ public static class TrustedSclIedIdentityMatcher if (observed.Count == 0) return null; var matches = new List<(string Ied, string Ap, Dictionary Aliases, string? Ip)>(); + var topologyMatches = 0; var ambiguousUnboundTopology = false; foreach (var ied in trustedScl.Root.Elements(Scl + "IED")) { @@ -43,7 +44,10 @@ public static class TrustedSclIedIdentityMatcher foreach (var ap in ied.Elements(Scl + "AccessPoint")) { var apName = ((string?)ap.Attribute("name") ?? "").Trim(); - if (string.IsNullOrWhiteSpace(apName)) continue; + if (string.IsNullOrWhiteSpace(apName) || + (!string.IsNullOrWhiteSpace(selectedAccessPointName) && + !string.Equals(apName, selectedAccessPointName.Trim(), StringComparison.Ordinal))) + continue; var devices = ap.Element(Scl + "Server")?.Elements(Scl + "LDevice").ToArray() ?? Array.Empty(); if (devices.Length != observed.Count) continue; @@ -67,6 +71,10 @@ public static class TrustedSclIedIdentityMatcher } } if (invalid || aliases.Count != observed.Count) continue; + // Count every topology-compatible AP before endpoint filtering. + // One AP lacking ConnectedAP is still a competing possibility + // when the operator has not selected a unique AP. + topologyMatches++; var connected = trustedScl.Root.Element(Scl + "Communication")? .Descendants(Scl + "ConnectedAP") From 2dbfc2152d19ed2aa893274c3b285178732a3159 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:11:02 +0700 Subject: [PATCH 13/18] test(identity): preserve distinct case-sensitive live MMS LD domain names --- .../Discovery/LiveIedIdentityResolverTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index 8d143c23..1fbeaa46 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -38,6 +38,17 @@ public void Resolve_Common_Prefix_Does_Not_Remove_A_Valid_Ied_Name_Ending(string Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); } + + [Fact] + public void Mms_Domain_Names_Are_Case_Sensitive_And_Never_Coalesced() + { + var identity = LiveIedIdentityResolver.Resolve( + ["SIEBCUCTRL", "siebcuctrl", "SIEBCUMEAS"], "192.0.2.61"); + Assert.Equal(3, identity.LogicalDeviceAliases.Count); + Assert.Contains("SIEBCUCTRL", identity.LogicalDeviceAliases.Keys); + Assert.Contains("siebcuctrl", identity.LogicalDeviceAliases.Keys); + } + [Fact] public void Resolve_Preserves_Digits_In_A_Single_Ld0_Domain() { From 3c841f227d4cab35669e1f179d3a3f82d5c809e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:11:12 +0700 Subject: [PATCH 14/18] test(identity): AP selector cannot conceal another indistinguishable AP --- .../TrustedSclIedIdentityMatcherTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs index 69a8a823..6de86a25 100644 --- a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs @@ -169,6 +169,22 @@ public void Scl_Endpoint_Address_And_AccessPoint_Are_Mandatory_For_Verified_Iden scl,wrongDomainCase,"192.0.2.30")); } + + [Fact] + public void Selected_Ap_Name_Alone_Does_Not_Disambiguate_Identical_Unbound_Topology() + { + XNamespace ns=SclNs; + var scl=Design("STATIONBCU","CTRL","192.0.2.30"); + var ied=Assert.Single(scl.Descendants(ns+"IED")); + var ap=Assert.Single(ied.Elements(ns+"AccessPoint")); + var copy=new XElement(ap); + copy.SetAttributeValue("name","S2"); + ied.Add(copy); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl,["STATIONBCUCTRL"],"192.0.2.30", + selectedIedName:"STATIONBCU",selectedAccessPointName:"S1")); + } + [Fact] public void Other_Devices_And_Already_Recognized_Multiple_Domains_Are_Unchanged() { From a12cee613a40754d748010faf24c449b40fe5a7b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:11:15 +0700 Subject: [PATCH 15/18] fix(identity): evaluate all AP ambiguity before applying operator AP selection --- .../Discovery/TrustedSclIedIdentityMatcher.cs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs index 51376afd..b7fd9424 100644 --- a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -31,7 +31,6 @@ public static class TrustedSclIedIdentityMatcher if (observed.Count == 0) return null; var matches = new List<(string Ied, string Ap, Dictionary Aliases, string? Ip)>(); - var topologyMatches = 0; var ambiguousUnboundTopology = false; foreach (var ied in trustedScl.Root.Elements(Scl + "IED")) { @@ -44,10 +43,7 @@ public static class TrustedSclIedIdentityMatcher foreach (var ap in ied.Elements(Scl + "AccessPoint")) { var apName = ((string?)ap.Attribute("name") ?? "").Trim(); - if (string.IsNullOrWhiteSpace(apName) || - (!string.IsNullOrWhiteSpace(selectedAccessPointName) && - !string.Equals(apName, selectedAccessPointName.Trim(), StringComparison.Ordinal))) - continue; + if (string.IsNullOrWhiteSpace(apName)) continue; var devices = ap.Element(Scl + "Server")?.Elements(Scl + "LDevice").ToArray() ?? Array.Empty(); if (devices.Length != observed.Count) continue; @@ -71,10 +67,6 @@ public static class TrustedSclIedIdentityMatcher } } if (invalid || aliases.Count != observed.Count) continue; - // Count every topology-compatible AP before endpoint filtering. - // One AP lacking ConnectedAP is still a competing possibility - // when the operator has not selected a unique AP. - topologyMatches++; var connected = trustedScl.Root.Element(Scl + "Communication")? .Descendants(Scl + "ConnectedAP") From cd3b631911adaf0dc1eaafa13bb6bdbfa70a2582 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:11:38 +0700 Subject: [PATCH 16/18] fix(identity): preserve case-sensitive MMS domain inventory and exact alias keys --- src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs index 8c586748..9e6aeaf8 100644 --- a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs +++ b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs @@ -7,7 +7,7 @@ public sealed class LiveIedIdentity public LiveIedDiscoveryConfidenceLevel Confidence { get; init; } = LiveIedDiscoveryConfidenceLevel.Unknown; public bool IsAmbiguous { get; init; } public IReadOnlyList CandidateNames { get; init; } = Array.Empty(); - public IReadOnlyDictionary LogicalDeviceAliases { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public IReadOnlyDictionary LogicalDeviceAliases { get; init; } = new Dictionary(StringComparer.Ordinal); public IReadOnlyList Evidence { get; init; } = Array.Empty(); public string Summary => $"IED identity: name={IedName}, source={Source}, confidence={Confidence}, candidates={CandidateNames.Count}, ambiguous={IsAmbiguous.ToString().ToLowerInvariant()}"; @@ -31,8 +31,8 @@ public static LiveIedIdentity Resolve( var materialized = domains .Where(domain => !string.IsNullOrWhiteSpace(domain)) .Select(domain => domain.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(domain => domain, StringComparer.OrdinalIgnoreCase) + .Distinct(StringComparer.Ordinal) + .OrderBy(domain => domain, StringComparer.Ordinal) .ToArray(); if (!string.IsNullOrWhiteSpace(explicitIedName)) From 313af3c6bdf936b7dc314a7bcb64b9e65d71b20d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:13:23 +0700 Subject: [PATCH 17/18] test(identity): distinct MMS domain casing must not overwrite alias identity --- .../Discovery/LiveIedIdentityResolverTests.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index 1fbeaa46..849c6049 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -106,6 +106,18 @@ public void Resolve_Rejects_Conflicting_Domain_Candidates() } + + [Fact] + public void Resolve_Preserves_Case_Distinct_Mms_Domains_And_Alias_Keys() + { + var domains = new[] { "STATIONBCUCTRL", "stationbcuctrl" }; + var identity = LiveIedIdentityResolver.Resolve(domains, "192.0.2.42"); + Assert.Equal(2, identity.LogicalDeviceAliases.Count); + Assert.Equal("CTRL", identity.LogicalDeviceAliases["STATIONBCUCTRL"]); + Assert.Equal("ctrl", identity.LogicalDeviceAliases["stationbcuctrl"]); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + } + [Fact] public void Resolve_Fully_Known_Ld_Suffixes_Is_Still_Provisional_Without_Scl() { From ccf31f60095ea65f9095514f0fa0102060962049 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:13:27 +0700 Subject: [PATCH 18/18] fix(identity): retain exact case-sensitive MMS aliases in resolved identity --- src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs index 9e6aeaf8..8ce7beb3 100644 --- a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs +++ b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs @@ -168,7 +168,7 @@ private static LiveIedIdentity Create( IReadOnlyList domains, IReadOnlyList evidence) { - var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + var aliases = new Dictionary(StringComparer.Ordinal); foreach (var domain in domains) { var alias = domain.StartsWith(name, StringComparison.OrdinalIgnoreCase) && domain.Length > name.Length