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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System.Net;
using System.Xml.Linq;

namespace AR.Iec61850.Discovery;

/// <summary>
/// Evidence-only IEC 61850 identity reconciliation. A server exposes MMS domains,
/// not a guaranteed physical IED name or LDevice.inst boundary. Only a unique,
/// fully matched engineering SCL topology can promote a provisional identity.
/// No vendor-name heuristic, network call, cached-IP assumption, or model mutation.
/// </summary>
public static class TrustedSclIedIdentityMatcher
{
private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL";

public static LiveIedIdentity? TryMatch(
XDocument trustedScl,
IEnumerable<string> observedMmsDomains,
string? connectedHost,
string? selectedIedName = 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);
if (observed.Count == 0) return null;

var matches = new List<(string Ied, string Ap, Dictionary<string,string> Aliases, string? Ip)>();
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)))
continue;

foreach (var ap in ied.Elements(Scl + "AccessPoint"))
{
var apName = ((string?)ap.Attribute("name") ?? "").Trim();
if (string.IsNullOrWhiteSpace(apName)) continue;
var devices = ap.Element(Scl + "Server")?.Elements(Scl + "LDevice").ToArray()
?? Array.Empty<XElement>();
if (devices.Length != observed.Count) continue;

var aliases = new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);
var invalid = false;
foreach (var ld in devices)
{
var inst = ((string?)ld.Attribute("inst") ?? "").Trim();
var ldName = ((string?)ld.Attribute("ldName") ?? "").Trim();
if (string.IsNullOrWhiteSpace(inst) || !IsValidSclIdentifier(inst))
{
invalid = true;
break;
}
var domain = ldName.Length > 0 ? ldName : name + inst;
if (!observed.Contains(domain) || !aliases.TryAdd(domain,inst))
{
invalid = true;
break;
}
}
if (invalid || aliases.Count != observed.Count) continue;

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))
.ToArray() ?? Array.Empty<XElement>();
if (connected.Length > 1) continue;
var ipValues = connected.SingleOrDefault()?
.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 (!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));
}
}
// 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];
return new LiveIedIdentity
{
IedName=match.Ied,
Source="TrustedSclExactDomainMatch",
Confidence=LiveIedDiscoveryConfidenceLevel.High,
IsAmbiguous=false,
CandidateNames=[match.Ied],
LogicalDeviceAliases=match.Aliases,
Evidence=[
$"Trusted SCL IED '{match.Ied}' / AccessPoint '{match.Ap}' matches all {observed.Count} observed MMS domains and unique LDevice instances.",
string.IsNullOrWhiteSpace(match.Ip) ? "No endpoint IP asserted by SCL." : $"SCL endpoint IP {match.Ip}."
]
};
}

