Skip to content
Merged
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
42 changes: 29 additions & 13 deletions src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ public static LiveIedSclExportResult WriteFiles(
LiveIedCanonicalModel canonical,
string sclPath,
SclSchemaProfile schemaProfile = SclSchemaProfile.Edition2V31,
string profile = "safe-connection")
string profile = "safe-connection",
LiveIedIdentity? verifiedIdentity = null)
{
ArgumentNullException.ThrowIfNull(canonical);
ValidateCanonicalCommunication(canonical);
Expand All @@ -41,7 +42,8 @@ public static LiveIedSclExportResult WriteFiles(
? "StationBus"
: communication.SubNetworkName.Trim(),
IpAddress = communication.Host.Trim(),
IedNameOverride = canonical.IedName,
IedNameOverride = verifiedIdentity?.IedName ?? canonical.IedName,
VerifiedIdentity = verifiedIdentity,
IpSubnet = communication.IpSubnet?.Trim() ?? string.Empty,
IpGateway = communication.IpGateway?.Trim() ?? string.Empty,
OsiApTitle = association.ApTitle.Trim(),
Expand All @@ -53,10 +55,10 @@ public static LiveIedSclExportResult WriteFiles(
});

var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace);
ApplyCanonicalCommunication(document, canonical);
ApplyCanonicalCommunication(document, canonical, verifiedIdentity?.IedName);
PreserveRuntimeServiceCapacity(document, canonical.Discovery);
ApplyCanonicalInstanceValues(document, canonical);
ValidateRoundTripAssociation(document, canonical);
ApplyCanonicalInstanceValues(document, canonical, verifiedIdentity);
ValidateRoundTripAssociation(document, canonical, verifiedIdentity?.IedName);
document.Save(result.SclPath);
return result;
}
Expand Down Expand Up @@ -106,11 +108,12 @@ public static void ValidateCanonicalCommunication(LiveIedCanonicalModel canonica
throw new InvalidDataException(string.Join(" | ", errors));
}

private static void ApplyCanonicalCommunication(XDocument document, LiveIedCanonicalModel canonical)
private static void ApplyCanonicalCommunication(
XDocument document, LiveIedCanonicalModel canonical, string? trustedIedName)
{
var connectedAp = document.Descendants(Scl + "ConnectedAP").SingleOrDefault()
?? throw new InvalidDataException("Generated SCL must contain exactly one ConnectedAP.");
connectedAp.SetAttributeValue("iedName", canonical.IedName);
connectedAp.SetAttributeValue("iedName", trustedIedName ?? canonical.IedName);
connectedAp.SetAttributeValue("apName", canonical.AccessPointName);

var subNetwork = connectedAp.Parent;
Expand Down Expand Up @@ -163,7 +166,8 @@ public static void PreserveRuntimeServiceCapacity(

public static void ApplyCanonicalInstanceValues(
XDocument document,
LiveIedCanonicalModel canonical)
LiveIedCanonicalModel canonical,
LiveIedIdentity? verifiedIdentity = null)
{
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(canonical);
Expand Down Expand Up @@ -212,7 +216,7 @@ public static void ApplyCanonicalInstanceValues(
if (logicalNodeModel is null)
continue;

var lDevice = FindExportedLogicalDevice(document, canonical, evidence.Domain);
var lDevice = FindExportedLogicalDevice(document, canonical, evidence.Domain, verifiedIdentity);
if (lDevice is null)
continue;

Expand Down Expand Up @@ -272,9 +276,20 @@ public static void ApplyCanonicalInstanceValues(
private static XElement? FindExportedLogicalDevice(
XDocument document,
LiveIedCanonicalModel canonical,
string domain)
string domain,
LiveIedIdentity? verifiedIdentity)
{
var normalizedDomain = domain.Trim();
// A verified engineering source provides the exact LD instance.
// Avoid recomputing it from the canonical *provisional* IED name.
if (verifiedIdentity is not null &&
verifiedIdentity.LogicalDeviceAliases.TryGetValue(normalizedDomain,out var trustedInst))
{
var trusted = document.Descendants(Scl + "LDevice")
.Where(ld => string.Equals((string?)ld.Attribute("inst"),trustedInst,StringComparison.OrdinalIgnoreCase))
.ToArray();
return trusted.Length == 1 ? trusted[0] : null;
}
var iedName = canonical.IedName.Trim();
var stripped = normalizedDomain.StartsWith(iedName, StringComparison.OrdinalIgnoreCase) &&
normalizedDomain.Length > iedName.Length
Expand Down Expand Up @@ -462,12 +477,13 @@ private static bool TryFormatScalarValue(
}
}

private static void ValidateRoundTripAssociation(XDocument document, LiveIedCanonicalModel canonical)
private static void ValidateRoundTripAssociation(
XDocument document, LiveIedCanonicalModel canonical, string? trustedIedName)
{
var profiles = SclMmsAssociationProfileReader.Read(document);
var remote = profiles.Find(canonical.IedName, canonical.AccessPointName)
var remote = profiles.Find(trustedIedName ?? canonical.IedName, canonical.AccessPointName)
?? throw new InvalidDataException(
$"Generated SCL cannot round-trip its own ConnectedAP '{canonical.IedName}/{canonical.AccessPointName}'.");
$"Generated SCL cannot round-trip its own ConnectedAP '{trustedIedName ?? canonical.IedName}/{canonical.AccessPointName}'.");

var canonicalAssociation = canonical.Communication.Association;
var roundTripErrors = new List<string>();
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
Loading