diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index dbd7b027..f10c0ab4 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -20,7 +20,7 @@ on: - "*.slnx" - "Directory.Build.props" pull_request: - branches: [ main ] + branches: [ main, feat/495-dataset-type-closure ] workflow_dispatch: permissions: diff --git a/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs b/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs index 87f4a9ea..5c70f4f0 100644 --- a/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs +++ b/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Threading.Channels; using AR.Iec61850.Asn1; using AR.Iec61850.Mms; @@ -192,6 +193,17 @@ public sealed class MmsAssociationReportingRuntime : IMmsAssociationRuntime, IDi private readonly Dictionary _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( @@ -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 States => _states.Values; @@ -414,6 +432,7 @@ public void Dispose() return; _disposed = true; + _sendQueue.Writer.TryComplete(); _cts.Cancel(); lock (_gate) { @@ -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) diff --git a/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs b/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs new file mode 100644 index 00000000..ed2ac090 --- /dev/null +++ b/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs @@ -0,0 +1,91 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Control; + +/// +/// 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. +/// +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); diff --git a/src/AR.Iec61850/Control/Iec61850ControlService.cs b/src/AR.Iec61850/Control/Iec61850ControlService.cs index 30b4051f..6b6aba9b 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlService.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlService.cs @@ -126,7 +126,12 @@ internal async Task 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, @@ -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", diff --git a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs index a5932071..e9291348 100644 --- a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs +++ b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs @@ -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)) { diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs new file mode 100644 index 00000000..a3b8918a --- /dev/null +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Xml.Linq; + +namespace AR.Iec61850.Discovery; + +/// +/// 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. +/// +public static class TrustedSclIedIdentityMatcher +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + public static LiveIedIdentity? TryMatch( + XDocument trustedScl, + IEnumerable 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 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(); + if (devices.Length != observed.Count) continue; + + var aliases = new Dictionary(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(); + 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(); + 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 '.'); +} diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs index 59e36114..ee0d325d 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs @@ -8,6 +8,14 @@ namespace AR.Iec61850.Mms; public sealed partial class MmsClientSession { private readonly object _smartDiscoverySingleFlightSync = new(); + // Reuse a complete legacy control directory once per association when the + // smart-discovery inventory is not present (notably trusted Open SCL). + // This is structural metadata only; ctlModel and Oper/SBOw types stay live. + private Task>>? _controlNameInventoryTask; + private object? _controlNameInventoryAssociationMarker; + private string _controlNameInventoryHost = string.Empty; + private int _controlNameInventoryPort; + private object? _smartDiscoverySingleFlightAssociationMarker; private string _smartDiscoverySingleFlightHost = string.Empty; private int _smartDiscoverySingleFlightPort; @@ -102,7 +110,52 @@ internal async Task>> GetAutho } } - return await DiscoverDomainVariableNamesAsync(cancellationToken).ConfigureAwait(false); + return await GetLegacyControlInventoryOnceAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task>> GetLegacyControlInventoryOnceAsync( + CancellationToken cancellationToken) + { + var marker = LastAssociationAttempts; + var host = _lastHost; + var port = _lastPort; + Task>> pending; + lock (_smartDiscoverySingleFlightSync) + { + if (!ReferenceEquals(_controlNameInventoryAssociationMarker, marker) || + !string.Equals(_controlNameInventoryHost, host, StringComparison.OrdinalIgnoreCase) || + _controlNameInventoryPort != port) + { + _controlNameInventoryTask = null; + _controlNameInventoryAssociationMarker = marker; + _controlNameInventoryHost = host; + _controlNameInventoryPort = port; + } + pending = _controlNameInventoryTask ??= DiscoverDomainVariableNamesAsync(CancellationToken.None); + } + try + { + var result = await pending.WaitAsync(cancellationToken).ConfigureAwait(false); + // Never preserve empty/partial inventory as evidence after a failed + // enumeration; the next inspection can retry on the live association. + if (result.Count == 0 || result.Values.Any(names => names.Count == 0)) + ClearFailedControlInventory(pending); + return result; + } + catch + { + ClearFailedControlInventory(pending); + throw; + } + } + + private void ClearFailedControlInventory(Task>> task) + { + lock (_smartDiscoverySingleFlightSync) + { + if (ReferenceEquals(_controlNameInventoryTask, task)) + _controlNameInventoryTask = null; + } } private void ObserveSmartDiscoverySingleFlightCompletion(Task task) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs index 316e872a..3d189259 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs @@ -263,6 +263,59 @@ public async Task> GetVariableA return results; } + + /// + /// Exact static DataSet-member LN type closure. Shares the negotiated invoke + /// window and KPI accounting with smart hierarchy probes. This never reads + /// process values, mutates DataSets, or opens another MMS association. + /// + public async Task> ProbeDataSetMemberTypesSmartAsync( + MmsDiscoveryResult discovery, + IReadOnlyList previousTypes, + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + ArgumentNullException.ThrowIfNull(discovery); + ArgumentNullException.ThrowIfNull(previousTypes); + var roots = MmsSmartTypeProbePolicy.SelectUnprobedDataSetLogicalNodeRoots( + discovery.IedDirectory, discovery.DataSetDirectories, previousTypes); + if (roots.Length == 0) + return Array.Empty(); + var results = await RunVariableAttributeBatchAsync( + roots, ResolveSmartDiscoveryWindow(options ?? new MmsSmartDiscoveryOptions()), + cancellationToken).ConfigureAwait(false); + + // Authorize previously unseen LN roots only when two independent live MMS + // observations agree: an exact configured DataSet FCDA/FCD member and a + // successful typed GetVariableAccessAttributes response for that LN. + // Never materialize a foreign LN from an unsolicited GVA result. + var successfulRoots = results + .Where(result => result.IsSuccess && result.TypeSpecification is not null) + .Select(result => result.Reference.Domain + "/" + result.Reference.Item) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var membershipSeeds = discovery.DataSetDirectories + .Where(dataSet => dataSet.IsSuccess) + .SelectMany(dataSet => dataSet.Members) + .Where(member => successfulRoots.Contains(member.Domain + "/" + member.LogicalNode) && + !string.IsNullOrWhiteSpace(member.DataObjectPath) && + (member.FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) || + member.FunctionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase))) + .Select(member => new MmsFcResolvedPoint + { + Domain = member.Domain, + LogicalNode = member.LogicalNode, + FunctionalConstraint = member.FunctionalConstraint, + DataObjectPath = member.DataObjectPath, + MmsItemName = member.MmsItemName, + Source = "GetNamedVariableListAttributes+GetVariableAccessAttributes", + Confidence = 100 + }) + .ToArray(); + discovery.IedDirectory.AddSupplementalPoints(membershipSeeds); + return results; + } + private void PublishSmartTypeProbeBudget(MmsSmartTypeProbeBudgetSnapshot snapshot) => Volatile.Write(ref _lastSmartTypeProbeBudget, snapshot); diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs index 403ed96f..d5ec0490 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs @@ -669,6 +669,22 @@ private void RoutePersistentInformationReport(byte[] payload) return null; } + var dataSet = NormalizeRoutingReference(header.DataSetReference); + + // Report identity is a two-field contract whenever the peer provides + // a domain-qualified DatSet. A matching RptID is not sufficient to + // project another DataSet's payload using this monitor's member order. + // Keep the existing RptID-only compatibility for omitted or abbreviated + // DatSet fields (some supported peers do not advertise a full path). + bool ConflictsWithAdvertisedDataSet(MmsPersistentReportMonitorSession candidate) + { + if (string.IsNullOrWhiteSpace(dataSet) || !dataSet.Contains('/')) + return false; + var expected = NormalizeRoutingReference(candidate.Plan.DataSetReference); + return !string.IsNullOrWhiteSpace(expected) && + !expected.Equals(dataSet, StringComparison.OrdinalIgnoreCase); + } + var reportId = NormalizeRoutingReference(header.ReportId); if (!string.IsNullOrWhiteSpace(reportId)) { @@ -677,6 +693,11 @@ private void RoutePersistentInformationReport(byte[] payload) .ToArray(); if (exactRptId.Length == 1) { + if (ConflictsWithAdvertisedDataSet(exactRptId[0])) + { + evidence = "RptID/DatSet conflict: report was quarantined before process-value projection"; + return null; + } evidence = "exact RptID"; return exactRptId[0]; } @@ -684,12 +705,16 @@ private void RoutePersistentInformationReport(byte[] payload) var rcbAffinity = candidates.Where(x => HasRcbNameAffinity(reportId, x.ReportControl)).ToArray(); if (rcbAffinity.Length == 1) { + if (ConflictsWithAdvertisedDataSet(rcbAffinity[0])) + { + evidence = "RptID/DatSet conflict: report was quarantined before process-value projection"; + return null; + } evidence = "RptID-to-RCB name affinity"; return rcbAffinity[0]; } } - var dataSet = NormalizeRoutingReference(header.DataSetReference); if (!string.IsNullOrWhiteSpace(dataSet)) { var exactDataSet = candidates @@ -714,6 +739,11 @@ private void RoutePersistentInformationReport(byte[] payload) if (candidates.Length == 1) { + if (ConflictsWithAdvertisedDataSet(candidates[0])) + { + evidence = "single-monitor DatSet conflict: report was quarantined before process-value projection"; + return null; + } evidence = "single active monitor fallback"; return candidates[0]; } diff --git a/src/AR.Iec61850/Mms/MmsReceivePump.cs b/src/AR.Iec61850/Mms/MmsReceivePump.cs index d6f68854..1937f127 100644 --- a/src/AR.Iec61850/Mms/MmsReceivePump.cs +++ b/src/AR.Iec61850/Mms/MmsReceivePump.cs @@ -183,6 +183,7 @@ private async Task RunAsync(CancellationToken cancellationToken) { LastFaultMessage = $"MMS receive pump fault: {ex.GetType().Name}: {ex.Message}"; FaultPendingOperations(ex); + _router.FaultCanonicalInformationReports(ex); _router.FaultInformationReportSubscriptions(ex); } } diff --git a/src/AR.Iec61850/Mms/MmsReceiveRouter.cs b/src/AR.Iec61850/Mms/MmsReceiveRouter.cs index 402ad4fc..523c85cc 100644 --- a/src/AR.Iec61850/Mms/MmsReceiveRouter.cs +++ b/src/AR.Iec61850/Mms/MmsReceiveRouter.cs @@ -21,12 +21,30 @@ public sealed class MmsReceiveRouter { private readonly object _sync = new(); private readonly Dictionary> _confirmedByInvoke = new(); + // The canonical FIFO is the static report monitor authority. Bound both frame count + // and retained PDU bytes; never silently discard a buffered event on overload. + private const int DefaultCanonicalCapacity = 2048; + private const long DefaultCanonicalByteCapacity = 32L * 1024 * 1024; + private readonly int _canonicalCapacity; + private readonly long _canonicalByteCapacity; + private long _queuedCanonicalBytes; + private Exception? _canonicalReportFault; private readonly Queue _informationReports = new(); private readonly Queue _unconfirmed = new(); private readonly Queue _unmatched = new(); private readonly Dictionary> _informationReportSubscriptions = new(); private readonly SemaphoreSlim _informationReportAvailable = new(0, 1); + public MmsReceiveRouter( + int canonicalReportCapacity = DefaultCanonicalCapacity, + long canonicalReportByteCapacity = DefaultCanonicalByteCapacity) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(canonicalReportCapacity); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(canonicalReportByteCapacity); + _canonicalCapacity = canonicalReportCapacity; + _canonicalByteCapacity = canonicalReportByteCapacity; + } + public int QueuedConfirmedResultCount { get @@ -69,6 +87,7 @@ public MmsReceiveRouteResult Route(ReadOnlyMemory presentationPayload) lock (_sync) { + ThrowIfCanonicalReportFaultLocked(); if (envelope.IsConfirmedServiceResult && envelope.InvokeId.HasValue) { if (!_confirmedByInvoke.TryGetValue(envelope.InvokeId.Value, out var queue)) @@ -88,10 +107,39 @@ public MmsReceiveRouteResult Route(ReadOnlyMemory presentationPayload) if (envelope.IsInformationReport) { + var retainedBytes = envelope.PresentationPayload.Length; + if (_informationReports.Count >= _canonicalCapacity || + retainedBytes > _canonicalByteCapacity - _queuedCanonicalBytes) + { + _canonicalReportFault = new IOException( + "Canonical MMS InformationReport FIFO exceeded its bounded frame/byte capacity; " + + "SOE continuity is unverified. Re-establish the MMS association after addressing consumer lag."); + SignalInformationReportAvailable(); + throw (IOException)_canonicalReportFault; + } + _informationReports.Enqueue(envelope); + _queuedCanonicalBytes += retainedBytes; SignalInformationReportAvailable(); - foreach (var subscription in _informationReportSubscriptions.Values) - subscription.Writer.TryWrite(envelope); + // A subscriber cannot silently discard SOE on burst pressure. + // Preserve the canonical FIFO regardless of subscriber capacity; + // isolate and fault only consumers which cannot keep up. + List? overflowed = null; + foreach (var (id, subscription) in _informationReportSubscriptions) + { + if (!subscription.Writer.TryWrite(envelope)) + { + (overflowed ??= new List()).Add(id); + subscription.Writer.TryComplete(new IOException( + "MMS InformationReport subscriber exceeded its bounded capacity; " + + "continuity is unverified. Resubscribe/re-establish the report stream.")); + } + } + if (overflowed is not null) + { + foreach (var id in overflowed) + _informationReportSubscriptions.Remove(id); + } return new MmsReceiveRouteResult { @@ -146,10 +194,14 @@ public bool TryDequeueInformationReport(out MmsPduEnvelope envelope) if (_informationReports.Count > 0) { envelope = _informationReports.Dequeue(); + _queuedCanonicalBytes -= envelope.PresentationPayload.Length; return true; } } + lock (_sync) + ThrowIfCanonicalReportFaultLocked(); + envelope = new MmsPduEnvelope(); return false; } @@ -165,18 +217,30 @@ internal async Task WaitForInformationReportAsync( { cancellationToken.ThrowIfCancellationRequested(); - if (QueuedInformationReportCount > 0) + lock (_sync) { - _informationReportAvailable.Wait(0); - return true; + if (_informationReports.Count > 0) + { + _informationReportAvailable.Wait(0); + return true; + } + + ThrowIfCanonicalReportFaultLocked(); } if (timeout <= TimeSpan.Zero) return false; - return await _informationReportAvailable + var signalled = await _informationReportAvailable .WaitAsync(timeout, cancellationToken) .ConfigureAwait(false); + lock (_sync) + { + if (_informationReports.Count == 0) + ThrowIfCanonicalReportFaultLocked(); + } + + return signalled; } internal MmsInformationReportSubscription SubscribeInformationReports(int capacity = 32) @@ -184,7 +248,9 @@ internal MmsInformationReportSubscription SubscribeInformationReports(int capaci var boundedCapacity = Math.Clamp(capacity, 1, 1024); var channel = Channel.CreateBounded(new BoundedChannelOptions(boundedCapacity) { - FullMode = BoundedChannelFullMode.DropOldest, + // Never silently throw away the oldest event (SOE data loss). + // TryWrite=false is handled by explicitly faulting this subscriber. + FullMode = BoundedChannelFullMode.Wait, SingleReader = true, SingleWriter = false, AllowSynchronousContinuations = false @@ -224,6 +290,27 @@ internal void FaultInformationReportSubscriptions(Exception exception) subscription.Writer.TryComplete(exception); } + // Called by the single receive pump on a terminal transport/decoder error. + // Existing canonical entries remain drainable in FIFO order; after the final + // entry the consumer observes the terminal error rather than a false clean slice. + internal void FaultCanonicalInformationReports(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + lock (_sync) + { + _canonicalReportFault ??= exception; + SignalInformationReportAvailable(); + } + } + + private void ThrowIfCanonicalReportFaultLocked() + { + if (_canonicalReportFault is not null) + throw new IOException( + "Canonical MMS InformationReport continuity is unverified; association receive routing has faulted.", + _canonicalReportFault); + } + private void SignalInformationReportAvailable() { try @@ -243,6 +330,8 @@ public void Clear() { _confirmedByInvoke.Clear(); _informationReports.Clear(); + _queuedCanonicalBytes = 0; + _canonicalReportFault = null; _unconfirmed.Clear(); _unmatched.Clear(); subscriptions = _informationReportSubscriptions.Values.ToArray(); diff --git a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs index 0e6ddaae..31fc6f54 100644 --- a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs +++ b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs @@ -122,6 +122,55 @@ public static MmsObjectReference[] SelectLiveLogicalNodeRoots( .ToArray(); } + + /// + /// Wire-authoritative ST/MX LN candidates from successful DataSet directories. + /// No SCL guesses, no duplicate GVA requests, no extra name-list crawl. + /// + public static MmsObjectReference[] SelectUnprobedDataSetLogicalNodeRoots( + MmsIedModelDirectory directory, + IReadOnlyList directories, + IReadOnlyList attemptedTypes) + { + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(directories); + ArgumentNullException.ThrowIfNull(attemptedTypes); + + var domains = directory.LogicalDevices.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); + var attempted = attemptedTypes + .Where(result => !result.Reference.Item.Contains('$')) + .Select(result => BuildReferenceKey(result.Reference.Domain, result.Reference.Item)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var targets = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var dataSet in directories.Where(dataSet => dataSet.IsSuccess)) + foreach (var member in dataSet.Members) + { + if (!domains.Contains(member.Domain) || + !(member.FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) || + member.FunctionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase))) + continue; + + var parts = member.MmsItemName.Split('$'); + if (parts.Length < 3 || + !parts[1].Equals(member.FunctionalConstraint, StringComparison.OrdinalIgnoreCase)) + continue; + + var ln = parts[0].Trim(); + if (ln.Length == 0 || ln.Length > 64 || + !ln.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '_')) + continue; + + var key = BuildReferenceKey(member.Domain, ln); + if (!attempted.Contains(key)) + targets.TryAdd(key, new MmsObjectReference(member.Domain, ln, string.Empty)); + } + + return targets.Values + .OrderBy(root => root.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(root => root.Item, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + public static MmsObjectReference BuildDataObjectRoot(MmsFcResolvedPoint point) { ArgumentNullException.ThrowIfNull(point); diff --git a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs index ccd70779..b92de8a7 100644 --- a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs @@ -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); @@ -99,6 +106,80 @@ public static XDocument ApplyIdentity( return document; } + + /// + /// Normalizes both IED identity and LD instance mappings from verified SCL + /// topology. Physical MMS domain / FCDA evidence remains unchanged. + /// + 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(StringComparer.OrdinalIgnoreCase); + var seen = new HashSet(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, diff --git a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs index 925cbe11..ae31f67b 100644 --- a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs @@ -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); @@ -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(), @@ -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; } @@ -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; @@ -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); @@ -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; @@ -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 @@ -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(); diff --git a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs index c6b6ac3f..ad2c7838 100644 --- a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs +++ b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs @@ -15,6 +15,13 @@ public sealed class LiveIedSclExportOptions /// when an implementation uses explicit communication-level LD names. /// public string IedNameOverride { get; init; } = string.Empty; + + /// + /// 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. + /// + 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; diff --git a/src/AR.Iec61850/Scl/SclApTitleOidNotation.cs b/src/AR.Iec61850/Scl/SclApTitleOidNotation.cs new file mode 100644 index 00000000..d22206e9 --- /dev/null +++ b/src/AR.Iec61850/Scl/SclApTitleOidNotation.cs @@ -0,0 +1,68 @@ +using System.Globalization; + +namespace AR.Iec61850.Scl; + +/// +/// SCL AP-Title is normally an ASN.1 OID arc list. Some legacy engineering +/// exports spell the first *BER subidentifier* as one decimal component +/// (40*firstArc + secondArc). Only the unambiguous single-octet 40..79 +/// form is recognized here; arbitrary malformed OIDs remain invalid. +/// No source XML or other SCL association field is rewritten. +/// +internal static class SclApTitleOidNotation +{ + internal static bool TryParse( + string text, + out uint[] arcs, + out bool wasPackedFirstArc, + out string error) + { + arcs = Array.Empty(); + wasPackedFirstArc = false; + error = string.Empty; + var tokens = text.Split( + [',', '.', ' ', ';'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (tokens.Length < 2) + { + error = $"SCL OSI-AP-Title '{text}' does not contain a valid OID."; + return false; + } + + var declared = new uint[tokens.Length]; + for (var i = 0; i < tokens.Length; i++) + { + if (!uint.TryParse(tokens[i], NumberStyles.None, CultureInfo.InvariantCulture, out declared[i])) + { + error = $"SCL OSI-AP-Title '{text}' contains an invalid OID arc '{tokens[i]}'."; + return false; + } + } + + if (declared[0] <= 2 && (declared[0] == 2 || declared[1] <= 39)) + { + arcs = declared; + return true; + } + + // Legacy OID subidentifier: decimal 41 means ASN.1 arcs 1.1. + // At least one remaining arc is required to distinguish this + // representation from an incomplete engineering declaration. + if (declared.Length >= 3 && declared[0] is >= 40 and <= 79) + { + arcs = new uint[declared.Length + 1]; + arcs[0] = 1; + arcs[1] = declared[0] - 40; + Array.Copy(declared, 1, arcs, 2, declared.Length - 1); + wasPackedFirstArc = true; + return true; + } + + error = $"SCL OSI-AP-Title '{text}' violates ASN.1 object-identifier arc rules."; + return false; + } + + internal static string CompatibilityWarning(string declared, IReadOnlyList decoded) + => $"SCL OSI-AP-Title '{declared}' uses legacy BER-packed first subidentifier; " + + $"association encodes standard OID '{string.Join(",", decoded)}'. Original SCL declaration remains unchanged."; +} diff --git a/src/AR.Iec61850/Scl/SclAssistedMmsAssociationPlan.cs b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationPlan.cs index 265e436d..a0d97d2e 100644 --- a/src/AR.Iec61850/Scl/SclAssistedMmsAssociationPlan.cs +++ b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationPlan.cs @@ -102,7 +102,7 @@ public static SclAssistedMmsAssociationPlanResult BuildExact( var remoteTsel = ParseSelector(remote.Association.TransportSelector, "OSI-TSEL", errors); var remoteSsel = ParseSelector(remote.Association.SessionSelector, "OSI-SSEL", errors); var remotePsel = ParseSelector(remote.Association.PresentationSelector, "OSI-PSEL", errors); - var remoteApTitle = ParseApTitle(remote.Association.ApTitle, errors); + var remoteApTitle = ParseApTitle(remote.Association.ApTitle, errors, warnings); if (!remote.Association.AeQualifier.HasValue || remote.Association.AeQualifier.Value is < 0 or > 65535) @@ -223,7 +223,10 @@ private static void ValidateLocal(MmsLocalAssociationProfile local, ICollection< return bytes; } - private static uint[]? ParseApTitle(string text, ICollection errors) + private static uint[]? ParseApTitle( + string text, + ICollection errors, + ICollection warnings) { if (string.IsNullOrWhiteSpace(text)) { @@ -231,28 +234,14 @@ private static void ValidateLocal(MmsLocalAssociationProfile local, ICollection< return null; } - var tokens = text.Split([',', '.', ' ', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (tokens.Length < 2) + if (!SclApTitleOidNotation.TryParse(text, out var arcs, out var packed, out var error)) { - errors.Add($"SCL OSI-AP-Title '{text}' does not contain a valid OID."); + errors.Add(error); return null; } - var arcs = new uint[tokens.Length]; - for (var i = 0; i < tokens.Length; i++) - { - if (!uint.TryParse(tokens[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out arcs[i])) - { - errors.Add($"SCL OSI-AP-Title '{text}' contains an invalid OID arc '{tokens[i]}'."); - return null; - } - } - - if (arcs[0] > 2 || (arcs[0] < 2 && arcs[1] > 39)) - { - errors.Add($"SCL OSI-AP-Title '{text}' violates ASN.1 object-identifier arc rules."); - return null; - } + if (packed) + warnings.Add(SclApTitleOidNotation.CompatibilityWarning(text, arcs)); return arcs; } diff --git a/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs index 805061b0..432230e7 100644 --- a/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs +++ b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs @@ -87,7 +87,7 @@ public static SclAssistedMmsAssociationResolution Resolve( var tsel = ParseSelector(remote, "OSI-TSEL", remote.Association.TransportSelector, errors); var ssel = ParseSelector(remote, "OSI-SSEL", remote.Association.SessionSelector, errors); var psel = ParseSelector(remote, "OSI-PSEL", remote.Association.PresentationSelector, errors); - var apTitle = ParseApTitle(remote, remote.Association.ApTitle, errors); + var apTitle = ParseApTitle(remote, remote.Association.ApTitle, errors, warnings); var aeQualifier = ParseAeQualifier(remote, errors); var fields = new[] @@ -548,7 +548,8 @@ private static SelectorConstraint InvalidSelector(string name, string text) private static ApTitleConstraint ParseApTitle( SclMmsAccessPoint remote, string text, - ICollection errors) + ICollection errors, + ICollection warnings) { const string name = "OSI-AP-Title"; if (remote.AmbiguousParameters.Contains(name)) @@ -568,30 +569,14 @@ private static ApTitleConstraint ParseApTitle( new SclAssociationFieldEvidence { Name = name, State = SclAssociationFieldState.Unspecified }); } - var tokens = text.Split( - [',', '.', ' ', ';'], - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (tokens.Length < 2) + if (!SclApTitleOidNotation.TryParse(text, out var arcs, out var packed, out var error)) { - errors.Add($"SCL OSI-AP-Title '{text}' does not contain a valid OID."); + errors.Add(error); return InvalidApTitle(text); } - var arcs = new uint[tokens.Length]; - for (var i = 0; i < tokens.Length; i++) - { - if (!uint.TryParse(tokens[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out arcs[i])) - { - errors.Add($"SCL OSI-AP-Title '{text}' contains an invalid OID arc '{tokens[i]}'."); - return InvalidApTitle(text); - } - } - - if (arcs[0] > 2 || (arcs[0] < 2 && arcs[1] > 39)) - { - errors.Add($"SCL OSI-AP-Title '{text}' violates ASN.1 object-identifier arc rules."); - return InvalidApTitle(text); - } + if (packed) + warnings.Add(SclApTitleOidNotation.CompatibilityWarning(text, arcs)); return new ApTitleConstraint( SclAssociationFieldState.PresentValid, diff --git a/tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs b/tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs new file mode 100644 index 00000000..aa5bb41b --- /dev/null +++ b/tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs @@ -0,0 +1,66 @@ +using AR.Iec61850.Control; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Control; + +public sealed class MultiVendorControlSemanticResolverTests +{ + [Theory] + [InlineData("BSC", "BSC", "bit-string", 2, "LD/ATCC1.TapChg.valWTr.posVal", true)] + [InlineData("DPC", "DPC", "bit-string", 2, "LD/CSWI1.Pos.stVal", true)] + [InlineData("SPC", "SPC", "boolean", 0, "LD/GGIO1.Enable.stVal", true)] + [InlineData("BSC", "DPC", "bit-string", 2, "LD/ATCC1.TapChg.stVal", false)] + [InlineData("DPC", "BSC", "bit-string", 2, "LD/CSWI1.Pos.valWTr.posVal", false)] + [InlineData("INC", "INC/ISC", "integer", 0, "LD/GGIO1.Set.stVal", true)] + [InlineData("", "INC/ISC", "integer", 0, "LD/GGIO1.Set.stVal", false)] + [InlineData("ISC", "INC/ISC", "bit-string", 2, "LD/ATCC1.Step.stVal", false)] + [InlineData("", "DPC", "bit-string", 2, "LD/CSWI1.Pos.stVal", true)] + public void ResolvesOnlyCorroboratedCommandSemantics( + string declared, string observed, string mmsType, int size, string status, bool safe) + { + var reference = status[..status.LastIndexOf('.')]; + if (status.EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase)) + reference = reference[..reference.LastIndexOf('.')]; + var descriptor = NewDescriptor(reference, observed, mmsType, size, status); + var result = Iec61850ControlSemanticResolver.Resolve(descriptor, declared); + Assert.Equal(safe, result.IsCommandSafe); + Assert.False(string.IsNullOrWhiteSpace(result.Reason)); + } + + [Fact] + public void DisagreedPersistedBscFeedbackIsNotCommandAuthority() + { + var descriptor = NewDescriptor("LD/ATCC1.TapChg", "BSC", "bit-string", 2, + "LD/ATCC1.TapChg.valWTr.posVal"); + var result = Iec61850ControlSemanticResolver.Resolve( + descriptor, "BSC", "LD/ATCC2.TapChg.valWTr.posVal"); + Assert.False(result.IsCommandSafe); + Assert.Contains("disagrees", result.Reason); + } + + [Fact] + public void RawTwoBitEncodingAloneCannotEstablishBscDirection() + { + var descriptor = NewDescriptor("LD/ATCC1.TapChg", "DPC", "bit-string", 2, + "LD/ATCC1.TapChg.stVal"); + Assert.False(Iec61850ControlSemanticResolver.Resolve(descriptor, "BSC").IsCommandSafe); + } + + private static Iec61850ControlObjectDescriptor NewDescriptor( + string reference, string cdc, string type, int size, string status) => new() + { + ObjectReference = reference, + Cdc = cdc, + ControlModel = Iec61850ControlModel.DirectNormal, + CtlValSpecification = new MmsTypeSpecificationNode + { + Name = "ctlVal", MmsType = type, Size = size <= 0 ? null : size + }, + StatusReference = status, + OperSpecification = new MmsTypeSpecificationNode + { + MmsType = "structure", + Children = new[] { new MmsTypeSpecificationNode { Name = "ctlVal", MmsType = type } } + } + }; +} diff --git a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs index ead5a83d..4e4cb6f9 100644 --- a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs +++ b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs @@ -599,6 +599,30 @@ public async Task DescriptorDiscovery_UsesLiveCtlModelAndExactCtlValType() Assert.Equal("ST", session.Descriptor.StatusFunctionalConstraint); } + [Fact] + public async Task DescriptorDiscovery_RecognizesBscStepControl_FromExactLiveValWTrPosition() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(4) }); + transport.Specifications["ATCC1$CO$TapChg$Oper"] = CommandSpecification(); + transport.Specifications["ATCC1$CO$TapChg$SBOw"] = CommandSpecification(); + transport.Specifications["ATCC1$CO$TapChg$Cancel"] = CommandSpecification(); + transport.DomainVariables["LD0"] = new[] + { + "ATCC1$ST$TapChg$valWTr$posVal", + "ATCC1$ST$TapChg$q" + }; + var service = new Iec61850ControlService(); + + await using var session = await service.OpenCoreAsync(transport, "LD0/ATCC1.TapChg"); + + Assert.Equal("BSC", session.Descriptor.Cdc); + Assert.Equal(Iec61850ControlModel.SelectBeforeOperateEnhanced, session.Descriptor.ControlModel); + Assert.Equal("LD0/ATCC1.TapChg.valWTr.posVal", session.Descriptor.StatusReference); + Assert.Equal("ST", session.Descriptor.StatusFunctionalConstraint); + Assert.Equal("bit-string", session.Descriptor.CtlValSpecification.MmsType); + } + [Fact] public async Task DescriptorDiscovery_RejectsSboObjectWithoutCancelService() { diff --git a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs new file mode 100644 index 00000000..62274a78 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs @@ -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(() => + 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" + } + ] + } + ] + }; +} diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs index c6d7ed27..1010e7d7 100644 --- a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs @@ -10,6 +10,69 @@ public sealed class CanonicalLiveIedSclExporterTests { private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + [Fact] + public void VerifiedSclIdentity_KeepsAcceptedMmsAssociationWhileMappingLogicalDevice() + { + var accepted = CreateCanonical(); + const string observedDomain = "IEDLD0"; + var canonical = new LiveIedCanonicalModel + { + Discovery = new LiveIedModelDiscoveryDocument + { + Host = "10.20.30.40", IedName = "IED", AccessPointName = "AP1", + LogicalDevices = [ + new LiveIedLogicalDeviceModel + { + MmsDomain = observedDomain, Inst = observedDomain, + LogicalNodes = [ + new LiveIedLogicalNodeModel + { + Name = "LLN0", LnClass = "LLN0", + ProposedLnTypeId = "LN_LLN0" + } + ] + } + ] + }, + Communication = accepted.Communication + }; + XNamespace ns = Scl; + var trusted = new XDocument(new XElement(ns+"SCL", + new XElement(ns+"Communication",new XElement(ns+"SubNetwork", + new XElement(ns+"ConnectedAP", + new XAttribute("iedName","BCUGE"),new XAttribute("apName","AP1"), + new XElement(ns+"Address", + new XElement(ns+"P",new XAttribute("type","IP"),"10.20.30.40"))))), + new XElement(ns+"IED",new XAttribute("name","BCUGE"), + new XElement(ns+"AccessPoint",new XAttribute("name","AP1"), + new XElement(ns+"Server",new XElement(ns+"LDevice", + new XAttribute("inst","F650"), + new XAttribute("ldName",observedDomain))))))); + var proof = TrustedSclIedIdentityMatcher.TryMatch( + trusted,[observedDomain],"10.20.30.40","BCUGE"); + Assert.NotNull(proof); + + var dir=Path.Combine(Path.GetTempPath(),"ariec-p10-canonical-"+Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var result=CanonicalLiveIedSclExporter.WriteFiles(canonical, + Path.Combine(dir,"BCUGE.iid"),SclSchemaProfile.Edition1V16, + verifiedIdentity:proof); + var doc=XDocument.Load(result.SclPath); + Assert.Equal("BCUGE",(string?)Assert.Single(doc.Descendants(ns+"IED")).Attribute("name")); + var ld=Assert.Single(doc.Descendants(ns+"LDevice")); + Assert.Equal("F650",(string?)ld.Attribute("inst")); + Assert.Equal(observedDomain,(string?)ld.Attribute("ldName")); + Assert.Equal("BCUGE",(string?)Assert.Single(doc.Descendants(ns+"ConnectedAP")).Attribute("iedName")); + Assert.NotNull(SclMmsAssociationProfileReader.Read(doc).Find("BCUGE",canonical.AccessPointName)); + } + finally + { + Directory.Delete(dir,true); + } + } + [Fact] public void CanonicalBuilder_Preserves_CaseDistinct_InitialFcLeafEvidence() { diff --git a/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs index 14ec1f56..2fccfc4b 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs @@ -19,6 +19,68 @@ public void Selector_RoutesByExactReportIdBeforeDataSetProjection() Assert.Equal("exact RptID", evidence); } + [Fact] + public void Selector_RejectsContradictoryDomainQualifiedDataSetWithExactReportId() + { + var first = CreateSession("URCBA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + var second = CreateSession("URCBA02", "RPT_B", "IEDLD0/LLN0.DS_B"); + + var selected = MmsClientSession.SelectPersistentReportMonitor( + [first, second], + new MmsReportHeader { ReportId = "RPT_A", DataSetReference = "IEDLD0/LLN0$DS_B" }, + out var evidence); + + Assert.Null(selected); + Assert.Contains("RptID/DatSet conflict", evidence); + } + + [Fact] + public void Selector_RejectsUnknownFullDataSetDespiteMatchingReportId() + { + var first = CreateSession("URCBA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + var second = CreateSession("URCBA02", "RPT_B", "IEDLD0/LLN0.DS_B"); + + var selected = MmsClientSession.SelectPersistentReportMonitor( + [first, second], + new MmsReportHeader { ReportId = "RPT_A", DataSetReference = "OTHERLD/LLN0$DS_X" }, + out var evidence); + + Assert.Null(selected); + Assert.Contains("quarantined", evidence); + } + + [Fact] + public void Selector_SingleActiveMonitor_DoesNotBypassDataSetConsistency() + { + var session = CreateSession("URCBA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + + var selected = MmsClientSession.SelectPersistentReportMonitor( + [session], + new MmsReportHeader { ReportId = "UNRECOGNIZED", DataSetReference = "OTHERLD/LLN0.DS_X" }, + out var evidence); + + Assert.Null(selected); + Assert.Contains("single-monitor DatSet conflict", evidence); + } + + [Fact] + public void Selector_PreservesValidNoDataSetAndVendorShortDataSetCompatibility() + { + var session = CreateSession("URCBA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + + var missing = MmsClientSession.SelectPersistentReportMonitor( + [session], new MmsReportHeader { ReportId = "RPT_A" }, out var missingEvidence); + var shortName = MmsClientSession.SelectPersistentReportMonitor( + [session], + new MmsReportHeader { ReportId = "RPT_A", DataSetReference = "LLN0$DS_A" }, + out var shortEvidence); + + Assert.Same(session, missing); + Assert.Same(session, shortName); + Assert.Equal("exact RptID", missingEvidence); + Assert.Equal("exact RptID", shortEvidence); + } + [Fact] public void Selector_RoutesByExactDataSetWhenReportIdIsUnavailable() { diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReceivePumpTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReceivePumpTests.cs index e00df8d8..41d121ca 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReceivePumpTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReceivePumpTests.cs @@ -84,6 +84,30 @@ public async Task Pump_FaultsPendingInvokeWhenReaderFails() Assert.Equal(0, pump.PendingOperationCount); } + [Fact] + public async Task Pump_CanonicalOverflow_FaultsPendingConfirmedWorkAndRetainsQueuedReports() + { + var channel = Channel.CreateUnbounded(); + for (var i = 0; i < 4; i++) + await channel.Writer.WriteAsync(BuildInformationReport()); + + var router = new MmsReceiveRouter(canonicalReportCapacity: 2); + var pump = new MmsReceivePump(router, token => channel.Reader.ReadAsync(token).AsTask()); + using var pending = pump.RegisterConfirmedOperation(9); + pump.Start(); + + var failure = await Assert.ThrowsAsync( + () => pending.WaitAsync(new CancellationTokenSource(TimeSpan.FromSeconds(2)).Token)); + await pump.StopAsync(); + + Assert.Contains("bounded frame/byte capacity", failure.Message); + Assert.Equal(2, router.QueuedInformationReportCount); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.Throws(() => router.TryDequeueInformationReport(out _)); + Assert.Contains("IOException", pump.LastFaultMessage); + } + private static byte[] BuildConfirmedReadResponse(int invokeId) { var data = MmsDataCodec.Encode(MmsDataValue.Boolean(true)); diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs index 4910d871..80f53bc8 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs @@ -99,6 +99,97 @@ public async Task Route_FansOutInformationReportWithoutStealingLegacyQueue() Assert.True(queued.IsInformationReport); } + [Fact] + public async Task SubscriberBurstOverCapacity_FaultsWithoutSilentDrop_WhileCanonicalFifoRetainsEveryFrame() + { + var router = new MmsReceiveRouter(); + await using var slow = router.SubscribeInformationReports(capacity: 8); + var sent = Enumerable.Range(1, 80).Select(BuildInformationReport).ToArray(); + + foreach (var bytes in sent) + Assert.Equal(MmsReceiveRouteAction.QueuedInformationReport, router.Route(bytes).Action); + + // The bounded observer has *all eight original* frames, not the eight + // newest; its terminal error explicitly declares the missing 72. + for (var i = 0; i < 8; i++) + { + Assert.True(slow.Reader.TryRead(out var envelope)); + Assert.Equal(sent[i], envelope.PresentationPayload); + } + + Assert.False(slow.Reader.TryRead(out _)); + var overflow = await Assert.ThrowsAsync(async () => + await slow.Reader.Completion.WaitAsync(TimeSpan.FromSeconds(2))); + Assert.Contains("exceeded its bounded capacity", overflow.Message); + + // The actual static-monitor source of truth MUST NEVER lose those + // frames when an optional observer has fallen behind. + Assert.Equal(80, router.QueuedInformationReportCount); + for (var i = 0; i < sent.Length; i++) + { + Assert.True(router.TryDequeueInformationReport(out var queued)); + Assert.Equal(sent[i], queued.PresentationPayload); + } + Assert.False(router.TryDequeueInformationReport(out _)); + } + + [Fact] + public async Task SubscriberOverflow_IsolatedFromFastSubscriberAndConfirmedMmsResponses() + { + var router = new MmsReceiveRouter(); + await using var slow = router.SubscribeInformationReports(capacity: 1); + await using var fast = router.SubscribeInformationReports(capacity: 1); + + for (var i = 1; i <= 16; i++) + { + var frame = BuildInformationReport(i); + router.Route(frame); + Assert.True(fast.Reader.TryRead(out var received)); + Assert.Equal(frame, received.PresentationPayload); + } + + router.Route(BuildConfirmedReadResponse(invokeId: 42)); + Assert.True(router.TryDequeueConfirmedResult(42, out var confirmation)); + Assert.Equal(42, confirmation.InvokeId); + Assert.False(fast.Reader.Completion.IsCompleted); + + Assert.True(slow.Reader.TryRead(out var original)); + Assert.Equal(BuildInformationReport(1), original.PresentationPayload); + await Assert.ThrowsAsync(async () => + await slow.Reader.Completion.WaitAsync(TimeSpan.FromSeconds(2))); + + // Faulting one slow observer cannot corrupt or drop canonical report + // order, even while confirmed-service responses arrive. + for (var i = 1; i <= 16; i++) + { + Assert.True(router.TryDequeueInformationReport(out var queued)); + Assert.Equal(BuildInformationReport(i), queued.PresentationPayload); + } + } + + [Fact] + public async Task SubscriberAtCapacity_DoesNotFalseFail_AndCanReceiveAfterDrain() + { + var router = new MmsReceiveRouter(); + await using var sub = router.SubscribeInformationReports(capacity: 2); + var a = BuildInformationReport(1); + var b = BuildInformationReport(2); + var c = BuildInformationReport(3); + router.Route(a); + router.Route(b); + + Assert.False(sub.Reader.Completion.IsCompleted); + Assert.True(sub.Reader.TryRead(out var first)); + Assert.Equal(a, first.PresentationPayload); + router.Route(c); + + Assert.True(sub.Reader.TryRead(out var second)); + Assert.True(sub.Reader.TryRead(out var third)); + Assert.Equal(b, second.PresentationPayload); + Assert.Equal(c, third.PresentationPayload); + Assert.False(sub.Reader.Completion.IsCompleted); + } + [Fact] public async Task WaitForInformationReportAsync_ReturnsImmediately_WhenQueueAlreadyHasReport() { @@ -175,6 +266,63 @@ await Assert.ThrowsAsync(async () => await subscription.Reader.Completion.WaitAsync(timeout.Token)); } + [Fact] + public void CanonicalCapacityExceeded_PreservesOriginalFifoAndFailsClosedUntilClear() + { + var router = new MmsReceiveRouter(canonicalReportCapacity: 3); + var original = Enumerable.Range(1, 3).Select(BuildInformationReport).ToArray(); + foreach (var report in original) + router.Route(report); + + var failure = Assert.Throws(() => router.Route(BuildInformationReport(4))); + Assert.Contains("bounded frame/byte capacity", failure.Message); + Assert.Equal(3, router.QueuedInformationReportCount); + + for (var i = 0; i < original.Length; i++) + { + Assert.True(router.TryDequeueInformationReport(out var report)); + Assert.Equal(original[i], report.PresentationPayload); + } + + Assert.Throws(() => router.TryDequeueInformationReport(out _)); + Assert.Throws(() => router.Route(BuildConfirmedReadResponse(42))); + router.Clear(); + + Assert.Equal(0, router.QueuedInformationReportCount); + router.Route(BuildInformationReport(5)); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.False(router.TryDequeueInformationReport(out _)); + } + + [Fact] + public void CanonicalByteCapacity_RejectsThirdReportWithoutUnboundedRetention() + { + var report = BuildInformationReport(1); + var router = new MmsReceiveRouter( + canonicalReportCapacity: 10, + canonicalReportByteCapacity: 2L * report.Length); + router.Route(report); + router.Route(report); + + Assert.Throws(() => router.Route(report)); + Assert.Equal(2, router.QueuedInformationReportCount); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.Throws(() => router.TryDequeueInformationReport(out _)); + } + + [Fact] + public async Task CanonicalTerminalFault_WakesWaitingMonitorWithoutFalseTimeout() + { + var router = new MmsReceiveRouter(); + var waiting = router.WaitForInformationReportAsync(TimeSpan.FromSeconds(2)); + router.FaultCanonicalInformationReports(new IOException("scripted association failure")); + + var failure = await Assert.ThrowsAsync(() => waiting); + Assert.Contains("continuity is unverified", failure.Message); + Assert.Contains("scripted association failure", failure.InnerException?.Message); + } + private static byte[] BuildConfirmedReadResponse(int invokeId) { var data = MmsDataCodec.Encode(MmsDataValue.Boolean(true)); @@ -189,12 +337,12 @@ private static byte[] BuildConfirmedReadResponse(int invokeId) return MmsPresentation.WrapIsoPresentationPData(mms); } - private static byte[] BuildInformationReport() + private static byte[] BuildInformationReport(int sequence = 0) { var variableAccessSpecification = BerWriter.EncodeTlv( 0xA1, BerWriter.EncodeTlv(0x1A, BerWriter.EncodeAscii("LD0")) - .Concat(BerWriter.EncodeTlv(0x1A, BerWriter.EncodeAscii("LLN0$Events"))) + .Concat(BerWriter.EncodeTlv(0x1A, BerWriter.EncodeAscii(sequence == 0 ? "LLN0$Events" : $"LLN0$Events{sequence:D3}"))) .ToArray()); var listOfAccessResult = BerWriter.EncodeTlv(0xA0, MmsDataCodec.Encode(MmsDataValue.Boolean(true))); var informationReport = BerWriter.EncodeTlv( diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs index f69ed5f8..c12259a0 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs @@ -192,6 +192,54 @@ public void DataObjectFallbackRoot_StopsAtLnFcDo() Assert.Equal("MX", root.FunctionalConstraint); } + + [Fact] + public void DataSetClosure_SelectsOnlyObservedMissingStMxRoots_NoDuplicateOrForeignLn() + { + var directory = BuildDirectory(); + var dataset = new MmsDataSetDirectoryResult + { + IsSuccess = true, + Members = + [ + Member("LD0", "eveGGIO1", "ST", "Ind11"), + Member("LD0", "eveGGIO1", "ST", "Ind13"), + Member("LD0", "CSWI1", "ST", "Pos"), + Member("LD0", "CSWI16", "ST", "Pos"), + Member("LD0", "MMXU1", "MX", "A"), + Member("LD0", "FOREIGN1", "CO", "Oper"), + Member("FAKE", "eveGGIO2", "ST", "Ind1") + ] + }; + var already = new[] + { + new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "MMXU1", "") + }, + new MmsVariableAccessAttributesResult + { + IsSuccess = false, + Reference = new MmsObjectReference("LD0", "CSWI1", "") + } + }; + var selected = MmsSmartTypeProbePolicy.SelectUnprobedDataSetLogicalNodeRoots( + directory, [dataset], already); + Assert.Equal(2, selected.Length); + Assert.Equal(["CSWI16", "eveGGIO1"], selected.Select(x => x.Item).ToArray()); + } + + private static MmsDataSetDirectoryMember Member( + string domain, string ln, string fc, string dataObject) => new() + { + Domain = domain, + LogicalNode = ln, + FunctionalConstraint = fc, + DataObjectPath = dataObject, + MmsItemName = ln + "$" + fc + "$" + dataObject, + UserReference = domain + "/" + ln + "." + dataObject + }; private static MmsIedModelDirectory BuildDirectory() { var snapshot = new MmsDiscoverySnapshot diff --git a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs index bdd61755..b4266050 100644 --- a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs @@ -156,6 +156,54 @@ public void Resolve_Conflicting_Duplicate_Critical_Field_Fails_Closed() field.State == SclAssociationFieldState.Ambiguous); } + [Fact] + public void Resolve_ZivStylePackedFirstSubidentifier_ProducesExactWireOid_WithoutDiscovery() + { + var remote = BuildRemote( + tsel: "0001", + ssel: "0001", + psel: "00000001", + apTitle: "41,1,999,1", + aeQualifierText: "12", + aeQualifier: 12); + + var exact = SclAssistedMmsAssociationPlanBuilder.BuildExact( + remote, MmsLocalAssociationProfile.ExistingRuntimeDefault); + Assert.True(exact.IsSuccess, string.Join(" | ", exact.Errors)); + Assert.Equal(new uint[] { 1, 1, 1, 999, 1 }, exact.Plan!.Association.Called.ApTitle); + Assert.Contains(exact.Warnings, message => message.Contains("BER-packed", StringComparison.Ordinal)); + + var result = SclAssistedMmsAssociationCandidateResolver.Resolve( + remote, MmsLocalAssociationProfile.ExistingRuntimeDefault); + Assert.True(result.IsSuccess, string.Join(" | ", result.Errors)); + Assert.Equal(SclAssociationCandidateSource.ExactScl, result.Candidates[0].Source); + Assert.Contains(result.Warnings, message => + message.Contains("41,1,999,1", StringComparison.Ordinal) && + message.Contains("1,1,1,999,1", StringComparison.Ordinal)); + + var wire = AcseAssociationRequestIdentityReader.Read( + result.Candidates[0].AssociationProfile.Payload, + result.Candidates[0].Cotp.DestinationTsap); + Assert.Equal(new uint[] { 1, 1, 1, 999, 1 }, wire.CalledApTitle); + Assert.Equal(12, wire.CalledAeQualifier); + } + + [Theory] + [InlineData("80,1,999,1")] + [InlineData("41")] + [InlineData("41,invalid,999,1")] + [InlineData("3,1,999,1")] + public void Resolve_StillRejectsUnprovenMalformedApTitles(string title) + { + var remote = BuildRemote("0001", "0001", "00000001", title, "12", 12); + var resolution = SclAssistedMmsAssociationCandidateResolver.Resolve( + remote, MmsLocalAssociationProfile.ExistingRuntimeDefault); + Assert.False(resolution.IsSuccess); + Assert.Empty(resolution.Candidates); + Assert.Contains(resolution.Fields, f => f.Name == "OSI-AP-Title" && + f.State == SclAssociationFieldState.Invalid); + } + private static SclMmsAccessPoint BuildRemote( string tsel, string ssel, diff --git a/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs b/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs index 059f3fe3..f7d86174 100644 --- a/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs +++ b/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs @@ -128,6 +128,71 @@ public async Task ReportingRuntime_RequiresExplicitGiAndEmitsStandardReasonBits( Assert.Equal((byte)0x08, reason.RawValue[1]); } + [Fact] + public async Task ReportingRuntime_ConcurrentGiBurst_IsSerializedInWireOrder() + { + var enteredFirstSend = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirstSend = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var twoReportsSent = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sent = new List(); + var activeSends = 0; + var maxConcurrentSends = 0; + + using var runtime = new MmsAssociationReportingRuntime( + () => CreateSession(), + async (payload, ct) => + { + var active = Interlocked.Increment(ref activeSends); + maxConcurrentSends = Math.Max(maxConcurrentSends, active); + try + { + if (active == 1 && sent.Count == 0) + { + enteredFirstSend.TrySetResult(); + await releaseFirstSend.Task.WaitAsync(ct); + } + + lock (sent) + { + sent.Add(payload); + if (sent.Count == 2) + twoReportsSent.TrySetResult(); + } + } + finally + { + Interlocked.Decrement(ref activeSends); + } + }); + var rcb = Assert.Single(runtime.States, state => !state.Definition.Buffered); + Assert.True(runtime.TryWriteRcbAttribute( + $"{rcb.MmsReference}$RptEna", MmsDataValue.Boolean(true), out var enabled)); + Assert.Equal(0, enabled); + Assert.True(runtime.TryWriteRcbAttribute( + $"{rcb.MmsReference}$GI", MmsDataValue.Boolean(true), out var first)); + Assert.Equal(0, first); + await enteredFirstSend.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.True(runtime.TryWriteRcbAttribute( + $"{rcb.MmsReference}$GI", MmsDataValue.Boolean(true), out var second)); + Assert.Equal(0, second); + + // If a per-report fire-and-forget Task can overtake the blocked first + // send, the observed active sender count or frame order would fail. + await Task.Delay(100); + Assert.Equal(1, Volatile.Read(ref activeSends)); + Assert.Equal(1, maxConcurrentSends); + releaseFirstSend.TrySetResult(); + await twoReportsSent.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + var decoded = sent.Select(payload => + MmsReportFrameMapper.DecodeHeader(MmsInformationReportDecoder.Decode(payload))) + .ToArray(); + Assert.Equal(new ulong?[] { 1, 2 }, decoded.Select(frame => frame.SequenceNumber)); + Assert.Equal(1, maxConcurrentSends); + Assert.All(decoded, frame => Assert.Equal(rcb.RptId, frame.ReportId)); + } + [Fact] public void ReportControlBlockLayout_UsesExpectedPackedListWidths() {