private static bool IsValidSclIdentifier(string s)
=> s.All(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.');
}
83 changes: 82 additions & 1 deletion src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ public static LiveIedSclExportResult WriteFiles(
try
{
var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace);
if (!string.IsNullOrWhiteSpace(options.IedNameOverride))
if (options.VerifiedIdentity is { } verified)
{
if (!string.IsNullOrWhiteSpace(options.IedNameOverride) &&
!verified.IedName.Equals(options.IedNameOverride.Trim(), StringComparison.Ordinal))
throw new InvalidDataException("Caller IED override conflicts with trusted SCL evidence.");
document = ApplyVerifiedIdentity(document, model, verified);
}
else if (!string.IsNullOrWhiteSpace(options.IedNameOverride))
document = ApplyIdentity(document, model, options.IedNameOverride);

document = ApplyReportControlConfiguration(document, model, options.ResolvedSchemaProfile);
Expand Down Expand Up @@ -99,6 +106,80 @@ public static XDocument ApplyIdentity(
return document;
}


/// <summary>
/// Normalizes both IED identity and LD instance mappings from verified SCL
/// topology. Physical MMS domain / FCDA evidence remains unchanged.
/// </summary>
public static XDocument ApplyVerifiedIdentity(
XDocument generated,
LiveIedModelDiscoveryDocument model,
LiveIedIdentity identity)
{
ArgumentNullException.ThrowIfNull(identity);
var observed = model.LogicalDevices
.Select(LogicalDeviceDomain)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!identity.Source.Equals("TrustedSclExactDomainMatch", StringComparison.Ordinal) ||
identity.Confidence != LiveIedDiscoveryConfidenceLevel.High ||
identity.IsAmbiguous ||
observed.Count == 0 ||
identity.LogicalDeviceAliases.Count != observed.Count ||
identity.LogicalDeviceAliases.Keys.Any(x => !observed.Contains(x)) ||
identity.LogicalDeviceAliases.Values.Any(x => string.IsNullOrWhiteSpace(x)) ||
identity.LogicalDeviceAliases.Values.Distinct(StringComparer.OrdinalIgnoreCase).Count() != observed.Count)
throw new InvalidDataException("SCL identity evidence is not a unique complete MMS-domain mapping.");

// Existing authoritative normalization proves identity and preserves the
// MMS wire-domain graph. Transform LDevice.inst and FCDA LD references
// together so that a single-domain BCUGEF650 becomes BCUGE/F650.
var document = ApplyIdentity(generated, model, identity.IedName);
var rewrites = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var ied = document.Root?.Element(Scl + "IED")
?? throw new InvalidDataException("SCL has no unique IED.");
var nodes = ied.Descendants(Scl + "LDevice").ToArray();
if (nodes.Length != observed.Count)
throw new InvalidDataException("Generated LD set and observed MMS domain set differ.");

foreach (var ld in nodes)
{
var oldInst = ((string?)ld.Attribute("inst") ?? "").Trim();
var explicitDomain = ((string?)ld.Attribute("ldName") ?? "").Trim();
var domain = explicitDomain.Length != 0 ? explicitDomain : identity.IedName + oldInst;
if (!identity.LogicalDeviceAliases.TryGetValue(domain, out var newInst) ||
!seen.Add(domain))
throw new InvalidDataException($"Generated SCL domain '{domain}' is not uniquely matched.");
rewrites.Add(oldInst, newInst);
ld.SetAttributeValue("inst", newInst);
ld.SetAttributeValue("ldName",
domain.Equals(identity.IedName + newInst, StringComparison.OrdinalIgnoreCase)
? null : domain);
}
if (seen.Count != observed.Count)
throw new InvalidDataException("Generated SCL did not map every observed MMS domain.");

// FCDA ldInst references refer to LDevice.inst, not the MMS domain.
// Never rewrite references belonging to an explicitly different IED.
foreach (var fcda in ied.Descendants(Scl + "FCDA"))
{
var fcdaIed = ((string?)fcda.Attribute("iedName") ?? "").Trim();
if (fcdaIed.Length > 0 &&
!fcdaIed.Equals(model.IedName, StringComparison.OrdinalIgnoreCase) &&
!fcdaIed.Equals(identity.IedName, StringComparison.OrdinalIgnoreCase))
continue;
var oldRef = ((string?)fcda.Attribute("ldInst") ?? "").Trim();
if (rewrites.TryGetValue(oldRef, out var mapped))
fcda.SetAttributeValue("ldInst", mapped);
if (fcdaIed.Equals(model.IedName, StringComparison.OrdinalIgnoreCase))
fcda.SetAttributeValue("iedName", identity.IedName);
}
ValidateIdentity(document, identity.IedName, model);
return document;
}

