Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b27842a
test(identity): reproduce truncated device-name suffix and unverified…
masarray Oct 19, 2026
bcc8e22
fix(identity): preserve exact shared prefix and mark unverified domai…
masarray Oct 19, 2026
e1ba425
test(identity): align pre-existing shared-prefix expectation with pro…
masarray Oct 19, 2026
f2bf05e
test(identity): require all MMS-only and operator identities remain u…
masarray Oct 19, 2026
5b30f51
test(identity): require provisional confidence for suffix and operato…
masarray Oct 19, 2026
fbd6c4f
test(identity): require exact five-LD SCL and fail-closed AP/IP/domai…
masarray Oct 19, 2026
4cc3bde
test(identity): five-LD SCL proof and fail-closed missing endpoint
masarray Oct 19, 2026
10b3584
fix(identity): do not mark operator aliases or MMS suffix heuristics …
masarray Oct 19, 2026
1f3a601
fix(identity): mark all MMS suffix inference and operator alias as pr…
masarray Oct 19, 2026
f2bdb7f
fix(identity): require exact SCL domain/AP and connected endpoint proof
masarray Oct 19, 2026
a662834
fix(identity): fail closed on topology-equivalent AP with missing end…
masarray Oct 19, 2026
9e6b142
fix(identity): reject all duplicate topology-compatible APs before en…
masarray Oct 19, 2026
2dbfc21
test(identity): preserve distinct case-sensitive live MMS LD domain n…
masarray Oct 19, 2026
3c841f2
test(identity): AP selector cannot conceal another indistinguishable AP
masarray Oct 19, 2026
a12cee6
fix(identity): evaluate all AP ambiguity before applying operator AP …
masarray Oct 19, 2026
cd3b631
fix(identity): preserve case-sensitive MMS domain inventory and exact…
masarray Oct 19, 2026
313af3c
test(identity): distinct MMS domain casing must not overwrite alias i…
masarray Oct 19, 2026
ccf31f6
fix(identity): retain exact case-sensitive MMS aliases in resolved id…
masarray Oct 19, 2026
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
38 changes: 13 additions & 25 deletions src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public sealed class LiveIedIdentity
public LiveIedDiscoveryConfidenceLevel Confidence { get; init; } = LiveIedDiscoveryConfidenceLevel.Unknown;
public bool IsAmbiguous { get; init; }
public IReadOnlyList<string> CandidateNames { get; init; } = Array.Empty<string>();
public IReadOnlyDictionary<string, string> LogicalDeviceAliases { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IReadOnlyDictionary<string, string> LogicalDeviceAliases { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
public IReadOnlyList<string> Evidence { get; init; } = Array.Empty<string>();

public string Summary => $"IED identity: name={IedName}, source={Source}, confidence={Confidence}, candidates={CandidateNames.Count}, ambiguous={IsAmbiguous.ToString().ToLowerInvariant()}";
Expand All @@ -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
Expand All @@ -56,17 +56,15 @@ 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)
{
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();
Expand All @@ -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",
Expand All @@ -112,7 +108,7 @@ public static LiveIedIdentity Resolve(
return Create(
name,
"MmsDomainKnownLogicalDeviceSuffix",
materialized.Length == 1 ? LiveIedDiscoveryConfidenceLevel.Medium : LiveIedDiscoveryConfidenceLevel.High,
LiveIedDiscoveryConfidenceLevel.Medium,
false,
distinctSuffixCandidates,
materialized,
Expand Down Expand Up @@ -172,7 +168,7 @@ private static LiveIedIdentity Create(
IReadOnlyList<string> domains,
IReadOnlyList<string> evidence)
{
var aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var aliases = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var domain in domains)
{
var alias = domain.StartsWith(name, StringComparison.OrdinalIgnoreCase) && domain.Length > name.Length
Expand Down Expand Up @@ -235,18 +231,10 @@ private static string InferCommonPrefix(IReadOnlyList<string> 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;
}

Expand Down
49 changes: 30 additions & 19 deletions src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,27 @@ public static class TrustedSclIedIdentityMatcher
XDocument trustedScl,
IEnumerable<string> observedMmsDomains,
string? connectedHost,
string? selectedIedName = null)
string? selectedIedName = null,
string? selectedAccessPointName = null)
{
ArgumentNullException.ThrowIfNull(trustedScl);
ArgumentNullException.ThrowIfNull(observedMmsDomains);
if (trustedScl.Root?.Name != Scl + "SCL") return null;
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<string,string> 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"))
Expand All @@ -46,7 +48,7 @@ public static class TrustedSclIedIdentityMatcher
?? Array.Empty<XElement>();
if (devices.Length != observed.Count) continue;

var aliases = new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);
var aliases = new Dictionary<string,string>(StringComparer.Ordinal);
var invalid = false;
foreach (var ld in devices)
{
Expand All @@ -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<XElement>();
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<string>();
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,
Expand Down
96 changes: 92 additions & 4 deletions tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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"]);
}
Expand All @@ -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);
}
Expand All @@ -62,14 +105,59 @@ 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()
{
var identity = LiveIedIdentityResolver.Resolve(["OLSF501LD0"], "192.0.2.10", explicitIedName: "COMMISSIONED_IED");

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]
Expand All @@ -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"]);
}

Expand Down
Loading