diff --git a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs index 8508a176..8ce7beb3 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,15 +31,15 @@ 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)) { 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(); @@ -89,9 +87,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", @@ -112,7 +108,7 @@ public static LiveIedIdentity Resolve( return Create( name, "MmsDomainKnownLogicalDeviceSuffix", - materialized.Length == 1 ? LiveIedDiscoveryConfidenceLevel.Medium : LiveIedDiscoveryConfidenceLevel.High, + LiveIedDiscoveryConfidenceLevel.Medium, false, distinctSuffixCandidates, materialized, @@ -172,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 @@ -235,18 +231,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; } diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs index a3b8918a..b7fd9424 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,17 +26,18 @@ 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)>(); + var ambiguousUnboundTopology = false; foreach (var ied in trustedScl.Root.Elements(Scl + "IED")) { 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 +48,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 +70,39 @@ 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)) + { + 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) continue; - var ip = ipValues.SingleOrDefault(); - if (!string.IsNullOrWhiteSpace(ip) && !string.IsNullOrWhiteSpace(connectedHost)) + if (ipValues.Length != 1 || + !IPAddress.TryParse(ipValues[0],out var designIp) || + !IPAddress.TryParse(connectedHost.Trim(),out var observedIp)) { - 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 + ambiguousUnboundTopology = true; + continue; } - matches.Add((name, apName, aliases, ip)); + 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)) + return null; return new LiveIedIdentity { IedName=match.Ied, diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index 308799b6..849c6049 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -6,6 +6,49 @@ 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 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() { @@ -25,7 +68,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"]); } @@ -45,7 +88,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); } @@ -62,6 +105,40 @@ public void Resolve_Rejects_Conflicting_Domain_Candidates() Assert.Equal(["ALPHA", "BETA"], identity.CandidateNames); } + + + [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() + { + 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() { @@ -69,7 +146,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] @@ -81,7 +169,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"]); } diff --git a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs index 62274a78..6de86a25 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() { @@ -93,6 +126,65 @@ 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 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() {