public static XDocument ApplyReportControlConfiguration(
XDocument source,
LiveIedModelDiscoveryDocument model,
Expand Down
7 changes: 7 additions & 0 deletions src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ public sealed class LiveIedSclExportOptions
/// when an implementation uses explicit communication-level LD names.
/// </summary>
public string IedNameOverride { get; init; } = string.Empty;

/// <summary>
/// Optional evidence proved against the exact observed MMS domain set by
/// TrustedSclIedIdentityMatcher. Not a display alias or an IP-based guess.
/// Export fails closed on incomplete/colliding aliases.
/// </summary>
public AR.Iec61850.Discovery.LiveIedIdentity? VerifiedIdentity { get; init; }
public string IpSubnet { get; init; } = "255.255.255.0";
public string IpGateway { get; init; } = "0.0.0.0";
public string OsiApTitle { get; init; } = string.Empty;
Expand Down
172 changes: 172 additions & 0 deletions tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
using AR.Iec61850.Discovery;
using AR.Iec61850.Scl.Export;
using System.Xml.Linq;

namespace AR.Iec61850.Tests.Discovery;

public sealed class TrustedSclIedIdentityMatcherTests
{
private const string SclNs = "http://www.iec.ch/61850/2003/SCL";

[Fact]
public void GE_F650_Single_Domain_Uses_SCL_Evidence_Not_Vendor_Suffix_Heuristic()
{
var inferred = LiveIedIdentityResolver.Resolve(["BCUGEF650"], "192.16.1.33");
Assert.Equal("HostFallback", inferred.Source);
Assert.Equal(LiveIedDiscoveryConfidenceLevel.Low,inferred.Confidence);

var identity = TrustedSclIedIdentityMatcher.TryMatch(
Design("BCUGE", "F650", "192.16.1.33"), ["BCUGEF650"], "192.16.1.33");
Assert.NotNull(identity);
Assert.Equal("BCUGE",identity.IedName);
Assert.Equal("F650",identity.LogicalDeviceAliases["BCUGEF650"]);
Assert.Equal("TrustedSclExactDomainMatch",identity.Source);
Assert.Equal(LiveIedDiscoveryConfidenceLevel.High,identity.Confidence);
}

[Fact]
public void Verified_F650_Export_RoundTrips_Mms_Domain_And_Fcda_LdInst()
{
var model = Model("IED_192_16_1_33","BCUGEF650");
var identity = TrustedSclIedIdentityMatcher.TryMatch(
Design("BCUGE","F650","192.16.1.33"),["BCUGEF650"],"192.16.1.33");
Assert.NotNull(identity);

var generated = LiveIedSclExporter.BuildDocument(model,
new LiveIedSclExportOptions { SchemaProfile = SclSchemaProfile.Edition1V16 });
var normalized = AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity(generated,model,identity);
XNamespace ns=SclNs;
var ied=Assert.Single(normalized.Root!.Elements(ns+"IED"));
Assert.Equal("BCUGE",(string?)ied.Attribute("name"));
var device=Assert.Single(ied.Descendants(ns+"LDevice"));
Assert.Equal("F650",(string?)device.Attribute("inst"));
Assert.Null(device.Attribute("ldName")); // implicit MMS domain BCUGEF650
Assert.Equal("BCUGE",(string?)Assert.Single(normalized.Descendants(ns+"ConnectedAP")).Attribute("iedName"));
var fcda=Assert.Single(normalized.Descendants(ns+"FCDA"));
Assert.Equal("F650",(string?)fcda.Attribute("ldInst"));
}

[Fact]
public void Explicit_LdName_Remains_Exact_When_Different_From_Implicit_Mms_Domain()
{
var scl=Design("BCUGE","F650","192.16.1.33","CUSTOM_WIRE_DOMAIN");
var identity=TrustedSclIedIdentityMatcher.TryMatch(scl,
["CUSTOM_WIRE_DOMAIN"],"192.16.1.33");
Assert.NotNull(identity);
var model=Model("IED_192_16_1_33","CUSTOM_WIRE_DOMAIN");
var doc=AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity(
LiveIedSclExporter.BuildDocument(model,new LiveIedSclExportOptions()),model,identity);
XNamespace ns=SclNs;
var ld=Assert.Single(doc.Descendants(ns+"LDevice"));
Assert.Equal("F650",(string?)ld.Attribute("inst"));
Assert.Equal("CUSTOM_WIRE_DOMAIN",(string?)ld.Attribute("ldName"));
Assert.Equal("F650",(string?)Assert.Single(doc.Descendants(ns+"FCDA")).Attribute("ldInst"));
}

[Fact]
public void Different_Host_Or_Missing_Domain_Or_Wrong_Selected_IED_Fails_Closed()
{
var scl=Design("BCUGE","F650","192.16.1.33");
Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.34"));
Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650","BCUGECTRL"],"192.16.1.33"));
Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.33","OTHER_IED"));
Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["DIFFERENT"],"192.16.1.33"));
}

