Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion .github/workflows/dotnet-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ on:
- "*.slnx"
- "Directory.Build.props"
pull_request:
branches: [ main ]
branches: [ main, feat/495-dataset-type-closure ]
workflow_dispatch:

permissions:
Expand Down
61 changes: 49 additions & 12 deletions src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Threading.Channels;
using AR.Iec61850.Asn1;
using AR.Iec61850.Mms;

Expand Down Expand Up @@ -192,6 +193,17 @@ public sealed class MmsAssociationReportingRuntime : IMmsAssociationRuntime, IDi
private readonly Dictionary<string, Timer> _integrityTimers = new(StringComparer.OrdinalIgnoreCase);
private readonly object _gate = new();
private readonly CancellationTokenSource _cts = new();
// One FIFO per MMS association. A distinct Task.Run per report previously
// allowed send delegates to acquire the socket lock in a different order
// from the SqNum assignment, manufacturing on-wire discontinuities.
private readonly Channel<(MmsRcbRuntimeState State, byte Reason)> _sendQueue =
Channel.CreateUnbounded<(MmsRcbRuntimeState, byte)>(
new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
AllowSynchronousContinuations = false
});
private bool _disposed;

public MmsAssociationReportingRuntime(
Expand Down Expand Up @@ -226,6 +238,12 @@ public MmsAssociationReportingRuntime(
IntgPd = (uint)Math.Max(0, rcb.IntegrityPeriodMs)
};
}

// One asynchronous worker is allocated per association, not per report.
// Capture the token here: a delayed worker must never access a CTS
// after Dispose() has disposed its source.
var senderToken = _cts.Token;
_ = Task.Run(() => DrainReportQueueAsync(senderToken));
}

public IReadOnlyCollection<MmsRcbRuntimeState> States => _states.Values;
Expand Down Expand Up @@ -414,6 +432,7 @@ public void Dispose()
return;

_disposed = true;
_sendQueue.Writer.TryComplete();
_cts.Cancel();
lock (_gate)
{
Expand Down Expand Up @@ -449,21 +468,39 @@ private void QueueReport(MmsRcbRuntimeState state, byte reason)
if (_disposed)
return;

_ = Task.Run(async () =>
// Channel FIFO is the report ordering authority. No per-report Task.Run,
// no competing asynchronous socket writers and no silent report drop.
_sendQueue.Writer.TryWrite((state, reason));
}

private async Task DrainReportQueueAsync(CancellationToken cancellationToken)
{
try
{
try
{
await SendReportAsync(state, reason, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Association closing.
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException)
await foreach (var request in _sendQueue.Reader.ReadAllAsync(cancellationToken)
.ConfigureAwait(false))
{
_activity?.Invoke(state.MmsReference, false, $"InformationReport send failed: {ex.Message}");
try
{
await SendReportAsync(request.State, request.Reason, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return;
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or
System.Net.Sockets.SocketException or InvalidOperationException)
{
_activity?.Invoke(request.State.MmsReference, false,
$"InformationReport send failed: {ex.Message}");
}
}
}, CancellationToken.None);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Association closing: pending reports are not sent on a dead socket.
}
}

private async Task SendReportAsync(MmsRcbRuntimeState state, byte reason, CancellationToken cancellationToken)
Expand Down
91 changes: 91 additions & 0 deletions src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using AR.Iec61850.Mms;

namespace AR.Iec61850.Control;

/// <summary>
/// Resolves *command* semantics from a declared CDC and corroborating live MMS
/// evidence. A primitive MMS type is an encoding, not permission to Operate.
/// This is vendor-independent and never sends an MMS request.
/// </summary>
public static class Iec61850ControlSemanticResolver
{
public static Iec61850ControlSemanticResolution Resolve(
Iec61850ControlObjectDescriptor descriptor,
string? declaredCdc,
string? declaredStatusReference = null)
{
ArgumentNullException.ThrowIfNull(descriptor);
var declared = (declaredCdc ?? string.Empty).Trim().ToUpperInvariant();
var inferred = (descriptor.Cdc ?? string.Empty).Trim().ToUpperInvariant();
var status = descriptor.StatusReference ?? string.Empty;
var mmsType = (descriptor.CtlValSpecification.MmsType ?? string.Empty).Trim().ToLowerInvariant();
var size = descriptor.CtlValSpecification.Size;
var cdc = string.IsNullOrWhiteSpace(declared) ? inferred : declared;

Iec61850ControlSemanticResolution Deny(string reason)
=> new(false, cdc, reason);

if (!descriptor.IsOperationallyReady)
return Deny("The live control descriptor or ctlModel is not operationally ready.");
if (string.IsNullOrWhiteSpace(status))
return Deny("No live status reference corroborates the control semantics.");

// A stale persisted feedback reference must not silently override a
// different live BSC status object when commands are involved.
if (cdc == "BSC" && !string.IsNullOrWhiteSpace(declaredStatusReference) &&
!string.Equals(declaredStatusReference.Trim(), status, StringComparison.OrdinalIgnoreCase))
return Deny("Declared BSC feedback reference disagrees with the live control descriptor.");

if (cdc != inferred &&
!(inferred == "INC/ISC" && cdc is "INC" or "ISC") &&
!(cdc == "DPC" && inferred == "SPC" && IsStandardPosition(descriptor.ObjectReference)))
return Deny($"Declared CDC '{cdc}' conflicts with live CDC evidence '{inferred}'.");

var statusIsStep = status.EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase);
var statusIsState = status.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase);
var supported = cdc switch
{
"BSC" => statusIsStep &&
(mmsType is "integer" or "unsigned" ||
(mmsType == "bit-string" && size == 2)),
"DPC" => statusIsState &&
((mmsType == "bit-string" && size == 2) ||
(mmsType == "boolean" && IsStandardPosition(descriptor.ObjectReference))),
"SPC" => statusIsState && mmsType == "boolean",
"INC" or "ISC" => declared == cdc && statusIsState &&
mmsType is "integer" or "unsigned" or "bcd",
"APC" => declared == "APC" &&
(mmsType == "floating-point" || IsAnalogStructure(descriptor.CtlValSpecification)),
_ => false // Other CDCs require an independently audited typed codec.
};

if (!supported)
return Deny($"CDC '{cdc}' cannot be safely bound to live ctlVal '{descriptor.CtlValSpecification.Signature}' and status '{status}'.");

return new(true, cdc,
$"Verified CDC={cdc}; live ctlModel={descriptor.ControlModel}; ctlVal={descriptor.CtlValSpecification.Signature}; status={status}.");
}

private static bool IsStandardPosition(string reference)
{
var suffix = reference[(reference.LastIndexOf('/') + 1)..];
var dot = suffix.IndexOf('.');
if (dot <= 0 || !suffix[(dot + 1)..].Equals("Pos", StringComparison.OrdinalIgnoreCase))
return false;
var ln = suffix[..dot].ToUpperInvariant();
return ln.StartsWith("CSWI", StringComparison.Ordinal) ||
ln.StartsWith("XCBR", StringComparison.Ordinal) ||
ln.StartsWith("XSWI", StringComparison.Ordinal);
}

private static bool IsAnalogStructure(MmsTypeSpecificationNode ctlVal)
{
if (!ctlVal.MmsType.Equals("structure", StringComparison.OrdinalIgnoreCase))
return false;
var numeric = ctlVal.Children.Where(child =>
child.MmsType is "integer" or "unsigned" or "floating-point").ToArray();
return numeric.Length == 1 && ctlVal.Children.Count == 1;
}
}

public sealed record Iec61850ControlSemanticResolution(bool IsCommandSafe, string Cdc, string Reason);
8 changes: 7 additions & 1 deletion src/AR.Iec61850/Control/Iec61850ControlService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,12 @@ internal async Task<Iec61850ControlObjectSession> OpenCoreAsync(
var descriptor = new Iec61850ControlObjectDescriptor
{
ObjectReference = references.ObjectReference,
Cdc = InferCdc(ctlValSpecification),
// The exact live status shape distinguishes BSC TapChg's Tcmd enum
// from DPC even when both ctlVal types have a two-bit MMS encoding.
// Never classify a step-position value as an Open/Close switch.
Cdc = status.Reference.EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase)
? "BSC"
: InferCdc(ctlValSpecification),
ControlModel = controlModel,
CtlValSpecification = ctlValSpecification,
OperSpecification = operSpecification,
Expand Down Expand Up @@ -220,6 +225,7 @@ private static (string Reference, string FunctionalConstraint) FindStatusReferen
{
var prefixes = new[]
{
$"{references.LogicalNode}$ST${references.MmsDataObjectPath}$valWTr$posVal",
$"{references.LogicalNode}$ST${references.MmsDataObjectPath}$stVal",
$"{references.LogicalNode}$ST${references.MmsDataObjectPath}$posVal",
$"{references.LogicalNode}$MX${references.MmsDataObjectPath}$mag$f",
Expand Down
3 changes: 3 additions & 0 deletions src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ result.TypeSpecification is not null &&
{
var domain = result.Reference.Domain.Trim();
var logicalNode = result.Reference.Item.Trim();
// Fail closed: only known directory LN roots are materialized.
// DataSet-root closure first promotes confirmed MMS member references
// into this directory, after a successful exact LN type probe.
if (!directory.LogicalDevices.TryGetValue(domain, out var logicalDevice) ||
!logicalDevice.LogicalNodes.ContainsKey(logicalNode))
{
Expand Down
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 '.');
}
Loading