[Fact]
public void Ambiguous_Duplicate_AccessPoints_And_Conflicting_Sources_Are_Not_Promoted()
{
XNamespace ns=SclNs;
var scl=Design("BCUGE","F650","192.16.1.33");
var ied=Assert.Single(scl.Descendants(ns+"IED"));
var ap=Assert.Single(ied.Elements(ns+"AccessPoint"));
var duplicate=new XElement(ap);
duplicate.SetAttributeValue("name","S2");
ied.Add(duplicate);
Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.33"));

var model=Model("IED_192_16_1_33","BCUGEF650");
var unverified=LiveIedIdentityResolver.Resolve(["BCUGEF650"],"192.16.1.33");
Assert.Throws<InvalidDataException>(() =>
AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity(
LiveIedSclExporter.BuildDocument(model,new LiveIedSclExportOptions()),
model,unverified));
}

[Fact]
public void Other_Devices_And_Already_Recognized_Multiple_Domains_Are_Unchanged()
{
var old=LiveIedIdentityResolver.Resolve(
["OLSF501LD0","OLSF501CTRL"],"192.0.2.10");
Assert.Equal("OLSF501",old.IedName);
var other=LiveIedIdentityResolver.Resolve(
["OCR7SJ8Application","OCR7SJ8CB1","OCR7SJ8Mod2_MU1"],
"192.0.2.12");
Assert.Equal("OCR7SJ8",other.IedName);
}

private static XDocument Design(string ied,string inst,string ip,string? ldName=null)
{
XNamespace ns=SclNs;
return new XDocument(new XElement(ns+"SCL",
new XElement(ns+"Communication",
new XElement(ns+"SubNetwork",
new XElement(ns+"ConnectedAP",
new XAttribute("iedName",ied),new XAttribute("apName","S1"),
new XElement(ns+"Address",
new XElement(ns+"P",new XAttribute("type","IP"),ip))))),
new XElement(ns+"IED",new XAttribute("name",ied),
new XElement(ns+"AccessPoint",new XAttribute("name","S1"),
new XElement(ns+"Server",
new XElement(ns+"LDevice",
new XAttribute("inst",inst),
ldName is null ? null : new XAttribute("ldName",ldName),
new XElement(ns+"LN0",
new XAttribute("lnType","LLN0_type"))))))));
}

private static LiveIedModelDiscoveryDocument Model(string ied,string domain)
=> new()
{
Host="192.16.1.33",
IedName=ied,
AccessPointName="S1",
LogicalDevices=[
new LiveIedLogicalDeviceModel
{
MmsDomain=domain,
Inst=domain,
LogicalNodes=[
new LiveIedLogicalNodeModel
{
Name="LLN0",
LnClass="LLN0",
ProposedLnTypeId="LN_LLN0_type"
},
new LiveIedLogicalNodeModel
{
Name="CSWI6",
LnClass="CSWI",
LnInst="6",
ProposedLnTypeId="LN_CSWI6_type"
}
]
}
],
DataSets=[
new LiveIedDataSetModel
{
Reference=$"{domain}/LLN0.Digital",
Domain=domain,LogicalNode="LLN0",Name="Digital",
MemberCount=1,
Members=[
new LiveIedDataSetMemberModel
{
Index=0,Reference=$"{domain}/CSWI6.Pos.stVal",
FunctionalConstraint="ST"
}
]
}
]
};
}