From dcab71f66b1349213fecbef39d8decf9228e58dc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 10:49:16 +0700 Subject: [PATCH 01/29] trial: migrate protocol-only trusted-SCL lane --- App.xaml.cs | 15 + Services/Iec61850ConnectionPathPolicy.cs | 29 ++ Services/NativeIec61850Client.SclAssisted.cs | 469 ++++++++++++++++++ Services/SclAssistedConnectionPreparation.cs | 189 +++++++ Services/SclSafeTrialGlobalUsings.cs | 1 + Services/SclSafeTrialRunner.cs | 288 +++++++++++ Services/VerifiedSclSourceLoader.cs | 57 +++ .../G1ControlCorrectnessRegressionTests.cs | 13 +- .../P0Build1888RecoveryRegressionTests.cs | 10 +- .../SclConnectionAuthorityTests.cs | 89 ++++ tests/ARSAS.Tests/SclSafeTrialRunnerTests.cs | 98 ++++ 11 files changed, 1251 insertions(+), 7 deletions(-) create mode 100644 Services/Iec61850ConnectionPathPolicy.cs create mode 100644 Services/NativeIec61850Client.SclAssisted.cs create mode 100644 Services/SclAssistedConnectionPreparation.cs create mode 100644 Services/SclSafeTrialGlobalUsings.cs create mode 100644 Services/SclSafeTrialRunner.cs create mode 100644 Services/VerifiedSclSourceLoader.cs create mode 100644 tests/ARSAS.Tests/SclConnectionAuthorityTests.cs create mode 100644 tests/ARSAS.Tests/SclSafeTrialRunnerTests.cs diff --git a/App.xaml.cs b/App.xaml.cs index 7a0d16197..f70562c4d 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -6,6 +6,7 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Threading; +using ArIED61850Tester.Services; namespace ArIED61850Tester; @@ -22,6 +23,20 @@ protected override void OnStartup(StartupEventArgs e) WindowsApplicationIdentity.Apply(); base.OnStartup(e); + if (SclSafeTrialCommand.IsRequested(e.Args)) + { + var trial = SclSafeTrialRunner.RunAsync(e.Args, CancellationToken.None) + .GetAwaiter() + .GetResult(); + MessageBox.Show( + $"{trial.Message}\n\nEvidence: {trial.EvidencePath}", + trial.IsSuccess ? "SCL Safe Trial — PASS" : "SCL Safe Trial — NOT PROVEN", + MessageBoxButton.OK, + trial.IsSuccess ? MessageBoxImage.Information : MessageBoxImage.Warning); + Shutdown(trial.ExitCode); + return; + } + // P2 installs one calm industrial visual system before StartupUri materializes. // Existing XAML keeps its semantic resource keys while the overlay replaces // glare-heavy white/blue surfaces with Blue Steel + Light Greige equivalents. diff --git a/Services/Iec61850ConnectionPathPolicy.cs b/Services/Iec61850ConnectionPathPolicy.cs new file mode 100644 index 000000000..dc3f9281f --- /dev/null +++ b/Services/Iec61850ConnectionPathPolicy.cs @@ -0,0 +1,29 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +public enum Iec61850ConnectionPath +{ + FullDiscovery, + CachedLiveModel, + SclAssisted +} + +/// +/// Pure routing policy. SCL design authority is distinct from a saved live-discovery +/// cache: Play/Connect uses the SCL-assisted path, while Re-scan remains an explicit +/// caller of full discovery. +/// +public static class Iec61850ConnectionPathPolicy +{ + public static Iec61850ConnectionPath SelectForFastConnect(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + if (device.HasSclDesignModel) + return Iec61850ConnectionPath.SclAssisted; + if (device.HasDiscoveryCache && device.Signals.Count > 0) + return Iec61850ConnectionPath.CachedLiveModel; + return Iec61850ConnectionPath.FullDiscovery; + } +} diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs new file mode 100644 index 000000000..963eecbaa --- /dev/null +++ b/Services/NativeIec61850Client.SclAssisted.cs @@ -0,0 +1,469 @@ +using System.Diagnostics; +using AR.Iec61850.Discovery; +using ArMms = AR.Iec61850.Mms; +using ArScl = AR.Iec61850.Scl; + +namespace ArIED61850Tester.Services; + +public sealed class SclAssistedClientConnectResult +{ + public bool IsSuccess { get; init; } + public SclAssistedConnectionPreparation Preparation { get; init; } = new(); + public ArScl.SclAssistedMmsOnlineResult? Online { get; init; } + public ArMms.InitialFcReadExecutionResult? InitialRead { get; init; } + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + public TimeSpan AssociationValidationDuration { get; init; } + public TimeSpan InitialReadDuration { get; init; } + public TimeSpan TotalDuration { get; init; } + public string Message { get; init; } = string.Empty; +} + +public sealed partial class NativeIec61850Client +{ + private readonly Dictionary _trustedSclDataSetDirectories = + new(StringComparer.OrdinalIgnoreCase); + private bool _trustedSclOnlineAuthorityActive; + + internal bool HasTrustedSclOnlineAuthority => _trustedSclOnlineAuthorityActive; + + internal IReadOnlyList TrustedSclReportControls + => _trustedSclOnlineAuthorityActive && _lastDiscovery is not null + ? _lastDiscovery.ReportInventory.ReportControls + : Array.Empty(); + + internal bool TryGetTrustedSclDataSetDirectory( + string dataSetReference, + out ArMms.MmsDataSetDirectoryResult directory) + => _trustedSclOnlineAuthorityActive && + _trustedSclDataSetDirectories.TryGetValue( + NormalizeTrustedSclReference(dataSetReference), + out directory!); + + private void ResetTrustedSclOnlineAuthority() + { + _trustedSclOnlineAuthorityActive = false; + _trustedSclDataSetDirectories.Clear(); + } + + public Task ConnectUsingSclAsync( + string sclXml, + string iedName, + string accessPointName, + string host, + int port, + CancellationToken cancellationToken) + => ConnectUsingSclAsync( + sclXml, + iedName, + accessPointName, + host, + port, + ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead, + cancellationToken); + + /// + /// Opens the trusted-SCL online path: exact SCL association identity, one Domain/VMD + /// reconciliation, then bounded sequential FC-root initial Reads. Static DataSet and + /// ReportControl authority is retained from the SCL model in memory; no live directory + /// discovery is introduced behind this path. + /// + public async Task ConnectUsingSclAsync( + string sclXml, + string iedName, + string accessPointName, + string host, + int port, + int maximumVariableReferencesPerRead, + CancellationToken cancellationToken) + { + var totalWatch = Stopwatch.StartNew(); + var associationDuration = TimeSpan.Zero; + var initialReadDuration = TimeSpan.Zero; + + ResetTrustedSclOnlineAuthority(); + await DisposeControlSessionsAsync().ConfigureAwait(false); + LastErrorMessage = string.Empty; + LastConnectionFailureKind = string.Empty; + LastConnectionTechnicalSummary = string.Empty; + LastDiscoverySummary = string.Empty; + _lastDiscovery = null; + _liveModel = null; + LastReportInventory = new NativeReportInventory(); + _reportMonitorSessions.Clear(); + _reportMonitorCoverage.Clear(); + ResetSemanticReportProjectionContext(); + Interlocked.Exchange(ref _engineCompatibilityWarningIssued, 0); + DetectedIdentity = new Iec61850DeviceIdentity(); + _host = host?.Trim() ?? string.Empty; + _port = port <= 0 ? 102 : port; + + var preparation = SclAssistedConnectionPreparationBuilder.Build( + sclXml, + iedName, + accessPointName, + _host, + _port, + maximumVariableReferencesPerRead); + if (!preparation.IsSuccess || + preparation.AssociationPlan is null || + preparation.InitialReadDesign is null || + preparation.InitialReadPlan is null) + { + LastConnectionFailureKind = "SCL_PLAN_INVALID"; + LastErrorMessage = preparation.Errors.Count == 0 + ? "SCL-assisted connection preparation failed." + : string.Join(" | ", preparation.Errors); + LastConnectionTechnicalSummary = LastErrorMessage; + totalWatch.Stop(); + return new SclAssistedClientConnectResult + { + Preparation = preparation, + Warnings = preparation.Warnings, + TotalDuration = totalWatch.Elapsed, + Message = LastErrorMessage + }; + } + + try + { + var associationWatch = Stopwatch.StartNew(); + var online = await _session.ConnectSclAssistedAsync( + preparation.AssociationPlan, + preparation.DomainInventory, + TimeSpan.FromSeconds(8), + cancellationToken).ConfigureAwait(false); + associationWatch.Stop(); + associationDuration = associationWatch.Elapsed; + + if (!online.IsCompatible) + { + LastConnectionFailureKind = online.Status.ToString(); + LastErrorMessage = online.Message; + LastConnectionTechnicalSummary = online.Domains?.Summary ?? online.Message; + await _session.DisposeAsync().ConfigureAwait(false); + totalWatch.Stop(); + return new SclAssistedClientConnectResult + { + Preparation = preparation, + Online = online, + Warnings = preparation.Warnings, + AssociationValidationDuration = associationDuration, + TotalDuration = totalWatch.Elapsed, + Message = LastErrorMessage + }; + } + + var readWatch = Stopwatch.StartNew(); + var initialRead = await _session.ExecuteInitialFcReadPlanAsync( + preparation.InitialReadPlan, + TimeSpan.FromSeconds(5), + cancellationToken).ConfigureAwait(false); + readWatch.Stop(); + initialReadDuration = readWatch.Elapsed; + + if (initialRead.Status is ArMms.InitialFcReadExecutionStatus.InvalidPlan + or ArMms.InitialFcReadExecutionStatus.SessionNotReady + or ArMms.InitialFcReadExecutionStatus.TimedOut + or ArMms.InitialFcReadExecutionStatus.TransportFailure) + { + LastConnectionFailureKind = $"INITIAL_FC_READ_{initialRead.Status}"; + LastErrorMessage = initialRead.Message; + LastConnectionTechnicalSummary = initialRead.Message; + await _session.DisposeAsync().ConfigureAwait(false); + totalWatch.Stop(); + return new SclAssistedClientConnectResult + { + Preparation = preparation, + Online = online, + InitialRead = initialRead, + Warnings = preparation.Warnings, + AssociationValidationDuration = associationDuration, + InitialReadDuration = initialReadDuration, + TotalDuration = totalWatch.Elapsed, + Message = LastErrorMessage + }; + } + + var reconciledDomains = online.Domains?.MatchedDomains + ?? preparation.DomainInventory.ExpectedDomains; + var domainVariables = reconciledDomains + .Distinct(StringComparer.Ordinal) + .ToDictionary( + domain => domain, + _ => (IReadOnlyList)Array.Empty(), + StringComparer.Ordinal); + + _liveModel = preparation.InitialReadDesign.Model; + var reportInventory = BuildTrustedSclReportInventory(_liveModel); + var dataSetDirectories = BuildTrustedSclDataSetDirectories(_liveModel); + foreach (var directory in dataSetDirectories) + { + _trustedSclDataSetDirectories[ + NormalizeTrustedSclReference(directory.DataSetReference)] = directory; + } + + _lastDiscovery = new ArMms.MmsDiscoveryResult + { + Snapshot = new ArMms.MmsDiscoverySnapshot + { + DomainVariables = domainVariables, + DomainVariableLists = new Dictionary>(StringComparer.Ordinal) + }, + ReportInventory = reportInventory, + IedDirectory = new ArMms.MmsIedModelDirectory(Array.Empty()), + DataSetDirectories = dataSetDirectories, + Summary = + "Trusted SCL authority: Domain/VMD validation and bounded FC-root snapshot completed; " + + "static DataSet/RCB authority retained locally; full live discovery intentionally skipped." + }; + LastReportInventory = ToNativeInventory(reportInventory); + _trustedSclOnlineAuthorityActive = true; + + var projectionErrors = initialRead.Batches + .Sum(batch => batch.Projections.Sum(projection => projection.Errors.Count)); + var extraDomains = online.Domains?.ExtraObservedDomains.Count ?? 0; + var partial = initialRead.Status == ArMms.InitialFcReadExecutionStatus.Partial; + LastDiscoverySummary = + $"SCL-assisted MMS: domains={reconciledDomains.Count}, extraOnlineDomains={extraDomains}, " + + $"FC-roots={initialRead.Plan.Targets.Count}, successfulReads={initialRead.SuccessfulTargetCount}, " + + $"failedReads={initialRead.FailedTargetCount}, projectedLeaves={initialRead.ProjectedLeafCount}, " + + $"projectionErrors={projectionErrors}, maxVariablesPerRead={initialRead.Plan.MaximumVariableReferencesPerRead}, " + + $"staticDataSets={dataSetDirectories.Count}, staticRCB={reportInventory.ReportControls.Count}, fullDiscovery=skipped."; + LastConnectionFailureKind = string.Empty; + LastConnectionTechnicalSummary = online.Domains?.Summary ?? online.Message; + LastErrorMessage = partial + ? "SCL-assisted association is healthy, but one or more initial FC-root values could not be read or projected. The trusted SCL model was preserved." + : string.Empty; + + var warnings = preparation.Warnings + .Concat(extraDomains > 0 + ? new[] { $"IED exposes {extraDomains} extra online MMS domain(s); they remain evidence only and do not mutate the SCL model." } + : Array.Empty()) + .Concat(partial ? new[] { LastErrorMessage } : Array.Empty()) + .Where(message => !string.IsNullOrWhiteSpace(message)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + totalWatch.Stop(); + return new SclAssistedClientConnectResult + { + IsSuccess = true, + Preparation = preparation, + Online = online, + InitialRead = initialRead, + Warnings = warnings, + AssociationValidationDuration = associationDuration, + InitialReadDuration = initialReadDuration, + TotalDuration = totalWatch.Elapsed, + Message = LastDiscoverySummary + }; + } + catch (OperationCanceledException) + { + ResetTrustedSclOnlineAuthority(); + totalWatch.Stop(); + await _session.DisposeAsync().ConfigureAwait(false); + throw; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException) + { + ResetTrustedSclOnlineAuthority(); + totalWatch.Stop(); + LastConnectionFailureKind = "SCL_ASSISTED_RUNTIME_FAILURE"; + LastErrorMessage = $"SCL-assisted MMS connection failed: {ex.GetType().Name}: {ex.Message}"; + LastConnectionTechnicalSummary = LastErrorMessage; + await _session.DisposeAsync().ConfigureAwait(false); + return new SclAssistedClientConnectResult + { + Preparation = preparation, + Warnings = preparation.Warnings, + AssociationValidationDuration = associationDuration, + InitialReadDuration = initialReadDuration, + TotalDuration = totalWatch.Elapsed, + Message = LastErrorMessage + }; + } + } + + private static ArMms.MmsReportInventory BuildTrustedSclReportInventory( + LiveIedModelDiscoveryDocument model) + { + var inventory = new ArMms.MmsReportInventory(); + foreach (var dataSet in model.DataSets) + { + var (domain, itemName) = ParseTrustedSclDataSetReference( + dataSet.Reference, + dataSet.Domain, + dataSet.LogicalNode, + dataSet.Name); + inventory.DataSets.Add(new ArMms.MmsDataSetCandidate + { + Domain = domain, + LogicalNode = dataSet.LogicalNode, + Name = dataSet.Name, + Reference = dataSet.Reference, + RawMmsName = itemName + }); + } + + foreach (var report in model.ReportControls) + { + var reference = ConcreteFirstStaticRcbReference(report.Reference); + var candidate = new ArMms.MmsReportControlCandidate + { + Domain = report.Domain, + LogicalNode = report.LogicalNode, + FunctionalConstraint = report.Buffered ? "BR" : "RP", + Name = StaticRcbLeaf(reference), + Reference = reference, + Buffered = report.Buffered, + DataSetReference = report.DataSetReference, + DataSetProbeState = ArMms.MmsRcbDataSetProbeState.NotAttempted, + DataSetProbeMessage = "Trusted SCL authority; no live DataSet probe performed.", + ReportId = report.ReportId, + ConfRev = report.ConfRev, + IntegrityPeriodMs = report.IntegrityPeriodMs, + EnabledState = report.EnabledState, + ReservationState = report.ReservationState, + ReservationTimeSeconds = report.ReservationTimeSeconds, + BufferTimeMs = report.BufferTimeMs, + TriggerOptions = report.TriggerOptions, + OptionalFields = report.OptionalFields, + Status = "TrustedSclAuthority" + }; + + candidate.Attributes.AddRange(report.Buffered + ? new[] + { + "RptID", "RptEna", "DatSet", "ConfRev", "OptFlds", "BufTm", "SqNum", + "TrgOps", "IntgPd", "GI", "PurgeBuf", "EntryID", "TimeOfEntry", "ResvTms", "Owner" + } + : new[] + { + "RptID", "RptEna", "Resv", "DatSet", "ConfRev", "OptFlds", "BufTm", + "SqNum", "TrgOps", "IntgPd", "GI", "Owner" + }); + inventory.ReportControls.Add(candidate); + } + + return inventory; + } + + private static IReadOnlyList BuildTrustedSclDataSetDirectories( + LiveIedModelDiscoveryDocument model) + => model.DataSets + .Select(dataSet => + { + var (domain, itemName) = ParseTrustedSclDataSetReference( + dataSet.Reference, + dataSet.Domain, + dataSet.LogicalNode, + dataSet.Name); + var members = dataSet.Members + .OrderBy(member => member.Index) + .Select(member => BuildTrustedSclDataSetMember(member, domain)) + .ToArray(); + return new ArMms.MmsDataSetDirectoryResult + { + IsSuccess = members.Length > 0, + DataSetReference = dataSet.Reference, + Domain = domain, + DataSetMmsName = itemName, + IsDeletable = dataSet.IsDeletable, + Members = members, + Message = + $"Trusted SCL DataSet authority: {dataSet.Reference} has {members.Length} ordered member(s); no network directory request was sent." + }; + }) + .ToArray(); + + private static ArMms.MmsDataSetDirectoryMember BuildTrustedSclDataSetMember( + LiveIedDataSetMemberModel member, + string fallbackDomain) + { + var mmsReference = member.MmsReference?.Trim() ?? string.Empty; + var slash = mmsReference.IndexOf('/'); + var domain = slash > 0 ? mmsReference[..slash] : fallbackDomain; + var itemName = slash > 0 && slash + 1 < mmsReference.Length + ? mmsReference[(slash + 1)..] + : BuildMmsItemNameFromUserReference(member.Reference, member.FunctionalConstraint); + var tokens = itemName.Split('$', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var logicalNode = tokens.FirstOrDefault() ?? string.Empty; + var dataObjectPath = tokens.Length > 2 + ? string.Join('.', tokens.Skip(2)) + : string.Empty; + + return new ArMms.MmsDataSetDirectoryMember + { + Domain = domain, + MmsItemName = itemName, + UserReference = member.Reference, + FunctionalConstraint = member.FunctionalConstraint, + LogicalNode = logicalNode, + DataObjectPath = dataObjectPath, + Source = "TrustedScl", + Confidence = 100 + }; + } + + private static (string Domain, string ItemName) ParseTrustedSclDataSetReference( + string reference, + string fallbackDomain, + string logicalNode, + string name) + { + var normalized = reference?.Trim() ?? string.Empty; + var slash = normalized.IndexOf('/'); + var domain = slash > 0 ? normalized[..slash] : fallbackDomain; + var tail = slash > 0 && slash + 1 < normalized.Length + ? normalized[(slash + 1)..] + : string.Empty; + var itemName = string.IsNullOrWhiteSpace(tail) + ? $"{(string.IsNullOrWhiteSpace(logicalNode) ? "LLN0" : logicalNode)}${name}" + : tail.Replace('.', '$'); + if (!itemName.Contains('$', StringComparison.Ordinal)) + itemName = $"LLN0${itemName}"; + return (domain, itemName); + } + + private static string BuildMmsItemNameFromUserReference(string reference, string functionalConstraint) + { + var normalized = reference?.Trim() ?? string.Empty; + var slash = normalized.IndexOf('/'); + var tail = slash >= 0 && slash + 1 < normalized.Length + ? normalized[(slash + 1)..] + : normalized; + var parts = tail.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 0) + return string.Empty; + var logicalNode = parts[0]; + var dataPath = parts.Length > 1 ? string.Join('$', parts.Skip(1)) : string.Empty; + return string.IsNullOrWhiteSpace(dataPath) + ? $"{logicalNode}${functionalConstraint}" + : $"{logicalNode}${functionalConstraint}${dataPath}"; + } + + private static string ConcreteFirstStaticRcbReference(string reference) + { + var normalized = reference?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(normalized)) + return string.Empty; + + var separator = Math.Max(normalized.LastIndexOf('$'), normalized.LastIndexOf('.')); + var leaf = separator >= 0 ? normalized[(separator + 1)..] : normalized; + return leaf.Length > 0 && !char.IsDigit(leaf[^1]) + ? normalized + "01" + : normalized; + } + + private static string StaticRcbLeaf(string reference) + { + var separator = Math.Max(reference.LastIndexOf('$'), reference.LastIndexOf('.')); + return separator >= 0 && separator + 1 < reference.Length + ? reference[(separator + 1)..] + : reference; + } + + private static string NormalizeTrustedSclReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); +} diff --git a/Services/SclAssistedConnectionPreparation.cs b/Services/SclAssistedConnectionPreparation.cs new file mode 100644 index 000000000..e37b849e9 --- /dev/null +++ b/Services/SclAssistedConnectionPreparation.cs @@ -0,0 +1,189 @@ +using System.Xml; +using ArMms = AR.Iec61850.Mms; +using ArScl = AR.Iec61850.Scl; + +namespace ArIED61850Tester.Services; + +public sealed class SclAssistedConnectionPreparation +{ + public ArScl.SclAssistedMmsAssociationPlan? AssociationPlan { get; init; } + public ArScl.SclMmsDomainInventory DomainInventory { get; init; } = new(); + public ArScl.SclInitialFcReadDesign? InitialReadDesign { get; init; } + public ArMms.InitialFcReadPlan? InitialReadPlan { get; init; } + public IReadOnlyList Errors { get; init; } = Array.Empty(); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + public bool IsSuccess => + Errors.Count == 0 && + AssociationPlan is not null && + DomainInventory.IsSuccess && + InitialReadDesign?.IsSuccess == true && + InitialReadPlan?.IsValid == true; +} + +/// +/// Pure Step-5 orchestration preparation. It converts one trusted SCL IED/AccessPoint +/// plus the operator-bound TCP endpoint into the exact ARIEC61850 association/domain/ +/// initial-read contracts. It performs no socket I/O and never falls back to discovery. +/// +public static class SclAssistedConnectionPreparationBuilder +{ + public static SclAssistedConnectionPreparation Build( + string sclXml, + string iedName, + string accessPointName, + string host, + int port, + int maximumVariableReferencesPerRead = ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead) + { + var errors = new List(); + var warnings = new List(); + var normalizedHost = (host ?? string.Empty).Trim(); + var normalizedIed = (iedName ?? string.Empty).Trim(); + var normalizedAccessPoint = (accessPointName ?? string.Empty).Trim(); + var normalizedPort = port <= 0 ? 102 : port; + + if (string.IsNullOrWhiteSpace(sclXml)) + errors.Add("SCL XML is empty."); + if (string.IsNullOrWhiteSpace(normalizedIed)) + errors.Add("An exact SCL IED name is required."); + if (string.IsNullOrWhiteSpace(normalizedAccessPoint)) + errors.Add("An exact SCL AccessPoint name is required."); + if (string.IsNullOrWhiteSpace(normalizedHost)) + errors.Add("A TCP endpoint is required before SCL-assisted connect."); + if (normalizedPort is < 1 or > 65535) + errors.Add($"TCP port must be in 1..65535; received {normalizedPort}."); + if (maximumVariableReferencesPerRead is < 1 or > ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead) + { + errors.Add( + $"Initial Read batch size must be in 1..{ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead}; received {maximumVariableReferencesPerRead}."); + } + + if (errors.Count > 0) + return Fail(errors, warnings); + + ArScl.SclMmsAssociationProfileSet profileSet; + try + { + profileSet = ArScl.SclMmsAssociationProfileReader.Read(sclXml); + } + catch (XmlException ex) + { + errors.Add($"SCL XML is malformed: {ex.Message}"); + return Fail(errors, warnings); + } + catch (Exception ex) when (ex is InvalidDataException or ArgumentException or InvalidOperationException) + { + errors.Add($"SCL MMS communication profile could not be read: {ex.GetType().Name}: {ex.Message}"); + return Fail(errors, warnings); + } + + warnings.AddRange(profileSet.Warnings); + var matches = profileSet.AccessPoints + .Where(profile => + string.Equals(profile.IedName, normalizedIed, StringComparison.Ordinal) && + string.Equals(profile.AccessPointName, normalizedAccessPoint, StringComparison.Ordinal)) + .ToArray(); + + if (matches.Length != 1) + { + errors.Add(matches.Length == 0 + ? $"SCL Communication has no exact ConnectedAP for IED '{normalizedIed}' / AccessPoint '{normalizedAccessPoint}'." + : $"SCL Communication has {matches.Length} exact ConnectedAP entries for IED '{normalizedIed}' / AccessPoint '{normalizedAccessPoint}'; association identity is ambiguous."); + return Fail(errors, warnings); + } + + var sclRemote = matches[0]; + var sclHost = (sclRemote.Endpoint.IpAddress ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(sclHost)) + { + warnings.Add($"SCL ConnectedAP has no IP address; using the explicit endpoint binding '{normalizedHost}'."); + } + else if (!string.Equals(sclHost, normalizedHost, StringComparison.OrdinalIgnoreCase)) + { + warnings.Add($"SCL IP '{sclHost}' differs from the explicit endpoint binding '{normalizedHost}'; TCP uses the explicit binding while OSI association identity remains SCL-derived."); + } + + // TCP endpoint binding is an application-level choice. Preserve every called-side + // OSI identity value from SCL and change only the network endpoint presented to + // the pure ARIEC association planner. + var effectiveRemote = new ArScl.SclMmsAccessPoint + { + IedName = sclRemote.IedName, + AccessPointName = sclRemote.AccessPointName, + SubNetworkName = sclRemote.SubNetworkName, + SubNetworkType = sclRemote.SubNetworkType, + Endpoint = new ArScl.SclMmsEndpoint + { + IpAddress = normalizedHost, + IpSubnet = sclRemote.Endpoint.IpSubnet, + IpGateway = sclRemote.Endpoint.IpGateway + }, + Association = sclRemote.Association, + Parameters = sclRemote.Parameters + }; + + var association = ArScl.SclAssistedMmsAssociationPlanBuilder.BuildExact( + effectiveRemote, + ArScl.MmsLocalAssociationProfile.SclInteroperabilityDefault); + warnings.AddRange(association.Warnings); + if (!association.IsSuccess || association.Plan is null) + { + errors.AddRange(association.Errors); + return Fail(errors, warnings); + } + + var runtimePlan = new ArScl.SclAssistedMmsAssociationPlan + { + Host = normalizedHost, + Port = normalizedPort, + IedName = association.Plan.IedName, + AccessPointName = association.Plan.AccessPointName, + LocalProfileName = association.Plan.LocalProfileName, + Cotp = association.Plan.Cotp, + Association = association.Plan.Association, + CotpConnectRequest = association.Plan.CotpConnectRequest, + SessionPresentationAcseMmsRequest = association.Plan.SessionPresentationAcseMmsRequest + }; + + var domains = ArScl.SclMmsDomainInventoryReader.Read(sclXml, normalizedIed, normalizedAccessPoint); + warnings.AddRange(domains.Warnings); + if (!domains.IsSuccess) + errors.AddRange(domains.Errors); + + var design = ArScl.SclInitialFcReadDesignBuilder.Read(sclXml, normalizedIed, normalizedAccessPoint); + warnings.AddRange(design.Warnings); + if (!design.IsSuccess) + errors.AddRange(design.Errors); + + ArMms.InitialFcReadPlan? initialReadPlan = null; + if (design.IsSuccess && domains.IsSuccess) + { + initialReadPlan = ArMms.InitialFcReadPlanner.FromSclModel( + design.Model, + domains.ExpectedDomains, + maximumVariableReferencesPerRead); + warnings.AddRange(initialReadPlan.Warnings); + if (!initialReadPlan.IsValid) + errors.AddRange(initialReadPlan.Errors); + } + + return new SclAssistedConnectionPreparation + { + AssociationPlan = runtimePlan, + DomainInventory = domains, + InitialReadDesign = design, + InitialReadPlan = initialReadPlan, + Errors = errors.Distinct(StringComparer.Ordinal).ToArray(), + Warnings = warnings.Distinct(StringComparer.Ordinal).ToArray() + }; + } + + private static SclAssistedConnectionPreparation Fail( + IReadOnlyCollection errors, + IReadOnlyCollection warnings) + => new() + { + Errors = errors.Where(message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal).ToArray(), + Warnings = warnings.Where(message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal).ToArray() + }; +} diff --git a/Services/SclSafeTrialGlobalUsings.cs b/Services/SclSafeTrialGlobalUsings.cs new file mode 100644 index 000000000..e662eefc9 --- /dev/null +++ b/Services/SclSafeTrialGlobalUsings.cs @@ -0,0 +1 @@ +global using ArIED61850Tester.Models; diff --git a/Services/SclSafeTrialRunner.cs b/Services/SclSafeTrialRunner.cs new file mode 100644 index 000000000..4b8490600 --- /dev/null +++ b/Services/SclSafeTrialRunner.cs @@ -0,0 +1,288 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text.Json; +using System.Xml.Linq; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +public sealed record SclSafeTrialCommand( + string SclPath, + string IedName, + string AccessPointName, + string Host, + int Port, + int MaximumVariableReferencesPerRead, + string EvidencePath) +{ + public const string Switch = "--scl-safe-trial"; + public const string SingleReferenceSwitch = "--scl-safe-trial-single"; + + public static bool IsRequested(IReadOnlyList args) + => args.Any(argument => + string.Equals(argument, Switch, StringComparison.OrdinalIgnoreCase) || + string.Equals(argument, SingleReferenceSwitch, StringComparison.OrdinalIgnoreCase)); + + public static bool TryParse(IReadOnlyList args, out SclSafeTrialCommand? command, out string error) + { + command = null; + error = string.Empty; + if (args.Count == 0 || + (!string.Equals(args[0], Switch, StringComparison.OrdinalIgnoreCase) && + !string.Equals(args[0], SingleReferenceSwitch, StringComparison.OrdinalIgnoreCase))) + { + error = $"Expected {Switch} or {SingleReferenceSwitch} as the first argument."; + return false; + } + + if (args.Count < 5) + { + error = + $"Usage: ARSAS.exe {Switch} [port] [evidence JSON path]. " + + $"Use {SingleReferenceSwitch} with the same arguments for a controlled one-variable-per-Read interoperability trial."; + return false; + } + + var sclPath = Path.GetFullPath(args[1]); + var iedName = (args[2] ?? string.Empty).Trim(); + var accessPointName = (args[3] ?? string.Empty).Trim(); + var host = (args[4] ?? string.Empty).Trim(); + var port = 102; + if (args.Count >= 6 && (!int.TryParse(args[5], out port) || port is < 1 or > 65535)) + { + error = $"Invalid MMS TCP port '{args[5]}'; expected 1..65535."; + return false; + } + + if (string.IsNullOrWhiteSpace(iedName) || string.IsNullOrWhiteSpace(accessPointName) || string.IsNullOrWhiteSpace(host)) + { + error = "IED name, AccessPoint name and host/IP are required."; + return false; + } + + var maximumVariableReferencesPerRead = string.Equals( + args[0], + SingleReferenceSwitch, + StringComparison.OrdinalIgnoreCase) + ? 1 + : ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead; + + var evidencePath = args.Count >= 7 && !string.IsNullOrWhiteSpace(args[6]) + ? Path.GetFullPath(args[6]) + : Path.Combine( + Path.GetTempPath(), + $"ARSAS-SCL-Trial-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{maximumVariableReferencesPerRead}ref-{Guid.NewGuid():N}.json"); + + command = new SclSafeTrialCommand( + sclPath, + iedName, + accessPointName, + host, + port, + maximumVariableReferencesPerRead, + evidencePath); + return true; + } +} + +public sealed record SclSafeTrialRunResult( + int ExitCode, + bool IsSuccess, + string Message, + string EvidencePath); + +/// +/// Read-only laboratory entry point for physically validating the SCL-assisted MMS path. +/// It performs association, Domain/VMD reconciliation and bounded initial FC-root Reads only. +/// The process exits immediately afterwards, so reporting, control, writes and hidden full +/// discovery cannot be entered by this trial path. +/// +public static class SclSafeTrialRunner +{ + public static async Task RunAsync( + IReadOnlyList args, + CancellationToken cancellationToken = default) + { + if (!SclSafeTrialCommand.TryParse(args, out var command, out var parseError) || command is null) + return await WriteInputFailureAsync(args, parseError, cancellationToken).ConfigureAwait(false); + + var stopwatch = Stopwatch.StartNew(); + string sourceSha256 = string.Empty; + SclAssistedClientConnectResult? result = null; + Iec61850DeviceDiagnosticSnapshot? diagnostic = null; + string message; + var exitCode = 0; + + try + { + if (!File.Exists(command.SclPath)) + throw new FileNotFoundException("SCL/CID source file was not found.", command.SclPath); + + var sourceBytes = await File.ReadAllBytesAsync(command.SclPath, cancellationToken).ConfigureAwait(false); + sourceSha256 = Convert.ToHexString(SHA256.HashData(sourceBytes)).ToLowerInvariant(); + + // Let an XML parser honor the document encoding and normalize only the in-memory + // representation passed to the pure SCL planners. The evidence hash remains over + // the exact source bytes selected by the operator. + var document = XDocument.Load(command.SclPath, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo); + var sclXml = document.ToString(SaveOptions.DisableFormatting); + + var client = new NativeIec61850Client(); + result = await client.ConnectUsingSclAsync( + sclXml, + command.IedName, + command.AccessPointName, + command.Host, + command.Port, + command.MaximumVariableReferencesPerRead, + cancellationToken).ConfigureAwait(false); + + diagnostic = client.CaptureDiagnosticSnapshot("SCL safe trial"); + message = result.Message; + exitCode = result.IsSuccess ? 0 : 31; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + message = "SCL safe trial was cancelled by the operator."; + exitCode = 32; + } + catch (Exception ex) + { + message = $"SCL safe trial failed before completion: {ex.GetType().Name}: {ex.Message}"; + exitCode = 33; + } + finally + { + stopwatch.Stop(); + } + + var evidence = new + { + schema = "arsas-scl-safe-trial-v2", + capturedAtUtc = DateTimeOffset.UtcNow, + safety = new + { + readOnly = true, + fullDiscoveryAllowed = false, + writesAllowed = false, + controlAllowed = false, + reportEnableAllowed = false, + dynamicDataSetAllowed = false, + automaticReadFallbackAllowed = false + }, + source = new + { + path = command.SclPath, + sha256 = sourceSha256, + iedName = command.IedName, + accessPointName = command.AccessPointName, + host = command.Host, + port = command.Port + }, + trialMode = new + { + maximumVariableReferencesPerRead = command.MaximumVariableReferencesPerRead, + label = command.MaximumVariableReferencesPerRead == 1 + ? "single-reference-control" + : "bounded-multi-reference" + }, + timing = new + { + processTotalMilliseconds = stopwatch.Elapsed.TotalMilliseconds, + connectionTotalMilliseconds = result?.TotalDuration.TotalMilliseconds ?? 0d, + associationAndDomainValidationMilliseconds = result?.AssociationValidationDuration.TotalMilliseconds ?? 0d, + initialReadMilliseconds = result?.InitialReadDuration.TotalMilliseconds ?? 0d + }, + result = new + { + success = result?.IsSuccess == true, + exitCode, + message, + warnings = result?.Warnings ?? Array.Empty(), + preparationErrors = result?.Preparation.Errors ?? Array.Empty(), + preparationWarnings = result?.Preparation.Warnings ?? Array.Empty(), + association = result?.Online is null ? null : new + { + status = result.Online.Status.ToString(), + result.Online.AssociationSucceeded, + result.Online.DomainInventorySucceeded, + result.Online.SessionRemainsOpen, + result.Online.Message + }, + domains = result?.Online?.Domains is null ? null : new + { + expected = result.Online.Domains.ExpectedDomains, + observed = result.Online.Domains.ObservedDomains, + matched = result.Online.Domains.MatchedDomains, + missing = result.Online.Domains.MissingExpectedDomains, + extra = result.Online.Domains.ExtraObservedDomains, + result.Online.Domains.IsCompatible, + result.Online.Domains.IsExactMatch, + result.Online.Domains.Summary + }, + initialRead = result?.InitialRead is null ? null : new + { + status = result.InitialRead.Status.ToString(), + result.InitialRead.Message, + result.InitialRead.SuccessfulTargetCount, + result.InitialRead.FailedTargetCount, + result.InitialRead.ProjectedLeafCount, + maximumVariableReferencesPerRead = result.InitialRead.Plan.MaximumVariableReferencesPerRead, + maximumOutstandingReads = result.InitialRead.Plan.MaximumOutstandingReads, + targetCount = result.InitialRead.Plan.Targets.Count, + batchCount = result.InitialRead.Plan.Batches.Count, + batches = result.InitialRead.Batches.Select(batch => new + { + batch.BatchIndex, + targetCount = batch.Targets.Count, + references = batch.Targets.Select(target => target.MmsReference).ToArray(), + successCount = batch.Read.Results.Count(item => item.IsSuccess), + failureCount = batch.Read.Results.Count(item => !item.IsSuccess), + projectionErrorCount = batch.Projections.Sum(item => item.Errors.Count) + }).ToArray() + }, + diagnostic + } + }; + + await WriteEvidenceAsync(command.EvidencePath, evidence, cancellationToken).ConfigureAwait(false); + return new SclSafeTrialRunResult(exitCode, result?.IsSuccess == true, message, command.EvidencePath); + } + + private static async Task WriteInputFailureAsync( + IReadOnlyList args, + string message, + CancellationToken cancellationToken) + { + var path = Path.Combine( + Path.GetTempPath(), + $"ARSAS-SCL-Trial-Invalid-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.json"); + var evidence = new + { + schema = "arsas-scl-safe-trial-v2", + capturedAtUtc = DateTimeOffset.UtcNow, + success = false, + exitCode = 30, + message, + arguments = args.ToArray() + }; + await WriteEvidenceAsync(path, evidence, cancellationToken).ConfigureAwait(false); + return new SclSafeTrialRunResult(30, false, message, path); + } + + private static async Task WriteEvidenceAsync( + string path, + object evidence, + CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + var json = JsonSerializer.Serialize(evidence, new JsonSerializerOptions + { + WriteIndented = true + }); + await File.WriteAllTextAsync(path, json, cancellationToken).ConfigureAwait(false); + } +} diff --git a/Services/VerifiedSclSourceLoader.cs b/Services/VerifiedSclSourceLoader.cs new file mode 100644 index 000000000..2ca0d3808 --- /dev/null +++ b/Services/VerifiedSclSourceLoader.cs @@ -0,0 +1,57 @@ +using System.Security.Cryptography; +using System.Xml.Linq; + +namespace ArIED61850Tester.Services; + +public sealed record VerifiedSclSource( + string FullPath, + string Sha256, + string Xml); + +/// +/// Loads exactly the source bytes previously accepted by the SCL workspace. A missing +/// file or SHA-256 mismatch is a hard stop: online SCL-assisted connect must never use +/// a silently edited design file or fall back to live discovery without the operator. +/// +public static class VerifiedSclSourceLoader +{ + public static async Task LoadAsync( + string sourcePath, + string expectedSha256, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(sourcePath)) + throw new InvalidDataException("SCL source path is empty. Re-open the trusted SCL/CID before connecting."); + if (string.IsNullOrWhiteSpace(expectedSha256)) + throw new InvalidDataException("SCL source SHA-256 is missing. Re-open the trusted SCL/CID before connecting."); + + var fullPath = Path.GetFullPath(sourcePath); + if (!File.Exists(fullPath)) + throw new FileNotFoundException("The trusted SCL/CID source file is no longer available. Re-open it before connecting.", fullPath); + + var bytes = await File.ReadAllBytesAsync(fullPath, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + var actualSha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + var expected = NormalizeSha256(expectedSha256); + if (!string.Equals(actualSha256, expected, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Trusted SCL/CID changed after import. Expected SHA-256 {expected}, actual {actualSha256}. Re-open the file so the design authority is explicit."); + } + + using var stream = new MemoryStream(bytes, writable: false); + var document = XDocument.Load(stream, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo); + return new VerifiedSclSource( + fullPath, + actualSha256, + document.ToString(SaveOptions.DisableFormatting)); + } + + private static string NormalizeSha256(string value) + { + var normalized = value.Trim().Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant(); + if (normalized.Length != 64 || normalized.Any(character => !Uri.IsHexDigit(character))) + throw new InvalidDataException($"Invalid SCL SHA-256 evidence '{value}'."); + return normalized; + } +} diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index a8662812b..ab82b5bfe 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -19,11 +19,14 @@ public void EngineLock_PreservesExactG1FieldProvenAncestryAcrossReviewedPinAdvan json.GetProperty("sourcePullRequest").GetInt32() >= 95, "A reviewed post-G2.4 engine pin must retain the field-proven G1/G1.1 ancestry contract."); - var purpose = json.GetProperty("purpose").GetString() ?? string.Empty; + var baseline = json.GetProperty("fieldProvenBaseline"); + Assert.Equal("11ab2304482600c19ba979f4fc9021ddb46b9af9", baseline.GetProperty("commit").GetString()); + Assert.Equal(111, baseline.GetProperty("sourcePullRequest").GetInt32()); + var purpose = baseline.GetProperty("purpose").GetString() ?? string.Empty; - // Engine consumers may advance the immutable pin for a proven missing capability, - // but the field-proven G1/G2.3/P0/P1 ancestry and all reporting/control safety - // statements must remain explicit in the lock provenance. + // Engine consumers may advance the immutable trial pin for a proven missing capability, + // but the structured field-proven baseline must retain the G1/G2.3/P0/P1 ancestry and + // all reporting/control safety statements verbatim. Assert.Contains("a18e550d07f7bbe4ff7753c180b02615075f6292", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("signed primitive constraints", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("ordered SBO/SBOw-to-Operate wire evidence", purpose, StringComparison.OrdinalIgnoreCase); @@ -151,4 +154,4 @@ private static string RepoRoot() } throw new DirectoryNotFoundException("ARSAS repository root not found."); } -} +} \ No newline at end of file diff --git a/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs b/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs index 4b12550a1..d4a1abeee 100644 --- a/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs +++ b/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs @@ -5,12 +5,18 @@ namespace ARSAS.Tests; public sealed class P0Build1888RecoveryRegressionTests { [Fact] - public void P0_KeepsExactArIec61850GoldenPin() + public void P0_KeepsExactArIec61850GoldenBaselineAcrossReviewedTrialPin() { using var document = JsonDocument.Parse(File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json"))); + var root = document.RootElement; + Assert.Matches("^[0-9a-f]{40}$", root.GetProperty("commit").GetString() ?? string.Empty); + Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 125); + + var baseline = root.GetProperty("fieldProvenBaseline"); Assert.Equal( "11ab2304482600c19ba979f4fc9021ddb46b9af9", - document.RootElement.GetProperty("commit").GetString()); + baseline.GetProperty("commit").GetString()); + Assert.Equal(111, baseline.GetProperty("sourcePullRequest").GetInt32()); } [Fact] diff --git a/tests/ARSAS.Tests/SclConnectionAuthorityTests.cs b/tests/ARSAS.Tests/SclConnectionAuthorityTests.cs new file mode 100644 index 000000000..6d5a02346 --- /dev/null +++ b/tests/ARSAS.Tests/SclConnectionAuthorityTests.cs @@ -0,0 +1,89 @@ +using System.Security.Cryptography; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SclConnectionAuthorityTests +{ + [Fact] + public void ConnectionPolicy_PrefersSclAuthorityOverDiscoveryCache() + { + var device = new Iec61850MonitorDevice + { + SclSourceSha256 = new string('a', 64), + HasDiscoveryCache = true + }; + device.Signals.Add(new SignalDefinition { Name = "stVal" }); + + Assert.Equal( + Iec61850ConnectionPath.SclAssisted, + Iec61850ConnectionPathPolicy.SelectForFastConnect(device)); + } + + [Fact] + public void ConnectionPolicy_UsesCachedLiveModelOnlyWithoutSclAuthority() + { + var device = new Iec61850MonitorDevice { HasDiscoveryCache = true }; + device.Signals.Add(new SignalDefinition { Name = "stVal" }); + + Assert.Equal( + Iec61850ConnectionPath.CachedLiveModel, + Iec61850ConnectionPathPolicy.SelectForFastConnect(device)); + } + + [Fact] + public void ConnectionPolicy_RequiresFullDiscoveryWithoutTrustedModel() + { + var device = new Iec61850MonitorDevice(); + + Assert.Equal( + Iec61850ConnectionPath.FullDiscovery, + Iec61850ConnectionPathPolicy.SelectForFastConnect(device)); + } + + [Fact] + public async Task VerifiedLoader_AcceptsExactImportedSourceBytes() + { + var path = Path.Combine(Path.GetTempPath(), $"arsas-scl-{Guid.NewGuid():N}.cid"); + const string xml = ""; + try + { + await File.WriteAllTextAsync(path, xml); + var bytes = await File.ReadAllBytesAsync(path); + var sha = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + var verified = await VerifiedSclSourceLoader.LoadAsync(path, sha); + + Assert.Equal(sha, verified.Sha256); + Assert.Equal(Path.GetFullPath(path), verified.FullPath); + Assert.Contains("IED01", verified.Xml, StringComparison.Ordinal); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task VerifiedLoader_RejectsSourceChangedAfterImport() + { + var path = Path.Combine(Path.GetTempPath(), $"arsas-scl-{Guid.NewGuid():N}.cid"); + try + { + await File.WriteAllTextAsync(path, ""); + var original = await File.ReadAllBytesAsync(path); + var expectedSha = Convert.ToHexString(SHA256.HashData(original)).ToLowerInvariant(); + await File.WriteAllTextAsync(path, ""); + + var error = await Assert.ThrowsAsync(() => + VerifiedSclSourceLoader.LoadAsync(path, expectedSha)); + + Assert.Contains("changed after import", error.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(path); + } + } +} diff --git a/tests/ARSAS.Tests/SclSafeTrialRunnerTests.cs b/tests/ARSAS.Tests/SclSafeTrialRunnerTests.cs new file mode 100644 index 000000000..5e23a1e9a --- /dev/null +++ b/tests/ARSAS.Tests/SclSafeTrialRunnerTests.cs @@ -0,0 +1,98 @@ +using AR.Iec61850.Mms; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SclSafeTrialRunnerTests +{ + [Fact] + public void CommandParser_UsesExplicitSclIdentityAndBoundedBatchDefault() + { + var source = Path.Combine(Path.GetTempPath(), "trial.cid"); + var args = new[] + { + SclSafeTrialCommand.Switch, + source, + "IED01", + "AP1", + "192.0.2.10" + }; + + Assert.True(SclSafeTrialCommand.TryParse(args, out var command, out var error), error); + Assert.NotNull(command); + Assert.Equal(Path.GetFullPath(source), command!.SclPath); + Assert.Equal("IED01", command.IedName); + Assert.Equal("AP1", command.AccessPointName); + Assert.Equal("192.0.2.10", command.Host); + Assert.Equal(102, command.Port); + Assert.Equal(MmsReadBatchCodec.MaximumVariableReferencesPerRead, command.MaximumVariableReferencesPerRead); + Assert.EndsWith(".json", command.EvidencePath, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CommandParser_SingleReferenceMode_UsesExactlyOneVariablePerRead() + { + var args = new[] + { + SclSafeTrialCommand.SingleReferenceSwitch, + "trial.cid", + "IED01", + "AP1", + "192.0.2.10" + }; + + Assert.True(SclSafeTrialCommand.TryParse(args, out var command, out var error), error); + Assert.NotNull(command); + Assert.Equal(1, command!.MaximumVariableReferencesPerRead); + } + + [Theory] + [InlineData("0")] + [InlineData("65536")] + [InlineData("not-a-port")] + public void CommandParser_RejectsInvalidMmsPort(string port) + { + var args = new[] + { + SclSafeTrialCommand.Switch, + "trial.cid", + "IED01", + "AP1", + "192.0.2.10", + port + }; + + Assert.False(SclSafeTrialCommand.TryParse(args, out var command, out var error)); + Assert.Null(command); + Assert.Contains("port", error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TrialRunner_SourceContract_HasNoDiscoveryWriteControlOrReportingEntryPoint() + { + var source = File.ReadAllText(FindRepoFile("Services/SclSafeTrialRunner.cs")); + + Assert.Contains("ConnectUsingSclAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("DiscoverAsync(", source, StringComparison.Ordinal); + Assert.DoesNotContain("DiscoverSignalsAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("WriteAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("Operate", source, StringComparison.Ordinal); + Assert.DoesNotContain("StartReportMonitor", source, StringComparison.Ordinal); + Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From 8feca6347661fab2f103e17a9b0a34471990a3c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 11:49:27 +0700 Subject: [PATCH 02/29] trial: pin canonical green SCL engine --- engines/ARIEC61850.lock.json | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 2d50a2390..f90293cc1 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,12 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9", - "sourcePullRequest": 111, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values. Physical BRCB compatibility hardening at 11ab2304482600c19ba979f4fc9021ddb46b9af9 adds a client-compatible persistent activation wrapper: when ResvTms is exposed it attempts an explicit 60-second BRCB reservation with implicit-RptEna fallback, keeps cleanup/release deterministic, and requests GI only after the persistent report session is registered." + "commit": "e41def0a2676efb8a143905798155f6bccc6f047", + "sourcePullRequest": 125, + "purpose": "Canonical protocol-only golden-wire SCL-assisted trial pin. This exact SHA is built from the immutable ARSAS field-proven engine baseline recorded below and passed the dedicated convergence CI. It adds SCL-derived association identity, Domain/VMD reconciliation, bounded sequential initial FC-root Reads, and trusted static-report activation without hidden full discovery, network DataSet-directory browsing, implicit GI, or proactive BRCB ResvTms. PR #125 is an evidence/trial lane and is not a merge authority for ARIEC main; ARSAS checks out this immutable SHA directly.", + "fieldProvenBaseline": { + "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9", + "sourcePullRequest": 111, + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values. Physical BRCB compatibility hardening at 11ab2304482600c19ba979f4fc9021ddb46b9af9 adds a client-compatible persistent activation wrapper: when ResvTms is exposed it attempts an explicit 60-second BRCB reservation with implicit-RptEna fallback, keeps cleanup/release deterministic, and requests GI only after the persistent report session is registered." + } } From e2336b5586825dbe49a1bd74bdaadef73df17670 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 11:49:54 +0700 Subject: [PATCH 03/29] trial: add trusted SCL static report adapter --- ...ec61850Client.TrustedSclStaticReporting.cs | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 Services/NativeIec61850Client.TrustedSclStaticReporting.cs diff --git a/Services/NativeIec61850Client.TrustedSclStaticReporting.cs b/Services/NativeIec61850Client.TrustedSclStaticReporting.cs new file mode 100644 index 000000000..85e60526b --- /dev/null +++ b/Services/NativeIec61850Client.TrustedSclStaticReporting.cs @@ -0,0 +1,213 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +/// +/// Golden-wire static-report adapter used only after a successful trusted-SCL online +/// connection. DataSet membership and configured RCB identity come from the exact SCL +/// source that established the association. No live DataSet-directory browse, dynamic +/// DataSet mutation, or implicit GI is permitted on this path. +/// +public sealed partial class NativeIec61850Client +{ + public async Task StartTrustedSclStaticReportMonitorAsync( + ReportControlPlan plan, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(plan); + cancellationToken.ThrowIfCancellationRequested(); + + if (!HasTrustedSclOnlineAuthority) + { + return TrustedSclStaticFailure( + plan, + "Trusted SCL online authority is not active for this MMS association.", + "TrustedSclAuthorityUnavailable"); + } + + if (!_session.IsMmsInitiated) + { + return TrustedSclStaticFailure( + plan, + $"Trusted SCL static report monitor requires an initiated MMS association. Current state: {_session.State}.", + "TransportUnavailable"); + } + + if (_reportMonitorSessions.ContainsKey(plan.PlanId)) + { + return new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = plan.PlanId, + Message = $"Trusted SCL static report monitor already active for {plan.DisplayReference}.", + ReportControlReference = plan.ReportControlReference, + DataSetReference = plan.DataSetReference, + AcquisitionLabel = $"Trusted SCL: {(plan.Buffered ? "StaticBrcb" : "StaticUrcb")}", + CoveredReferences = _reportMonitorCoverage.TryGetValue(plan.PlanId, out var existingCoverage) + ? existingCoverage + : Array.Empty() + }; + } + + if (string.IsNullOrWhiteSpace(plan.DataSetReference) || + !TryGetTrustedSclDataSetDirectory(plan.DataSetReference, out var directory) || + !directory.IsSuccess || + directory.Members.Count == 0) + { + return TrustedSclStaticFailure( + plan, + $"Trusted SCL DataSet '{plan.DataSetReference}' is missing or has no ordered members. " + + "The client refused network DataSet-directory discovery and did not arm an RCB.", + "TrustedSclDataSetUnavailable"); + } + + var configuredReference = (plan.ReportControlReference ?? string.Empty).Trim(); + var candidates = TrustedSclReportControls + .Where(candidate => + string.IsNullOrWhiteSpace(candidate.DataSetReference) || + SameStaticReference(candidate.DataSetReference, directory.DataSetReference)) + .Select(candidate => new + { + Candidate = candidate, + Rank = string.IsNullOrWhiteSpace(configuredReference) + ? 0 + : Iec61850StaticRcbReferenceMatcher.MatchRank(configuredReference, candidate.Reference) + }) + .Where(item => item.Rank != int.MaxValue) + .OrderBy(item => item.Rank) + .ThenByDescending(item => item.Candidate.Buffered) + .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (candidates.Length == 0) + { + return TrustedSclStaticFailure( + plan, + $"No trusted SCL RCB matches configured reference '{plan.ReportControlReference}' and DataSet '{directory.DataSetReference}'. " + + "No peer RCB substitution or live RCB directory discovery was attempted.", + "TrustedSclRcbUnavailable"); + } + + var bestRank = candidates[0].Rank; + var best = candidates.Where(item => item.Rank == bestRank).ToArray(); + if (best.Length != 1) + { + return TrustedSclStaticFailure( + plan, + $"Trusted SCL RCB selection is ambiguous for '{plan.ReportControlReference}': {best.Length} equally ranked candidate(s). " + + "The client failed closed without enabling any RCB.", + "TrustedSclRcbAmbiguous"); + } + + var rcb = CloneReportControlForPlanning(best[0].Candidate); + if (string.IsNullOrWhiteSpace(rcb.DataSetReference)) + rcb.DataSetReference = directory.DataSetReference; + if (!SameStaticReference(rcb.DataSetReference, directory.DataSetReference)) + { + return TrustedSclStaticFailure( + plan, + $"Trusted SCL RCB {rcb.Reference} binds DataSet '{rcb.DataSetReference}', not '{directory.DataSetReference}'. " + + "RCB activation was withheld.", + "TrustedSclRcbDataSetMismatch"); + } + + var subscription = new ArMms.MmsReportSubscriptionPlan + { + Mode = ArMms.MmsReportSubscriptionPlanMode.StaticDataSet, + Status = ArMms.MmsReportSubscriptionPlanStatus.ReadyRequiresWrite, + ReportControl = rcb, + DataSetReference = directory.DataSetReference, + Members = directory.Members, + DynamicPoints = Array.Empty(), + Steps = new[] + { + $"Use trusted SCL RCB {rcb.Reference} and DataSet {directory.DataSetReference}.", + $"Use {directory.Members.Count} ordered DataSet member(s) from the verified SCL source.", + "Install InformationReport receiver before RCB activation.", + "Primary wire sequence: whole-RCB Read, optional URCB Resv, RptEna=true, two whole-RCB readbacks.", + "BRCB ResvTms is retry-only after a real direct-RptEna rejection; GI remains off." + }, + Warnings = Array.Empty() + }; + + var coveredReferences = ExtractSubscriptionMemberReferences(subscription.Members); + var start = await RunMmsOperationAsync( + () => _session.StartStaticSclReportMonitorAsync( + subscription, + triggerGeneralInterrogation: false, + cancellationToken), + cancellationToken).ConfigureAwait(false); + + var warnings = start.Warnings + .Concat(subscription.Warnings) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (!start.IsSuccess || start.Session is null) + { + return new NativeReportMonitorStartResult + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = $"Trusted SCL static report activation failed for {plan.DisplayReference}: {start.Message}", + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + WriteStepCount = start.WriteSteps.Count, + UsedDynamicDataSet = false, + DynamicAttempted = false, + DynamicAttemptState = "NotApplicable", + FailureReason = "TrustedSclStaticActivationFailed", + ReportControlReference = plan.ReportControlReference, + DataSetReference = plan.DataSetReference, + CoveredReferences = coveredReferences, + Warnings = warnings + }; + } + + plan.ReportControlReference = start.Session.ReportControl.Reference; + plan.DataSetReference = start.Session.Plan.DataSetReference; + plan.Buffered = start.Session.ReportControl.Buffered; + plan.IsEngineAuthoritative = true; + plan.EngineAcquisitionKind = plan.Buffered ? "StaticBrcb" : "StaticUrcb"; + + _reportMonitorSessions[plan.PlanId] = start.Session; + _reportMonitorCoverage[plan.PlanId] = coveredReferences; + + return new NativeReportMonitorStartResult + { + IsSuccess = true, + PlanId = plan.PlanId, + Message = $"Trusted SCL {plan.EngineAcquisitionKind} monitor active. {start.Message}", + SubscriptionSummary = subscription.Summary, + MemberCount = subscription.Members.Count, + WriteStepCount = start.WriteSteps.Count, + UsedDynamicDataSet = false, + DynamicAttempted = false, + DynamicAttemptState = "NotApplicable", + ReportControlReference = plan.ReportControlReference, + DataSetReference = plan.DataSetReference, + AcquisitionLabel = $"Trusted SCL: {plan.EngineAcquisitionKind}", + CoveredReferences = coveredReferences, + Warnings = warnings + }; + } + + private static NativeReportMonitorStartResult TrustedSclStaticFailure( + ReportControlPlan plan, + string message, + string failureReason) + => new() + { + IsSuccess = false, + PlanId = plan.PlanId, + Message = message, + UsedDynamicDataSet = false, + DynamicAttempted = false, + DynamicAttemptState = "NotApplicable", + FailureReason = failureReason, + ReportControlReference = plan.ReportControlReference, + DataSetReference = plan.DataSetReference, + CoveredReferences = Array.Empty() + }; +} From 027f22f0b43b69e0ac98397e5bc343190e9b0fc8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 11:51:14 +0700 Subject: [PATCH 04/29] ci: apply isolated trusted SCL runtime wiring --- .../apply-scl-golden-wire-runtime.yml | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 .github/workflows/apply-scl-golden-wire-runtime.yml diff --git a/.github/workflows/apply-scl-golden-wire-runtime.yml b/.github/workflows/apply-scl-golden-wire-runtime.yml new file mode 100644 index 000000000..19c8f89d0 --- /dev/null +++ b/.github/workflows/apply-scl-golden-wire-runtime.yml @@ -0,0 +1,356 @@ +name: Apply trusted SCL golden-wire runtime wiring + +on: + push: + branches: + - trial/scl-golden-wire-v1636 + paths: + - .github/workflows/apply-scl-golden-wire-runtime.yml + +permissions: + contents: write + +jobs: + apply-runtime-wiring: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: trial/scl-golden-wire-v1636 + fetch-depth: 0 + + - name: Patch runtime and add regression contract + shell: python + run: | + from pathlib import Path + + path = Path("Services/Iec61850MonitorRuntime.cs") + text = path.read_text(encoding="utf-8") + + start = text.index(" public async Task ConnectUsingCachedModelAsync(") + end = text.index(" public async Task> StartMonitoringAsync(", start) + replacement = r''' public async Task ConnectUsingCachedModelAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken, + IProgress? progress = null) + { + ArgumentNullException.ThrowIfNull(device); + ValidateEndpoint(device); + + var connectionPath = Iec61850ConnectionPathPolicy.SelectForFastConnect(device); + if (connectionPath == Iec61850ConnectionPath.FullDiscovery) + throw new InvalidOperationException($"{device.Name} has no trusted SCL design or successful saved discovery model. Run a full discovery first."); + if (connectionPath == Iec61850ConnectionPath.CachedLiveModel && + (!device.HasDiscoveryCache || device.Signals.Count == 0)) + throw new InvalidOperationException($"{device.Name} has no successful saved discovery model. Run a full discovery first."); + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.PreparingSession, + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verifying trusted SCL/CID source…" + : "Preparing saved IEC 61850 model…", + 4d, + 1, + 4)); + + await StopDeviceAsync(device.DeviceId).ConfigureAwait(false); + var session = new DeviceSession + { + Device = device, + Client = new NativeIec61850Client() + }; + _sessions[device.DeviceId] = session; + + device.IsConnected = false; + device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Preparing verified SCL-assisted connection" + : "Preparing fast connection from saved model"); + device.Status = connectionPath == Iec61850ConnectionPath.SclAssisted + ? "SCL connecting" + : "Fast connecting"; + device.Detail = connectionPath == Iec61850ConnectionPath.SclAssisted + ? $"Opening {device.IpAddress}:{device.Port} with verified SCL association identity." + : $"Opening {device.IpAddress}:{device.Port} with the saved discovery model."; + Log("INFO", device.Name, + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Trusted SCL authority selected for Play; source SHA will be verified before any socket is opened and no discovery fallback is allowed." + : $"Fast reconnect using saved model ({device.SignalCount:N0} signals); full live discovery is skipped."); + + try + { + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.OpeningTcp, + $"Opening TCP {device.IpAddress}:{device.Port}…", + 24d, + 2, + 4)); + + connectionPath = await ConnectUsingSelectedFastPathAsync( + session, + cancellationToken, + allowCachedRetry: true).ConfigureAwait(false); + if (!session.Client.IsConnected) + { + device.Status = "Connection failed"; + device.Detail = string.IsNullOrWhiteSpace(session.Client.LastErrorMessage) + ? "The IED did not complete IEC 61850 ACSE/MMS association." + : session.Client.LastErrorMessage; + throw new InvalidOperationException(device.Detail); + } + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.AssociatingMms, + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "SCL association validated. Restoring SCL signal workspace…" + : "ACSE/MMS associated. Restoring saved signal workspace…", + 74d, + 3, + 4)); + + device.IsConnected = true; + device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verified SCL-assisted connection complete" + : "Fast connection complete"); + device.Status = "Ready"; + device.Detail = connectionPath == Iec61850ConnectionPath.SclAssisted + ? $"Connected from verified SCL: {device.SignalCount:N0} signal(s), {device.SelectedSignalCount:N0} selected. Domain/VMD validation and bounded FC-root reads completed; full discovery was skipped." + : $"Connected with saved model: {device.SignalCount:N0} signal(s), {device.SelectedSignalCount:N0} selected. Full discovery was skipped."; + device.AcquisitionMode = connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verified SCL • ready to monitor" + : "Saved model • ready to monitor"; + device.RefreshComputed(); + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.Complete, + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verified SCL online path ready for static reporting." + : "Saved model restored — ready for live values.", + 100d, + 4, + 4)); + + Log("INFO", device.Name, + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Trusted SCL connection complete. Static reporting will reuse SCL RCB/DataSet authority; no network DataSet-directory browse, dynamic DataSet mutation, or implicit GI is permitted." + : "Fast reconnect complete. Reporting setup will validate only the acquisition objects required by the selected points; the full signal scan remains cached."); + } + catch (Exception ex) + { + device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verified SCL-assisted connection failed" + : "Fast TCP/ACSE/MMS connection failed", + ex); + if (!session.Client.IsConnected) + { + device.IsConnected = false; + _sessions.TryRemove(device.DeviceId, out _); + await DisposeClientForReconnectAsync( + session.Client, + device.Name, + SmartReconnectPolicy.ClientCleanupBudget, + CancellationToken.None).ConfigureAwait(false); + } + throw; + } + finally + { + device.RefreshComputed(); + } + } + + private async Task ConnectUsingSelectedFastPathAsync( + DeviceSession session, + CancellationToken cancellationToken, + bool allowCachedRetry) + { + var device = session.Device; + var path = Iec61850ConnectionPathPolicy.SelectForFastConnect(device); + if (path == Iec61850ConnectionPath.FullDiscovery) + throw new InvalidOperationException($"{device.Name} requires full discovery; fast-connect cannot invent a model authority."); + + if (path == Iec61850ConnectionPath.SclAssisted) + { + var verified = await VerifiedSclSourceLoader.LoadAsync( + device.SclSourcePath, + device.SclSourceSha256, + cancellationToken).ConfigureAwait(false); + var result = await session.Client.ConnectUsingSclAsync( + verified.Xml, + device.SclIedName, + device.SclAccessPointName, + device.IpAddress, + device.Port, + cancellationToken).ConfigureAwait(false); + if (!result.IsSuccess || !session.Client.IsConnected) + throw new InvalidOperationException(result.Message); + + device.LiveDiscoveryModel = session.Client.LastLiveModel ?? device.SclWorkspace?.DesignModel; + Log("INFO", device.Name, + $"Verified SCL authority active: SHA256={verified.Sha256}; IED={device.SclIedName}; AP={device.SclAccessPointName}; maxReadRefs={result.Preparation.InitialReadPlan?.MaximumVariableReferencesPerRead ?? 0}."); + return path; + } + + if (allowCachedRetry) + await ConnectCachedAssociationWithRetryAsync(session, cancellationToken).ConfigureAwait(false); + else + await session.Client.ConnectAsync(device.IpAddress, device.Port, cancellationToken).ConfigureAwait(false); + return path; + } + +''' + text = text[:start] + replacement + text[end:] + + old = ''' var result = plan.IsEngineAuthoritative + ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) + : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);''' + new = ''' var result = session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority + ? await session.Client.StartTrustedSclStaticReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) + : plan.IsEngineAuthoritative + ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) + : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);''' + if old not in text: + raise SystemExit("StartReportPlansAsync seam not found") + text = text.replace(old, new, 1) + + marker = ''' private async Task> BuildReportPlansForCurrentAssociationAsync( + DeviceSession session, + IReadOnlyList legacyPlans, + CancellationToken cancellationToken) + { +''' + inject = marker + ''' if (session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority) + { + session.HybridValidation.Reset(null); + var trustedPlans = legacyPlans.Count > 0 + ? legacyPlans + : Iec61850ReportPlanner.BuildPlans( + session.Device, + session.Points.Values, + allowDynamicDataSetWrites: false); + Log("INFO", session.Device.Name, + $"Trusted SCL report planning retained {trustedPlans.Count} local static candidate(s). DataSet membership and RCB identity remain SCL-authoritative; online directory discovery and Hybrid availability probing are bypassed."); + return trustedPlans.Where(plan => !plan.AllowDynamicDataSetWrites).ToArray(); + } + +''' + if marker not in text: + raise SystemExit("BuildReportPlansForCurrentAssociationAsync seam not found") + text = text.replace(marker, inject, 1) + + old_reconnect = ''' try + { + await replacement.ConnectAsync( + session.Device.IpAddress, + session.Device.Port, + connectTimeout.Token).ConfigureAwait(false); + } +''' + new_reconnect = ''' try + { + var reconnectPath = await ConnectUsingSelectedFastPathAsync( + session, + connectTimeout.Token, + allowCachedRetry: false).ConfigureAwait(false); + Log("INFO", session.Device.Name, + reconnectPath == Iec61850ConnectionPath.SclAssisted + ? "Smart reconnect reused verified SCL authority; no cached-association or discovery fallback was attempted." + : "Smart reconnect reused the saved live-model association path."); + } +''' + if old_reconnect not in text: + raise SystemExit("TryReconnectAsync connect seam not found") + text = text.replace(old_reconnect, new_reconnect, 1) + + path.write_text(text, encoding="utf-8") + + test = r'''using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SclGoldenWireIntegrationContractTests +{ + [Fact] + public void Runtime_FastPlay_PrefersVerifiedSclWithoutDiscoveryFallback() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + var start = source.IndexOf("public async Task ConnectUsingCachedModelAsync", StringComparison.Ordinal); + var end = source.IndexOf("public async Task> StartMonitoringAsync", start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + var connect = source[start..end]; + + Assert.Contains("Iec61850ConnectionPathPolicy.SelectForFastConnect", connect, StringComparison.Ordinal); + Assert.Contains("VerifiedSclSourceLoader.LoadAsync", connect, StringComparison.Ordinal); + Assert.Contains("ConnectUsingSclAsync", connect, StringComparison.Ordinal); + Assert.Contains("allowCachedRetry: true", connect, StringComparison.Ordinal); + Assert.DoesNotContain("ConnectAndDiscoverAsync", connect, StringComparison.Ordinal); + Assert.DoesNotContain("DiscoverSignalsAsync", connect, StringComparison.Ordinal); + Assert.True( + connect.IndexOf("VerifiedSclSourceLoader.LoadAsync", StringComparison.Ordinal) < + connect.IndexOf("ConnectUsingSclAsync", StringComparison.Ordinal)); + } + + [Fact] + public void Runtime_TrustedSclStaticReporting_BypassesHybridAndLegacyStartPaths() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + Assert.Contains("session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority", source, StringComparison.Ordinal); + Assert.Contains("StartTrustedSclStaticReportMonitorAsync", source, StringComparison.Ordinal); + Assert.Contains("DataSet membership and RCB identity remain SCL-authoritative", source, StringComparison.Ordinal); + } + + [Fact] + public void TrustedSclStaticAdapter_HasGoldenWireSafetyContract() + { + var source = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.TrustedSclStaticReporting.cs")); + Assert.Contains("TryGetTrustedSclDataSetDirectory", source, StringComparison.Ordinal); + Assert.Contains("StartStaticSclReportMonitorAsync", source, StringComparison.Ordinal); + Assert.Contains("triggerGeneralInterrogation: false", source, StringComparison.Ordinal); + Assert.DoesNotContain("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal); + Assert.DoesNotContain("StartPersistentReportMonitorAsync", source, StringComparison.Ordinal); + } + + [Fact] + public void Runtime_Reconnect_ReusesSelectedAuthorityInsteadOfSilentlyDowngrading() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + var start = source.IndexOf("private async Task TryReconnectAsync", StringComparison.Ordinal); + var end = source.IndexOf("private void ScheduleReconnectRetry", start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + var reconnect = source[start..end]; + + Assert.Contains("ConnectUsingSelectedFastPathAsync", reconnect, StringComparison.Ordinal); + Assert.Contains("no cached-association or discovery fallback was attempted", reconnect, StringComparison.Ordinal); + Assert.DoesNotContain("replacement.ConnectAsync", reconnect, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} +''' + Path("tests/ARSAS.Tests/SclGoldenWireIntegrationContractTests.cs").write_text(test, encoding="utf-8") + Path(".github/workflows/apply-scl-golden-wire-runtime.yml").unlink() + + - name: Commit isolated wiring + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "trial: wire trusted SCL golden path into runtime" + git push origin HEAD:trial/scl-golden-wire-v1636 From 81fb42f2a553774fe7f38e677dd1e79ee5428080 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 11:52:44 +0700 Subject: [PATCH 05/29] ci: stage trusted SCL runtime patch script --- scripts/apply-scl-golden-wire-runtime.py | 320 +++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 scripts/apply-scl-golden-wire-runtime.py diff --git a/scripts/apply-scl-golden-wire-runtime.py b/scripts/apply-scl-golden-wire-runtime.py new file mode 100644 index 000000000..abc8166a7 --- /dev/null +++ b/scripts/apply-scl-golden-wire-runtime.py @@ -0,0 +1,320 @@ +from pathlib import Path + +runtime_path = Path("Services/Iec61850MonitorRuntime.cs") +text = runtime_path.read_text(encoding="utf-8") + +start = text.index(" public async Task ConnectUsingCachedModelAsync(") +end = text.index(" public async Task> StartMonitoringAsync(", start) +replacement = r''' public async Task ConnectUsingCachedModelAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken, + IProgress? progress = null) + { + ArgumentNullException.ThrowIfNull(device); + ValidateEndpoint(device); + + var connectionPath = Iec61850ConnectionPathPolicy.SelectForFastConnect(device); + if (connectionPath == Iec61850ConnectionPath.FullDiscovery) + throw new InvalidOperationException($"{device.Name} has no trusted SCL design or successful saved discovery model. Run a full discovery first."); + if (connectionPath == Iec61850ConnectionPath.CachedLiveModel && + (!device.HasDiscoveryCache || device.Signals.Count == 0)) + throw new InvalidOperationException($"{device.Name} has no successful saved discovery model. Run a full discovery first."); + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.PreparingSession, + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verifying trusted SCL/CID source…" + : "Preparing saved IEC 61850 model…", + 4d, + 1, + 4)); + + await StopDeviceAsync(device.DeviceId).ConfigureAwait(false); + var session = new DeviceSession + { + Device = device, + Client = new NativeIec61850Client() + }; + _sessions[device.DeviceId] = session; + + device.IsConnected = false; + device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Preparing verified SCL-assisted connection" + : "Preparing fast connection from saved model"); + device.Status = connectionPath == Iec61850ConnectionPath.SclAssisted + ? "SCL connecting" + : "Fast connecting"; + device.Detail = connectionPath == Iec61850ConnectionPath.SclAssisted + ? $"Opening {device.IpAddress}:{device.Port} with verified SCL association identity." + : $"Opening {device.IpAddress}:{device.Port} with the saved discovery model."; + Log("INFO", device.Name, + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Trusted SCL authority selected for Play; source SHA will be verified before any socket is opened and no discovery fallback is allowed." + : $"Fast reconnect using saved model ({device.SignalCount:N0} signals); full live discovery is skipped."); + + try + { + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.OpeningTcp, + $"Opening TCP {device.IpAddress}:{device.Port}…", + 24d, + 2, + 4)); + + connectionPath = await ConnectUsingSelectedFastPathAsync( + session, + cancellationToken, + allowCachedRetry: true).ConfigureAwait(false); + if (!session.Client.IsConnected) + { + device.Status = "Connection failed"; + device.Detail = string.IsNullOrWhiteSpace(session.Client.LastErrorMessage) + ? "The IED did not complete IEC 61850 ACSE/MMS association." + : session.Client.LastErrorMessage; + throw new InvalidOperationException(device.Detail); + } + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.AssociatingMms, + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "SCL association validated. Restoring SCL signal workspace…" + : "ACSE/MMS associated. Restoring saved signal workspace…", + 74d, + 3, + 4)); + + device.IsConnected = true; + device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verified SCL-assisted connection complete" + : "Fast connection complete"); + device.Status = "Ready"; + device.Detail = connectionPath == Iec61850ConnectionPath.SclAssisted + ? $"Connected from verified SCL: {device.SignalCount:N0} signal(s), {device.SelectedSignalCount:N0} selected. Domain/VMD validation and bounded FC-root reads completed; full discovery was skipped." + : $"Connected with saved model: {device.SignalCount:N0} signal(s), {device.SelectedSignalCount:N0} selected. Full discovery was skipped."; + device.AcquisitionMode = connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verified SCL • ready to monitor" + : "Saved model • ready to monitor"; + device.RefreshComputed(); + + progress?.Report(new IedDiscoveryProgress( + IedDiscoveryStage.Complete, + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verified SCL online path ready for static reporting." + : "Saved model restored — ready for live values.", + 100d, + 4, + 4)); + + Log("INFO", device.Name, + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Trusted SCL connection complete. Static reporting will reuse SCL RCB/DataSet authority; no network DataSet-directory browse, dynamic DataSet mutation, or implicit GI is permitted." + : "Fast reconnect complete. Reporting setup will validate only the acquisition objects required by the selected points; the full signal scan remains cached."); + } + catch (Exception ex) + { + device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verified SCL-assisted connection failed" + : "Fast TCP/ACSE/MMS connection failed", + ex); + if (!session.Client.IsConnected) + { + device.IsConnected = false; + _sessions.TryRemove(device.DeviceId, out _); + await DisposeClientForReconnectAsync( + session.Client, + device.Name, + SmartReconnectPolicy.ClientCleanupBudget, + CancellationToken.None).ConfigureAwait(false); + } + throw; + } + finally + { + device.RefreshComputed(); + } + } + + private async Task ConnectUsingSelectedFastPathAsync( + DeviceSession session, + CancellationToken cancellationToken, + bool allowCachedRetry) + { + var device = session.Device; + var path = Iec61850ConnectionPathPolicy.SelectForFastConnect(device); + if (path == Iec61850ConnectionPath.FullDiscovery) + throw new InvalidOperationException($"{device.Name} requires full discovery; fast-connect cannot invent a model authority."); + + if (path == Iec61850ConnectionPath.SclAssisted) + { + var verified = await VerifiedSclSourceLoader.LoadAsync( + device.SclSourcePath, + device.SclSourceSha256, + cancellationToken).ConfigureAwait(false); + var result = await session.Client.ConnectUsingSclAsync( + verified.Xml, + device.SclIedName, + device.SclAccessPointName, + device.IpAddress, + device.Port, + cancellationToken).ConfigureAwait(false); + if (!result.IsSuccess || !session.Client.IsConnected) + throw new InvalidOperationException(result.Message); + + device.LiveDiscoveryModel = session.Client.LastLiveModel ?? device.SclWorkspace?.DesignModel; + Log("INFO", device.Name, + $"Verified SCL authority active: SHA256={verified.Sha256}; IED={device.SclIedName}; AP={device.SclAccessPointName}; maxReadRefs={result.Preparation.InitialReadPlan?.MaximumVariableReferencesPerRead ?? 0}."); + return path; + } + + if (allowCachedRetry) + await ConnectCachedAssociationWithRetryAsync(session, cancellationToken).ConfigureAwait(false); + else + await session.Client.ConnectAsync(device.IpAddress, device.Port, cancellationToken).ConfigureAwait(false); + return path; + } + +''' +text = text[:start] + replacement + text[end:] + +old_start = ''' var result = plan.IsEngineAuthoritative + ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) + : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);''' +new_start = ''' var result = session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority + ? await session.Client.StartTrustedSclStaticReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) + : plan.IsEngineAuthoritative + ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) + : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);''' +if old_start not in text: + raise SystemExit("StartReportPlansAsync seam not found") +text = text.replace(old_start, new_start, 1) + +marker = ''' private async Task> BuildReportPlansForCurrentAssociationAsync( + DeviceSession session, + IReadOnlyList legacyPlans, + CancellationToken cancellationToken) + { +''' +inject = marker + ''' if (session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority) + { + session.HybridValidation.Reset(null); + var trustedPlans = legacyPlans.Count > 0 + ? legacyPlans + : Iec61850ReportPlanner.BuildPlans( + session.Device, + session.Points.Values, + allowDynamicDataSetWrites: false); + Log("INFO", session.Device.Name, + $"Trusted SCL report planning retained {trustedPlans.Count} local static candidate(s). DataSet membership and RCB identity remain SCL-authoritative; online directory discovery and Hybrid availability probing are bypassed."); + return trustedPlans.Where(plan => !plan.AllowDynamicDataSetWrites).ToArray(); + } + +''' +if marker not in text: + raise SystemExit("BuildReportPlansForCurrentAssociationAsync seam not found") +text = text.replace(marker, inject, 1) + +old_reconnect = ''' try + { + await replacement.ConnectAsync( + session.Device.IpAddress, + session.Device.Port, + connectTimeout.Token).ConfigureAwait(false); + } +''' +new_reconnect = ''' try + { + var reconnectPath = await ConnectUsingSelectedFastPathAsync( + session, + connectTimeout.Token, + allowCachedRetry: false).ConfigureAwait(false); + Log("INFO", session.Device.Name, + reconnectPath == Iec61850ConnectionPath.SclAssisted + ? "Smart reconnect reused verified SCL authority; no cached-association or discovery fallback was attempted." + : "Smart reconnect reused the saved live-model association path."); + } +''' +if old_reconnect not in text: + raise SystemExit("TryReconnectAsync connect seam not found") +text = text.replace(old_reconnect, new_reconnect, 1) + +runtime_path.write_text(text, encoding="utf-8") + +regression = r'''using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SclGoldenWireIntegrationContractTests +{ + [Fact] + public void Runtime_FastPlay_PrefersVerifiedSclWithoutDiscoveryFallback() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + var start = source.IndexOf("public async Task ConnectUsingCachedModelAsync", StringComparison.Ordinal); + var end = source.IndexOf("public async Task> StartMonitoringAsync", start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + var connect = source[start..end]; + + Assert.Contains("Iec61850ConnectionPathPolicy.SelectForFastConnect", connect, StringComparison.Ordinal); + Assert.Contains("VerifiedSclSourceLoader.LoadAsync", connect, StringComparison.Ordinal); + Assert.Contains("ConnectUsingSclAsync", connect, StringComparison.Ordinal); + Assert.Contains("allowCachedRetry: true", connect, StringComparison.Ordinal); + Assert.DoesNotContain("ConnectAndDiscoverAsync", connect, StringComparison.Ordinal); + Assert.DoesNotContain("DiscoverSignalsAsync", connect, StringComparison.Ordinal); + Assert.True( + connect.IndexOf("VerifiedSclSourceLoader.LoadAsync", StringComparison.Ordinal) < + connect.IndexOf("ConnectUsingSclAsync", StringComparison.Ordinal)); + } + + [Fact] + public void Runtime_TrustedSclStaticReporting_BypassesHybridAndLegacyStartPaths() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + Assert.Contains("session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority", source, StringComparison.Ordinal); + Assert.Contains("StartTrustedSclStaticReportMonitorAsync", source, StringComparison.Ordinal); + Assert.Contains("DataSet membership and RCB identity remain SCL-authoritative", source, StringComparison.Ordinal); + } + + [Fact] + public void TrustedSclStaticAdapter_HasGoldenWireSafetyContract() + { + var source = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.TrustedSclStaticReporting.cs")); + Assert.Contains("TryGetTrustedSclDataSetDirectory", source, StringComparison.Ordinal); + Assert.Contains("StartStaticSclReportMonitorAsync", source, StringComparison.Ordinal); + Assert.Contains("triggerGeneralInterrogation: false", source, StringComparison.Ordinal); + Assert.DoesNotContain("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal); + Assert.DoesNotContain("StartPersistentReportMonitorAsync", source, StringComparison.Ordinal); + } + + [Fact] + public void Runtime_Reconnect_ReusesSelectedAuthorityInsteadOfSilentlyDowngrading() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + var start = source.IndexOf("private async Task TryReconnectAsync", StringComparison.Ordinal); + var end = source.IndexOf("private void ScheduleReconnectRetry", start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + var reconnect = source[start..end]; + + Assert.Contains("ConnectUsingSelectedFastPathAsync", reconnect, StringComparison.Ordinal); + Assert.Contains("no cached-association or discovery fallback was attempted", reconnect, StringComparison.Ordinal); + Assert.DoesNotContain("replacement.ConnectAsync", reconnect, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} +''' +Path("tests/ARSAS.Tests/SclGoldenWireIntegrationContractTests.cs").write_text(regression, encoding="utf-8") From f4fa0a2b32aec2099a639b1f8a62a518e4ce87c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 11:52:56 +0700 Subject: [PATCH 06/29] ci: simplify trusted SCL runtime patch runner --- .../apply-scl-golden-wire-runtime.yml | 329 +----------------- 1 file changed, 4 insertions(+), 325 deletions(-) diff --git a/.github/workflows/apply-scl-golden-wire-runtime.yml b/.github/workflows/apply-scl-golden-wire-runtime.yml index 19c8f89d0..9a2cd7f3b 100644 --- a/.github/workflows/apply-scl-golden-wire-runtime.yml +++ b/.github/workflows/apply-scl-golden-wire-runtime.yml @@ -19,335 +19,14 @@ jobs: ref: trial/scl-golden-wire-v1636 fetch-depth: 0 - - name: Patch runtime and add regression contract - shell: python - run: | - from pathlib import Path - - path = Path("Services/Iec61850MonitorRuntime.cs") - text = path.read_text(encoding="utf-8") - - start = text.index(" public async Task ConnectUsingCachedModelAsync(") - end = text.index(" public async Task> StartMonitoringAsync(", start) - replacement = r''' public async Task ConnectUsingCachedModelAsync( - Iec61850MonitorDevice device, - CancellationToken cancellationToken, - IProgress? progress = null) - { - ArgumentNullException.ThrowIfNull(device); - ValidateEndpoint(device); - - var connectionPath = Iec61850ConnectionPathPolicy.SelectForFastConnect(device); - if (connectionPath == Iec61850ConnectionPath.FullDiscovery) - throw new InvalidOperationException($"{device.Name} has no trusted SCL design or successful saved discovery model. Run a full discovery first."); - if (connectionPath == Iec61850ConnectionPath.CachedLiveModel && - (!device.HasDiscoveryCache || device.Signals.Count == 0)) - throw new InvalidOperationException($"{device.Name} has no successful saved discovery model. Run a full discovery first."); - - progress?.Report(new IedDiscoveryProgress( - IedDiscoveryStage.PreparingSession, - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Verifying trusted SCL/CID source…" - : "Preparing saved IEC 61850 model…", - 4d, - 1, - 4)); - - await StopDeviceAsync(device.DeviceId).ConfigureAwait(false); - var session = new DeviceSession - { - Device = device, - Client = new NativeIec61850Client() - }; - _sessions[device.DeviceId] = session; - - device.IsConnected = false; - device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Preparing verified SCL-assisted connection" - : "Preparing fast connection from saved model"); - device.Status = connectionPath == Iec61850ConnectionPath.SclAssisted - ? "SCL connecting" - : "Fast connecting"; - device.Detail = connectionPath == Iec61850ConnectionPath.SclAssisted - ? $"Opening {device.IpAddress}:{device.Port} with verified SCL association identity." - : $"Opening {device.IpAddress}:{device.Port} with the saved discovery model."; - Log("INFO", device.Name, - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Trusted SCL authority selected for Play; source SHA will be verified before any socket is opened and no discovery fallback is allowed." - : $"Fast reconnect using saved model ({device.SignalCount:N0} signals); full live discovery is skipped."); - - try - { - progress?.Report(new IedDiscoveryProgress( - IedDiscoveryStage.OpeningTcp, - $"Opening TCP {device.IpAddress}:{device.Port}…", - 24d, - 2, - 4)); - - connectionPath = await ConnectUsingSelectedFastPathAsync( - session, - cancellationToken, - allowCachedRetry: true).ConfigureAwait(false); - if (!session.Client.IsConnected) - { - device.Status = "Connection failed"; - device.Detail = string.IsNullOrWhiteSpace(session.Client.LastErrorMessage) - ? "The IED did not complete IEC 61850 ACSE/MMS association." - : session.Client.LastErrorMessage; - throw new InvalidOperationException(device.Detail); - } - - progress?.Report(new IedDiscoveryProgress( - IedDiscoveryStage.AssociatingMms, - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "SCL association validated. Restoring SCL signal workspace…" - : "ACSE/MMS associated. Restoring saved signal workspace…", - 74d, - 3, - 4)); - - device.IsConnected = true; - device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Verified SCL-assisted connection complete" - : "Fast connection complete"); - device.Status = "Ready"; - device.Detail = connectionPath == Iec61850ConnectionPath.SclAssisted - ? $"Connected from verified SCL: {device.SignalCount:N0} signal(s), {device.SelectedSignalCount:N0} selected. Domain/VMD validation and bounded FC-root reads completed; full discovery was skipped." - : $"Connected with saved model: {device.SignalCount:N0} signal(s), {device.SelectedSignalCount:N0} selected. Full discovery was skipped."; - device.AcquisitionMode = connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Verified SCL • ready to monitor" - : "Saved model • ready to monitor"; - device.RefreshComputed(); - - progress?.Report(new IedDiscoveryProgress( - IedDiscoveryStage.Complete, - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Verified SCL online path ready for static reporting." - : "Saved model restored — ready for live values.", - 100d, - 4, - 4)); - - Log("INFO", device.Name, - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Trusted SCL connection complete. Static reporting will reuse SCL RCB/DataSet authority; no network DataSet-directory browse, dynamic DataSet mutation, or implicit GI is permitted." - : "Fast reconnect complete. Reporting setup will validate only the acquisition objects required by the selected points; the full signal scan remains cached."); - } - catch (Exception ex) - { - device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Verified SCL-assisted connection failed" - : "Fast TCP/ACSE/MMS connection failed", - ex); - if (!session.Client.IsConnected) - { - device.IsConnected = false; - _sessions.TryRemove(device.DeviceId, out _); - await DisposeClientForReconnectAsync( - session.Client, - device.Name, - SmartReconnectPolicy.ClientCleanupBudget, - CancellationToken.None).ConfigureAwait(false); - } - throw; - } - finally - { - device.RefreshComputed(); - } - } - - private async Task ConnectUsingSelectedFastPathAsync( - DeviceSession session, - CancellationToken cancellationToken, - bool allowCachedRetry) - { - var device = session.Device; - var path = Iec61850ConnectionPathPolicy.SelectForFastConnect(device); - if (path == Iec61850ConnectionPath.FullDiscovery) - throw new InvalidOperationException($"{device.Name} requires full discovery; fast-connect cannot invent a model authority."); - - if (path == Iec61850ConnectionPath.SclAssisted) - { - var verified = await VerifiedSclSourceLoader.LoadAsync( - device.SclSourcePath, - device.SclSourceSha256, - cancellationToken).ConfigureAwait(false); - var result = await session.Client.ConnectUsingSclAsync( - verified.Xml, - device.SclIedName, - device.SclAccessPointName, - device.IpAddress, - device.Port, - cancellationToken).ConfigureAwait(false); - if (!result.IsSuccess || !session.Client.IsConnected) - throw new InvalidOperationException(result.Message); - - device.LiveDiscoveryModel = session.Client.LastLiveModel ?? device.SclWorkspace?.DesignModel; - Log("INFO", device.Name, - $"Verified SCL authority active: SHA256={verified.Sha256}; IED={device.SclIedName}; AP={device.SclAccessPointName}; maxReadRefs={result.Preparation.InitialReadPlan?.MaximumVariableReferencesPerRead ?? 0}."); - return path; - } - - if (allowCachedRetry) - await ConnectCachedAssociationWithRetryAsync(session, cancellationToken).ConfigureAwait(false); - else - await session.Client.ConnectAsync(device.IpAddress, device.Port, cancellationToken).ConfigureAwait(false); - return path; - } - -''' - text = text[:start] + replacement + text[end:] - - old = ''' var result = plan.IsEngineAuthoritative - ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) - : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);''' - new = ''' var result = session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority - ? await session.Client.StartTrustedSclStaticReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) - : plan.IsEngineAuthoritative - ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) - : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);''' - if old not in text: - raise SystemExit("StartReportPlansAsync seam not found") - text = text.replace(old, new, 1) - - marker = ''' private async Task> BuildReportPlansForCurrentAssociationAsync( - DeviceSession session, - IReadOnlyList legacyPlans, - CancellationToken cancellationToken) - { -''' - inject = marker + ''' if (session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority) - { - session.HybridValidation.Reset(null); - var trustedPlans = legacyPlans.Count > 0 - ? legacyPlans - : Iec61850ReportPlanner.BuildPlans( - session.Device, - session.Points.Values, - allowDynamicDataSetWrites: false); - Log("INFO", session.Device.Name, - $"Trusted SCL report planning retained {trustedPlans.Count} local static candidate(s). DataSet membership and RCB identity remain SCL-authoritative; online directory discovery and Hybrid availability probing are bypassed."); - return trustedPlans.Where(plan => !plan.AllowDynamicDataSetWrites).ToArray(); - } - -''' - if marker not in text: - raise SystemExit("BuildReportPlansForCurrentAssociationAsync seam not found") - text = text.replace(marker, inject, 1) - - old_reconnect = ''' try - { - await replacement.ConnectAsync( - session.Device.IpAddress, - session.Device.Port, - connectTimeout.Token).ConfigureAwait(false); - } -''' - new_reconnect = ''' try - { - var reconnectPath = await ConnectUsingSelectedFastPathAsync( - session, - connectTimeout.Token, - allowCachedRetry: false).ConfigureAwait(false); - Log("INFO", session.Device.Name, - reconnectPath == Iec61850ConnectionPath.SclAssisted - ? "Smart reconnect reused verified SCL authority; no cached-association or discovery fallback was attempted." - : "Smart reconnect reused the saved live-model association path."); - } -''' - if old_reconnect not in text: - raise SystemExit("TryReconnectAsync connect seam not found") - text = text.replace(old_reconnect, new_reconnect, 1) - - path.write_text(text, encoding="utf-8") - - test = r'''using ArIED61850Tester.Services; - -namespace ARSAS.Tests; - -public sealed class SclGoldenWireIntegrationContractTests -{ - [Fact] - public void Runtime_FastPlay_PrefersVerifiedSclWithoutDiscoveryFallback() - { - var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); - var start = source.IndexOf("public async Task ConnectUsingCachedModelAsync", StringComparison.Ordinal); - var end = source.IndexOf("public async Task> StartMonitoringAsync", start, StringComparison.Ordinal); - Assert.True(start >= 0 && end > start); - var connect = source[start..end]; - - Assert.Contains("Iec61850ConnectionPathPolicy.SelectForFastConnect", connect, StringComparison.Ordinal); - Assert.Contains("VerifiedSclSourceLoader.LoadAsync", connect, StringComparison.Ordinal); - Assert.Contains("ConnectUsingSclAsync", connect, StringComparison.Ordinal); - Assert.Contains("allowCachedRetry: true", connect, StringComparison.Ordinal); - Assert.DoesNotContain("ConnectAndDiscoverAsync", connect, StringComparison.Ordinal); - Assert.DoesNotContain("DiscoverSignalsAsync", connect, StringComparison.Ordinal); - Assert.True( - connect.IndexOf("VerifiedSclSourceLoader.LoadAsync", StringComparison.Ordinal) < - connect.IndexOf("ConnectUsingSclAsync", StringComparison.Ordinal)); - } - - [Fact] - public void Runtime_TrustedSclStaticReporting_BypassesHybridAndLegacyStartPaths() - { - var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); - Assert.Contains("session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority", source, StringComparison.Ordinal); - Assert.Contains("StartTrustedSclStaticReportMonitorAsync", source, StringComparison.Ordinal); - Assert.Contains("DataSet membership and RCB identity remain SCL-authoritative", source, StringComparison.Ordinal); - } - - [Fact] - public void TrustedSclStaticAdapter_HasGoldenWireSafetyContract() - { - var source = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.TrustedSclStaticReporting.cs")); - Assert.Contains("TryGetTrustedSclDataSetDirectory", source, StringComparison.Ordinal); - Assert.Contains("StartStaticSclReportMonitorAsync", source, StringComparison.Ordinal); - Assert.Contains("triggerGeneralInterrogation: false", source, StringComparison.Ordinal); - Assert.DoesNotContain("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal); - Assert.DoesNotContain("StartPersistentReportMonitorAsync", source, StringComparison.Ordinal); - } - - [Fact] - public void Runtime_Reconnect_ReusesSelectedAuthorityInsteadOfSilentlyDowngrading() - { - var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); - var start = source.IndexOf("private async Task TryReconnectAsync", StringComparison.Ordinal); - var end = source.IndexOf("private void ScheduleReconnectRetry", start, StringComparison.Ordinal); - Assert.True(start >= 0 && end > start); - var reconnect = source[start..end]; - - Assert.Contains("ConnectUsingSelectedFastPathAsync", reconnect, StringComparison.Ordinal); - Assert.Contains("no cached-association or discovery fallback was attempted", reconnect, StringComparison.Ordinal); - Assert.DoesNotContain("replacement.ConnectAsync", reconnect, StringComparison.Ordinal); - } - - private static string FindRepoFile(string relativePath) - { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory != null) - { - var candidate = Path.Combine(directory.FullName, relativePath); - if (File.Exists(candidate)) - return candidate; - directory = directory.Parent; - } - throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); - } -} -''' - Path("tests/ARSAS.Tests/SclGoldenWireIntegrationContractTests.cs").write_text(test, encoding="utf-8") - Path(".github/workflows/apply-scl-golden-wire-runtime.yml").unlink() + - name: Apply isolated runtime patch + run: python scripts/apply-scl-golden-wire-runtime.py - name: Commit isolated wiring - shell: bash run: | set -euo pipefail + rm -f .github/workflows/apply-scl-golden-wire-runtime.yml + rm -f scripts/apply-scl-golden-wire-runtime.py git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A From fe603c06b2c0c5c009ef2660121a248d99a675bd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 04:53:05 +0000 Subject: [PATCH 07/29] trial: wire trusted SCL golden path into runtime --- .../apply-scl-golden-wire-runtime.yml | 35 -- Services/Iec61850MonitorRuntime.cs | 135 ++++++-- scripts/apply-scl-golden-wire-runtime.py | 320 ------------------ .../SclGoldenWireIntegrationContractTests.cs | 74 ++++ 4 files changed, 188 insertions(+), 376 deletions(-) delete mode 100644 .github/workflows/apply-scl-golden-wire-runtime.yml delete mode 100644 scripts/apply-scl-golden-wire-runtime.py create mode 100644 tests/ARSAS.Tests/SclGoldenWireIntegrationContractTests.cs diff --git a/.github/workflows/apply-scl-golden-wire-runtime.yml b/.github/workflows/apply-scl-golden-wire-runtime.yml deleted file mode 100644 index 9a2cd7f3b..000000000 --- a/.github/workflows/apply-scl-golden-wire-runtime.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Apply trusted SCL golden-wire runtime wiring - -on: - push: - branches: - - trial/scl-golden-wire-v1636 - paths: - - .github/workflows/apply-scl-golden-wire-runtime.yml - -permissions: - contents: write - -jobs: - apply-runtime-wiring: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: trial/scl-golden-wire-v1636 - fetch-depth: 0 - - - name: Apply isolated runtime patch - run: python scripts/apply-scl-golden-wire-runtime.py - - - name: Commit isolated wiring - run: | - set -euo pipefail - rm -f .github/workflows/apply-scl-golden-wire-runtime.yml - rm -f scripts/apply-scl-golden-wire-runtime.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "trial: wire trusted SCL golden path into runtime" - git push origin HEAD:trial/scl-golden-wire-v1636 diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 0b939ec05..6299739c3 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -258,12 +258,19 @@ public async Task ConnectUsingCachedModelAsync( { ArgumentNullException.ThrowIfNull(device); ValidateEndpoint(device); - if (!device.HasDiscoveryCache || device.Signals.Count == 0) + + var connectionPath = Iec61850ConnectionPathPolicy.SelectForFastConnect(device); + if (connectionPath == Iec61850ConnectionPath.FullDiscovery) + throw new InvalidOperationException($"{device.Name} has no trusted SCL design or successful saved discovery model. Run a full discovery first."); + if (connectionPath == Iec61850ConnectionPath.CachedLiveModel && + (!device.HasDiscoveryCache || device.Signals.Count == 0)) throw new InvalidOperationException($"{device.Name} has no successful saved discovery model. Run a full discovery first."); progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.PreparingSession, - "Preparing saved IEC 61850 model…", + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verifying trusted SCL/CID source…" + : "Preparing saved IEC 61850 model…", 4d, 1, 4)); @@ -277,11 +284,20 @@ public async Task ConnectUsingCachedModelAsync( _sessions[device.DeviceId] = session; device.IsConnected = false; - device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot("Preparing fast connection from saved model"); - device.Status = "Fast connecting"; - device.Detail = $"Opening {device.IpAddress}:{device.Port} with the saved discovery model."; + device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Preparing verified SCL-assisted connection" + : "Preparing fast connection from saved model"); + device.Status = connectionPath == Iec61850ConnectionPath.SclAssisted + ? "SCL connecting" + : "Fast connecting"; + device.Detail = connectionPath == Iec61850ConnectionPath.SclAssisted + ? $"Opening {device.IpAddress}:{device.Port} with verified SCL association identity." + : $"Opening {device.IpAddress}:{device.Port} with the saved discovery model."; Log("INFO", device.Name, - $"Fast reconnect using saved model ({device.SignalCount:N0} signals); full live discovery is skipped."); + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Trusted SCL authority selected for Play; source SHA will be verified before any socket is opened and no discovery fallback is allowed." + : $"Fast reconnect using saved model ({device.SignalCount:N0} signals); full live discovery is skipped."); try { @@ -292,7 +308,10 @@ public async Task ConnectUsingCachedModelAsync( 2, 4)); - await ConnectCachedAssociationWithRetryAsync(session, cancellationToken).ConfigureAwait(false); + connectionPath = await ConnectUsingSelectedFastPathAsync( + session, + cancellationToken, + allowCachedRetry: true).ConfigureAwait(false); if (!session.Client.IsConnected) { device.Status = "Connection failed"; @@ -304,32 +323,47 @@ public async Task ConnectUsingCachedModelAsync( progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.AssociatingMms, - "ACSE/MMS associated. Restoring saved signal workspace…", + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "SCL association validated. Restoring SCL signal workspace…" + : "ACSE/MMS associated. Restoring saved signal workspace…", 74d, 3, 4)); device.IsConnected = true; - device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot("Fast connection complete"); + device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verified SCL-assisted connection complete" + : "Fast connection complete"); device.Status = "Ready"; - device.Detail = $"Connected with saved model: {device.SignalCount:N0} signal(s), {device.SelectedSignalCount:N0} selected. Full discovery was skipped."; - device.AcquisitionMode = "Saved model • ready to monitor"; + device.Detail = connectionPath == Iec61850ConnectionPath.SclAssisted + ? $"Connected from verified SCL: {device.SignalCount:N0} signal(s), {device.SelectedSignalCount:N0} selected. Domain/VMD validation and bounded FC-root reads completed; full discovery was skipped." + : $"Connected with saved model: {device.SignalCount:N0} signal(s), {device.SelectedSignalCount:N0} selected. Full discovery was skipped."; + device.AcquisitionMode = connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verified SCL • ready to monitor" + : "Saved model • ready to monitor"; device.RefreshComputed(); progress?.Report(new IedDiscoveryProgress( IedDiscoveryStage.Complete, - "Saved model restored — ready for live values.", + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verified SCL online path ready for static reporting." + : "Saved model restored — ready for live values.", 100d, 4, 4)); Log("INFO", device.Name, - "Fast reconnect complete. Reporting setup will validate only the acquisition objects required by the selected points; the full signal scan remains cached."); + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Trusted SCL connection complete. Static reporting will reuse SCL RCB/DataSet authority; no network DataSet-directory browse, dynamic DataSet mutation, or implicit GI is permitted." + : "Fast reconnect complete. Reporting setup will validate only the acquisition objects required by the selected points; the full signal scan remains cached."); } catch (Exception ex) { device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( - "Fast TCP/ACSE/MMS connection failed", + connectionPath == Iec61850ConnectionPath.SclAssisted + ? "Verified SCL-assisted connection failed" + : "Fast TCP/ACSE/MMS connection failed", ex); if (!session.Client.IsConnected) { @@ -349,6 +383,45 @@ await DisposeClientForReconnectAsync( } } + private async Task ConnectUsingSelectedFastPathAsync( + DeviceSession session, + CancellationToken cancellationToken, + bool allowCachedRetry) + { + var device = session.Device; + var path = Iec61850ConnectionPathPolicy.SelectForFastConnect(device); + if (path == Iec61850ConnectionPath.FullDiscovery) + throw new InvalidOperationException($"{device.Name} requires full discovery; fast-connect cannot invent a model authority."); + + if (path == Iec61850ConnectionPath.SclAssisted) + { + var verified = await VerifiedSclSourceLoader.LoadAsync( + device.SclSourcePath, + device.SclSourceSha256, + cancellationToken).ConfigureAwait(false); + var result = await session.Client.ConnectUsingSclAsync( + verified.Xml, + device.SclIedName, + device.SclAccessPointName, + device.IpAddress, + device.Port, + cancellationToken).ConfigureAwait(false); + if (!result.IsSuccess || !session.Client.IsConnected) + throw new InvalidOperationException(result.Message); + + device.LiveDiscoveryModel = session.Client.LastLiveModel ?? device.SclWorkspace?.DesignModel; + Log("INFO", device.Name, + $"Verified SCL authority active: SHA256={verified.Sha256}; IED={device.SclIedName}; AP={device.SclAccessPointName}; maxReadRefs={result.Preparation.InitialReadPlan?.MaximumVariableReferencesPerRead ?? 0}."); + return path; + } + + if (allowCachedRetry) + await ConnectCachedAssociationWithRetryAsync(session, cancellationToken).ConfigureAwait(false); + else + await session.Client.ConnectAsync(device.IpAddress, device.Port, cancellationToken).ConfigureAwait(false); + return path; + } + public async Task> StartMonitoringAsync( Iec61850MonitorDevice device, IEnumerable selectedSignals, @@ -711,9 +784,11 @@ private async Task StartReportPlansAsync( cancellationToken.ThrowIfCancellationRequested(); try { - var result = plan.IsEngineAuthoritative - ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) - : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false); + var result = session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority + ? await session.Client.StartTrustedSclStaticReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) + : plan.IsEngineAuthoritative + ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) + : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false); session.HybridValidation.RecordActivation(plan, result); if (!result.IsSuccess) { @@ -943,6 +1018,20 @@ private async Task> BuildReportPlansForCurrentA IReadOnlyList legacyPlans, CancellationToken cancellationToken) { + if (session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority) + { + session.HybridValidation.Reset(null); + var trustedPlans = legacyPlans.Count > 0 + ? legacyPlans + : Iec61850ReportPlanner.BuildPlans( + session.Device, + session.Points.Values, + allowDynamicDataSetWrites: false); + Log("INFO", session.Device.Name, + $"Trusted SCL report planning retained {trustedPlans.Count} local static candidate(s). DataSet membership and RCB identity remain SCL-authoritative; online directory discovery and Hybrid availability probing are bypassed."); + return trustedPlans.Where(plan => !plan.AllowDynamicDataSetWrites).ToArray(); + } + if (session.Client.CanUseHybridReportPlanner(session.Device)) { NativeHybridReportPlanningResult hybrid; @@ -1727,10 +1816,14 @@ await DisposeClientForReconnectAsync( connectTimeout.CancelAfter(SmartReconnectPolicy.ConnectBudget); try { - await replacement.ConnectAsync( - session.Device.IpAddress, - session.Device.Port, - connectTimeout.Token).ConfigureAwait(false); + var reconnectPath = await ConnectUsingSelectedFastPathAsync( + session, + connectTimeout.Token, + allowCachedRetry: false).ConfigureAwait(false); + Log("INFO", session.Device.Name, + reconnectPath == Iec61850ConnectionPath.SclAssisted + ? "Smart reconnect reused verified SCL authority; no cached-association or discovery fallback was attempted." + : "Smart reconnect reused the saved live-model association path."); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { diff --git a/scripts/apply-scl-golden-wire-runtime.py b/scripts/apply-scl-golden-wire-runtime.py deleted file mode 100644 index abc8166a7..000000000 --- a/scripts/apply-scl-golden-wire-runtime.py +++ /dev/null @@ -1,320 +0,0 @@ -from pathlib import Path - -runtime_path = Path("Services/Iec61850MonitorRuntime.cs") -text = runtime_path.read_text(encoding="utf-8") - -start = text.index(" public async Task ConnectUsingCachedModelAsync(") -end = text.index(" public async Task> StartMonitoringAsync(", start) -replacement = r''' public async Task ConnectUsingCachedModelAsync( - Iec61850MonitorDevice device, - CancellationToken cancellationToken, - IProgress? progress = null) - { - ArgumentNullException.ThrowIfNull(device); - ValidateEndpoint(device); - - var connectionPath = Iec61850ConnectionPathPolicy.SelectForFastConnect(device); - if (connectionPath == Iec61850ConnectionPath.FullDiscovery) - throw new InvalidOperationException($"{device.Name} has no trusted SCL design or successful saved discovery model. Run a full discovery first."); - if (connectionPath == Iec61850ConnectionPath.CachedLiveModel && - (!device.HasDiscoveryCache || device.Signals.Count == 0)) - throw new InvalidOperationException($"{device.Name} has no successful saved discovery model. Run a full discovery first."); - - progress?.Report(new IedDiscoveryProgress( - IedDiscoveryStage.PreparingSession, - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Verifying trusted SCL/CID source…" - : "Preparing saved IEC 61850 model…", - 4d, - 1, - 4)); - - await StopDeviceAsync(device.DeviceId).ConfigureAwait(false); - var session = new DeviceSession - { - Device = device, - Client = new NativeIec61850Client() - }; - _sessions[device.DeviceId] = session; - - device.IsConnected = false; - device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Preparing verified SCL-assisted connection" - : "Preparing fast connection from saved model"); - device.Status = connectionPath == Iec61850ConnectionPath.SclAssisted - ? "SCL connecting" - : "Fast connecting"; - device.Detail = connectionPath == Iec61850ConnectionPath.SclAssisted - ? $"Opening {device.IpAddress}:{device.Port} with verified SCL association identity." - : $"Opening {device.IpAddress}:{device.Port} with the saved discovery model."; - Log("INFO", device.Name, - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Trusted SCL authority selected for Play; source SHA will be verified before any socket is opened and no discovery fallback is allowed." - : $"Fast reconnect using saved model ({device.SignalCount:N0} signals); full live discovery is skipped."); - - try - { - progress?.Report(new IedDiscoveryProgress( - IedDiscoveryStage.OpeningTcp, - $"Opening TCP {device.IpAddress}:{device.Port}…", - 24d, - 2, - 4)); - - connectionPath = await ConnectUsingSelectedFastPathAsync( - session, - cancellationToken, - allowCachedRetry: true).ConfigureAwait(false); - if (!session.Client.IsConnected) - { - device.Status = "Connection failed"; - device.Detail = string.IsNullOrWhiteSpace(session.Client.LastErrorMessage) - ? "The IED did not complete IEC 61850 ACSE/MMS association." - : session.Client.LastErrorMessage; - throw new InvalidOperationException(device.Detail); - } - - progress?.Report(new IedDiscoveryProgress( - IedDiscoveryStage.AssociatingMms, - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "SCL association validated. Restoring SCL signal workspace…" - : "ACSE/MMS associated. Restoring saved signal workspace…", - 74d, - 3, - 4)); - - device.IsConnected = true; - device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Verified SCL-assisted connection complete" - : "Fast connection complete"); - device.Status = "Ready"; - device.Detail = connectionPath == Iec61850ConnectionPath.SclAssisted - ? $"Connected from verified SCL: {device.SignalCount:N0} signal(s), {device.SelectedSignalCount:N0} selected. Domain/VMD validation and bounded FC-root reads completed; full discovery was skipped." - : $"Connected with saved model: {device.SignalCount:N0} signal(s), {device.SelectedSignalCount:N0} selected. Full discovery was skipped."; - device.AcquisitionMode = connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Verified SCL • ready to monitor" - : "Saved model • ready to monitor"; - device.RefreshComputed(); - - progress?.Report(new IedDiscoveryProgress( - IedDiscoveryStage.Complete, - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Verified SCL online path ready for static reporting." - : "Saved model restored — ready for live values.", - 100d, - 4, - 4)); - - Log("INFO", device.Name, - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Trusted SCL connection complete. Static reporting will reuse SCL RCB/DataSet authority; no network DataSet-directory browse, dynamic DataSet mutation, or implicit GI is permitted." - : "Fast reconnect complete. Reporting setup will validate only the acquisition objects required by the selected points; the full signal scan remains cached."); - } - catch (Exception ex) - { - device.LastDiagnosticSnapshot = session.Client.CaptureDiagnosticSnapshot( - connectionPath == Iec61850ConnectionPath.SclAssisted - ? "Verified SCL-assisted connection failed" - : "Fast TCP/ACSE/MMS connection failed", - ex); - if (!session.Client.IsConnected) - { - device.IsConnected = false; - _sessions.TryRemove(device.DeviceId, out _); - await DisposeClientForReconnectAsync( - session.Client, - device.Name, - SmartReconnectPolicy.ClientCleanupBudget, - CancellationToken.None).ConfigureAwait(false); - } - throw; - } - finally - { - device.RefreshComputed(); - } - } - - private async Task ConnectUsingSelectedFastPathAsync( - DeviceSession session, - CancellationToken cancellationToken, - bool allowCachedRetry) - { - var device = session.Device; - var path = Iec61850ConnectionPathPolicy.SelectForFastConnect(device); - if (path == Iec61850ConnectionPath.FullDiscovery) - throw new InvalidOperationException($"{device.Name} requires full discovery; fast-connect cannot invent a model authority."); - - if (path == Iec61850ConnectionPath.SclAssisted) - { - var verified = await VerifiedSclSourceLoader.LoadAsync( - device.SclSourcePath, - device.SclSourceSha256, - cancellationToken).ConfigureAwait(false); - var result = await session.Client.ConnectUsingSclAsync( - verified.Xml, - device.SclIedName, - device.SclAccessPointName, - device.IpAddress, - device.Port, - cancellationToken).ConfigureAwait(false); - if (!result.IsSuccess || !session.Client.IsConnected) - throw new InvalidOperationException(result.Message); - - device.LiveDiscoveryModel = session.Client.LastLiveModel ?? device.SclWorkspace?.DesignModel; - Log("INFO", device.Name, - $"Verified SCL authority active: SHA256={verified.Sha256}; IED={device.SclIedName}; AP={device.SclAccessPointName}; maxReadRefs={result.Preparation.InitialReadPlan?.MaximumVariableReferencesPerRead ?? 0}."); - return path; - } - - if (allowCachedRetry) - await ConnectCachedAssociationWithRetryAsync(session, cancellationToken).ConfigureAwait(false); - else - await session.Client.ConnectAsync(device.IpAddress, device.Port, cancellationToken).ConfigureAwait(false); - return path; - } - -''' -text = text[:start] + replacement + text[end:] - -old_start = ''' var result = plan.IsEngineAuthoritative - ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) - : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);''' -new_start = ''' var result = session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority - ? await session.Client.StartTrustedSclStaticReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) - : plan.IsEngineAuthoritative - ? await session.Client.StartHybridReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false) - : await session.Client.StartReportMonitorAsync(plan, cancellationToken).ConfigureAwait(false);''' -if old_start not in text: - raise SystemExit("StartReportPlansAsync seam not found") -text = text.replace(old_start, new_start, 1) - -marker = ''' private async Task> BuildReportPlansForCurrentAssociationAsync( - DeviceSession session, - IReadOnlyList legacyPlans, - CancellationToken cancellationToken) - { -''' -inject = marker + ''' if (session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority) - { - session.HybridValidation.Reset(null); - var trustedPlans = legacyPlans.Count > 0 - ? legacyPlans - : Iec61850ReportPlanner.BuildPlans( - session.Device, - session.Points.Values, - allowDynamicDataSetWrites: false); - Log("INFO", session.Device.Name, - $"Trusted SCL report planning retained {trustedPlans.Count} local static candidate(s). DataSet membership and RCB identity remain SCL-authoritative; online directory discovery and Hybrid availability probing are bypassed."); - return trustedPlans.Where(plan => !plan.AllowDynamicDataSetWrites).ToArray(); - } - -''' -if marker not in text: - raise SystemExit("BuildReportPlansForCurrentAssociationAsync seam not found") -text = text.replace(marker, inject, 1) - -old_reconnect = ''' try - { - await replacement.ConnectAsync( - session.Device.IpAddress, - session.Device.Port, - connectTimeout.Token).ConfigureAwait(false); - } -''' -new_reconnect = ''' try - { - var reconnectPath = await ConnectUsingSelectedFastPathAsync( - session, - connectTimeout.Token, - allowCachedRetry: false).ConfigureAwait(false); - Log("INFO", session.Device.Name, - reconnectPath == Iec61850ConnectionPath.SclAssisted - ? "Smart reconnect reused verified SCL authority; no cached-association or discovery fallback was attempted." - : "Smart reconnect reused the saved live-model association path."); - } -''' -if old_reconnect not in text: - raise SystemExit("TryReconnectAsync connect seam not found") -text = text.replace(old_reconnect, new_reconnect, 1) - -runtime_path.write_text(text, encoding="utf-8") - -regression = r'''using ArIED61850Tester.Services; - -namespace ARSAS.Tests; - -public sealed class SclGoldenWireIntegrationContractTests -{ - [Fact] - public void Runtime_FastPlay_PrefersVerifiedSclWithoutDiscoveryFallback() - { - var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); - var start = source.IndexOf("public async Task ConnectUsingCachedModelAsync", StringComparison.Ordinal); - var end = source.IndexOf("public async Task> StartMonitoringAsync", start, StringComparison.Ordinal); - Assert.True(start >= 0 && end > start); - var connect = source[start..end]; - - Assert.Contains("Iec61850ConnectionPathPolicy.SelectForFastConnect", connect, StringComparison.Ordinal); - Assert.Contains("VerifiedSclSourceLoader.LoadAsync", connect, StringComparison.Ordinal); - Assert.Contains("ConnectUsingSclAsync", connect, StringComparison.Ordinal); - Assert.Contains("allowCachedRetry: true", connect, StringComparison.Ordinal); - Assert.DoesNotContain("ConnectAndDiscoverAsync", connect, StringComparison.Ordinal); - Assert.DoesNotContain("DiscoverSignalsAsync", connect, StringComparison.Ordinal); - Assert.True( - connect.IndexOf("VerifiedSclSourceLoader.LoadAsync", StringComparison.Ordinal) < - connect.IndexOf("ConnectUsingSclAsync", StringComparison.Ordinal)); - } - - [Fact] - public void Runtime_TrustedSclStaticReporting_BypassesHybridAndLegacyStartPaths() - { - var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); - Assert.Contains("session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority", source, StringComparison.Ordinal); - Assert.Contains("StartTrustedSclStaticReportMonitorAsync", source, StringComparison.Ordinal); - Assert.Contains("DataSet membership and RCB identity remain SCL-authoritative", source, StringComparison.Ordinal); - } - - [Fact] - public void TrustedSclStaticAdapter_HasGoldenWireSafetyContract() - { - var source = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.TrustedSclStaticReporting.cs")); - Assert.Contains("TryGetTrustedSclDataSetDirectory", source, StringComparison.Ordinal); - Assert.Contains("StartStaticSclReportMonitorAsync", source, StringComparison.Ordinal); - Assert.Contains("triggerGeneralInterrogation: false", source, StringComparison.Ordinal); - Assert.DoesNotContain("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal); - Assert.DoesNotContain("StartPersistentReportMonitorAsync", source, StringComparison.Ordinal); - } - - [Fact] - public void Runtime_Reconnect_ReusesSelectedAuthorityInsteadOfSilentlyDowngrading() - { - var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); - var start = source.IndexOf("private async Task TryReconnectAsync", StringComparison.Ordinal); - var end = source.IndexOf("private void ScheduleReconnectRetry", start, StringComparison.Ordinal); - Assert.True(start >= 0 && end > start); - var reconnect = source[start..end]; - - Assert.Contains("ConnectUsingSelectedFastPathAsync", reconnect, StringComparison.Ordinal); - Assert.Contains("no cached-association or discovery fallback was attempted", reconnect, StringComparison.Ordinal); - Assert.DoesNotContain("replacement.ConnectAsync", reconnect, StringComparison.Ordinal); - } - - private static string FindRepoFile(string relativePath) - { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory != null) - { - var candidate = Path.Combine(directory.FullName, relativePath); - if (File.Exists(candidate)) - return candidate; - directory = directory.Parent; - } - throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); - } -} -''' -Path("tests/ARSAS.Tests/SclGoldenWireIntegrationContractTests.cs").write_text(regression, encoding="utf-8") diff --git a/tests/ARSAS.Tests/SclGoldenWireIntegrationContractTests.cs b/tests/ARSAS.Tests/SclGoldenWireIntegrationContractTests.cs new file mode 100644 index 000000000..0a37cdf41 --- /dev/null +++ b/tests/ARSAS.Tests/SclGoldenWireIntegrationContractTests.cs @@ -0,0 +1,74 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class SclGoldenWireIntegrationContractTests +{ + [Fact] + public void Runtime_FastPlay_PrefersVerifiedSclWithoutDiscoveryFallback() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + var start = source.IndexOf("public async Task ConnectUsingCachedModelAsync", StringComparison.Ordinal); + var end = source.IndexOf("public async Task> StartMonitoringAsync", start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + var connect = source[start..end]; + + Assert.Contains("Iec61850ConnectionPathPolicy.SelectForFastConnect", connect, StringComparison.Ordinal); + Assert.Contains("VerifiedSclSourceLoader.LoadAsync", connect, StringComparison.Ordinal); + Assert.Contains("ConnectUsingSclAsync", connect, StringComparison.Ordinal); + Assert.Contains("allowCachedRetry: true", connect, StringComparison.Ordinal); + Assert.DoesNotContain("ConnectAndDiscoverAsync", connect, StringComparison.Ordinal); + Assert.DoesNotContain("DiscoverSignalsAsync", connect, StringComparison.Ordinal); + Assert.True( + connect.IndexOf("VerifiedSclSourceLoader.LoadAsync", StringComparison.Ordinal) < + connect.IndexOf("ConnectUsingSclAsync", StringComparison.Ordinal)); + } + + [Fact] + public void Runtime_TrustedSclStaticReporting_BypassesHybridAndLegacyStartPaths() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + Assert.Contains("session.StaticDataSetReportOnly && session.Client.HasTrustedSclOnlineAuthority", source, StringComparison.Ordinal); + Assert.Contains("StartTrustedSclStaticReportMonitorAsync", source, StringComparison.Ordinal); + Assert.Contains("DataSet membership and RCB identity remain SCL-authoritative", source, StringComparison.Ordinal); + } + + [Fact] + public void TrustedSclStaticAdapter_HasGoldenWireSafetyContract() + { + var source = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.TrustedSclStaticReporting.cs")); + Assert.Contains("TryGetTrustedSclDataSetDirectory", source, StringComparison.Ordinal); + Assert.Contains("StartStaticSclReportMonitorAsync", source, StringComparison.Ordinal); + Assert.Contains("triggerGeneralInterrogation: false", source, StringComparison.Ordinal); + Assert.DoesNotContain("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal); + Assert.DoesNotContain("StartPersistentReportMonitorAsync", source, StringComparison.Ordinal); + } + + [Fact] + public void Runtime_Reconnect_ReusesSelectedAuthorityInsteadOfSilentlyDowngrading() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + var start = source.IndexOf("private async Task TryReconnectAsync", StringComparison.Ordinal); + var end = source.IndexOf("private void ScheduleReconnectRetry", start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + var reconnect = source[start..end]; + + Assert.Contains("ConnectUsingSelectedFastPathAsync", reconnect, StringComparison.Ordinal); + Assert.Contains("no cached-association or discovery fallback was attempted", reconnect, StringComparison.Ordinal); + Assert.DoesNotContain("replacement.ConnectAsync", reconnect, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException($"Could not locate repository file '{relativePath}'."); + } +} From 3705dc46bea753768dfe827bd28ae34109855dc5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 11:56:44 +0700 Subject: [PATCH 08/29] ci: stage trusted SCL compile fix --- scripts/fix-scl-trusted-out.py | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 scripts/fix-scl-trusted-out.py diff --git a/scripts/fix-scl-trusted-out.py b/scripts/fix-scl-trusted-out.py new file mode 100644 index 000000000..1c72374ca --- /dev/null +++ b/scripts/fix-scl-trusted-out.py @@ -0,0 +1,42 @@ +from pathlib import Path + +scl = Path("Services/NativeIec61850Client.SclAssisted.cs") +text = scl.read_text(encoding="utf-8") +old = ''' internal bool TryGetTrustedSclDataSetDirectory( + string dataSetReference, + out ArMms.MmsDataSetDirectoryResult directory) + => _trustedSclOnlineAuthorityActive && + _trustedSclDataSetDirectories.TryGetValue( + NormalizeTrustedSclReference(dataSetReference), + out directory!); +''' +new = ''' internal bool TryGetTrustedSclDataSetDirectory( + string dataSetReference, + out ArMms.MmsDataSetDirectoryResult directory) + { + directory = null!; + if (!_trustedSclOnlineAuthorityActive) + return false; + + return _trustedSclDataSetDirectories.TryGetValue( + NormalizeTrustedSclReference(dataSetReference), + out directory!); + } +''' +if old not in text: + raise SystemExit("Trusted SCL DataSet directory helper seam not found") +scl.write_text(text.replace(old, new, 1), encoding="utf-8") + +adapter = Path("Services/NativeIec61850Client.TrustedSclStaticReporting.cs") +text = adapter.read_text(encoding="utf-8") +old = ''' plan.ReportControlReference = start.Session.ReportControl.Reference; + plan.DataSetReference = start.Session.Plan.DataSetReference; +''' +new = ''' if (!string.IsNullOrWhiteSpace(start.Session.ReportControl.Reference)) + plan.ReportControlReference = start.Session.ReportControl.Reference; + if (!string.IsNullOrWhiteSpace(start.Session.Plan.DataSetReference)) + plan.DataSetReference = start.Session.Plan.DataSetReference; +''' +if old not in text: + raise SystemExit("Trusted SCL nullable assignment seam not found") +adapter.write_text(text.replace(old, new, 1), encoding="utf-8") From ffa72b57cf5769e610221c7e31098323d8845114 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 11:56:53 +0700 Subject: [PATCH 09/29] ci: apply trusted SCL compile fix --- .../apply-scl-trusted-compile-fix.yml | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/apply-scl-trusted-compile-fix.yml diff --git a/.github/workflows/apply-scl-trusted-compile-fix.yml b/.github/workflows/apply-scl-trusted-compile-fix.yml new file mode 100644 index 000000000..5fb3b7d71 --- /dev/null +++ b/.github/workflows/apply-scl-trusted-compile-fix.yml @@ -0,0 +1,33 @@ +name: Apply trusted SCL compile fix + +on: + push: + branches: + - trial/scl-golden-wire-v1636 + paths: + - .github/workflows/apply-scl-trusted-compile-fix.yml + +permissions: + contents: write + +jobs: + apply-fix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: trial/scl-golden-wire-v1636 + fetch-depth: 0 + - name: Apply compile fix + run: python scripts/fix-scl-trusted-out.py + - name: Commit fix + run: | + set -euo pipefail + rm -f .github/workflows/apply-scl-trusted-compile-fix.yml + rm -f scripts/fix-scl-trusted-out.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "trial: fix trusted SCL directory out contract" + git push origin HEAD:trial/scl-golden-wire-v1636 From eb6a555d1d30337121dbc713c5b3c02371e816c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 04:57:01 +0000 Subject: [PATCH 10/29] trial: fix trusted SCL directory out contract --- .../apply-scl-trusted-compile-fix.yml | 33 --------------- Services/NativeIec61850Client.SclAssisted.cs | 13 ++++-- ...ec61850Client.TrustedSclStaticReporting.cs | 6 ++- scripts/fix-scl-trusted-out.py | 42 ------------------- 4 files changed, 13 insertions(+), 81 deletions(-) delete mode 100644 .github/workflows/apply-scl-trusted-compile-fix.yml delete mode 100644 scripts/fix-scl-trusted-out.py diff --git a/.github/workflows/apply-scl-trusted-compile-fix.yml b/.github/workflows/apply-scl-trusted-compile-fix.yml deleted file mode 100644 index 5fb3b7d71..000000000 --- a/.github/workflows/apply-scl-trusted-compile-fix.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Apply trusted SCL compile fix - -on: - push: - branches: - - trial/scl-golden-wire-v1636 - paths: - - .github/workflows/apply-scl-trusted-compile-fix.yml - -permissions: - contents: write - -jobs: - apply-fix: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: trial/scl-golden-wire-v1636 - fetch-depth: 0 - - name: Apply compile fix - run: python scripts/fix-scl-trusted-out.py - - name: Commit fix - run: | - set -euo pipefail - rm -f .github/workflows/apply-scl-trusted-compile-fix.yml - rm -f scripts/fix-scl-trusted-out.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "trial: fix trusted SCL directory out contract" - git push origin HEAD:trial/scl-golden-wire-v1636 diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs index 963eecbaa..e03aa0f7d 100644 --- a/Services/NativeIec61850Client.SclAssisted.cs +++ b/Services/NativeIec61850Client.SclAssisted.cs @@ -34,10 +34,15 @@ internal IReadOnlyList TrustedSclReportControls internal bool TryGetTrustedSclDataSetDirectory( string dataSetReference, out ArMms.MmsDataSetDirectoryResult directory) - => _trustedSclOnlineAuthorityActive && - _trustedSclDataSetDirectories.TryGetValue( - NormalizeTrustedSclReference(dataSetReference), - out directory!); + { + directory = null!; + if (!_trustedSclOnlineAuthorityActive) + return false; + + return _trustedSclDataSetDirectories.TryGetValue( + NormalizeTrustedSclReference(dataSetReference), + out directory!); + } private void ResetTrustedSclOnlineAuthority() { diff --git a/Services/NativeIec61850Client.TrustedSclStaticReporting.cs b/Services/NativeIec61850Client.TrustedSclStaticReporting.cs index 85e60526b..d4aa480b1 100644 --- a/Services/NativeIec61850Client.TrustedSclStaticReporting.cs +++ b/Services/NativeIec61850Client.TrustedSclStaticReporting.cs @@ -165,8 +165,10 @@ public async Task StartTrustedSclStaticReportMon }; } - plan.ReportControlReference = start.Session.ReportControl.Reference; - plan.DataSetReference = start.Session.Plan.DataSetReference; + if (!string.IsNullOrWhiteSpace(start.Session.ReportControl.Reference)) + plan.ReportControlReference = start.Session.ReportControl.Reference; + if (!string.IsNullOrWhiteSpace(start.Session.Plan.DataSetReference)) + plan.DataSetReference = start.Session.Plan.DataSetReference; plan.Buffered = start.Session.ReportControl.Buffered; plan.IsEngineAuthoritative = true; plan.EngineAcquisitionKind = plan.Buffered ? "StaticBrcb" : "StaticUrcb"; diff --git a/scripts/fix-scl-trusted-out.py b/scripts/fix-scl-trusted-out.py deleted file mode 100644 index 1c72374ca..000000000 --- a/scripts/fix-scl-trusted-out.py +++ /dev/null @@ -1,42 +0,0 @@ -from pathlib import Path - -scl = Path("Services/NativeIec61850Client.SclAssisted.cs") -text = scl.read_text(encoding="utf-8") -old = ''' internal bool TryGetTrustedSclDataSetDirectory( - string dataSetReference, - out ArMms.MmsDataSetDirectoryResult directory) - => _trustedSclOnlineAuthorityActive && - _trustedSclDataSetDirectories.TryGetValue( - NormalizeTrustedSclReference(dataSetReference), - out directory!); -''' -new = ''' internal bool TryGetTrustedSclDataSetDirectory( - string dataSetReference, - out ArMms.MmsDataSetDirectoryResult directory) - { - directory = null!; - if (!_trustedSclOnlineAuthorityActive) - return false; - - return _trustedSclDataSetDirectories.TryGetValue( - NormalizeTrustedSclReference(dataSetReference), - out directory!); - } -''' -if old not in text: - raise SystemExit("Trusted SCL DataSet directory helper seam not found") -scl.write_text(text.replace(old, new, 1), encoding="utf-8") - -adapter = Path("Services/NativeIec61850Client.TrustedSclStaticReporting.cs") -text = adapter.read_text(encoding="utf-8") -old = ''' plan.ReportControlReference = start.Session.ReportControl.Reference; - plan.DataSetReference = start.Session.Plan.DataSetReference; -''' -new = ''' if (!string.IsNullOrWhiteSpace(start.Session.ReportControl.Reference)) - plan.ReportControlReference = start.Session.ReportControl.Reference; - if (!string.IsNullOrWhiteSpace(start.Session.Plan.DataSetReference)) - plan.DataSetReference = start.Session.Plan.DataSetReference; -''' -if old not in text: - raise SystemExit("Trusted SCL nullable assignment seam not found") -adapter.write_text(text.replace(old, new, 1), encoding="utf-8") From 8e9edbbd4e63dd80b09f7c929d10f32007361a50 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 11:57:39 +0700 Subject: [PATCH 11/29] docs: define trusted SCL physical trial gate --- docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md | 44 ++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md diff --git a/docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md b/docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md new file mode 100644 index 000000000..73c8bc511 --- /dev/null +++ b/docs/SCL_GOLDEN_WIRE_PHYSICAL_TRIAL.md @@ -0,0 +1,44 @@ +# Trusted SCL golden-wire physical trial + +This document freezes the first field-test contract for the protocol-only ARSAS 1.6.36 trial lane. + +## Immutable engine authority + +- ARSAS branch: `trial/scl-golden-wire-v1636` +- ARIEC61850 engine: `e41def0a2676efb8a143905798155f6bccc6f047` +- Field-proven reporting/control baseline preserved by the engine lock: `11ab2304482600c19ba979f4fc9021ddb46b9af9` + +## Gate 1 — read-only safe trial + +Run the portable application with Wireshark capturing TCP port 102. + +```powershell +ARSAS-1.6.36-win-x64-portable.exe --scl-safe-trial "C:\path\IED.cid" "IEDNAME" "AP1" "192.168.x.x" 102 +``` + +Expected network work is limited to association, Domain/VMD reconciliation and bounded sequential initial FC-root Reads. The safe-trial process exits immediately afterwards. It must not enter full signal discovery, DataSet-directory discovery, writes, control, RCB enable/GI, or dynamic DataSet services. + +If interoperability of a multi-variable Read is uncertain, repeat on a fresh association with exactly one variable reference per Read: + +```powershell +ARSAS-1.6.36-win-x64-portable.exe --scl-safe-trial-single "C:\path\IED.cid" "IEDNAME" "AP1" "192.168.x.x" 102 +``` + +Keep both the generated JSON evidence and the corresponding PCAP/PCAPNG. + +## Gate 2 — normal Play and trusted static reporting + +Only after Gate 1 association/read behavior is understood, open the same verified SCL source and use normal Play. + +Trusted-SCL Play verifies the imported source SHA-256 before socket activity, keeps the SCL IED/AccessPoint association identity, performs Domain/VMD validation and bounded initial Reads, and does not silently fall back to cached association or full discovery. + +For Static DataSet report-only mode, ordered DataSet membership and RCB identity remain SCL-authoritative in memory. The trusted path does not perform a network DataSet-directory browse or create/delete a dynamic DataSet. + +Primary activation expectation: + +- BRCB: whole-RCB Read -> `RptEna=true` -> whole-RCB Read -> whole-RCB Read. +- URCB: whole-RCB Read -> `Resv=true` when exposed -> `RptEna=true` -> two whole-RCB readbacks. +- BRCB `ResvTms` is retry-only after a real direct-`RptEna` rejection. +- GI is not sent implicitly. + +Physical success is not claimed by CI. JSON evidence plus Wireshark capture from the real IED are the acceptance evidence. From 0aeec7ffb2c90e8a78a356a6d21bd96a314df571 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 12:36:09 +0700 Subject: [PATCH 12/29] ux: restore thread-safe smart IED onboarding --- Services/SmartIedOnboardingBehavior.cs | 323 +++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 Services/SmartIedOnboardingBehavior.cs diff --git a/Services/SmartIedOnboardingBehavior.cs b/Services/SmartIedOnboardingBehavior.cs new file mode 100644 index 000000000..9f58b1c18 --- /dev/null +++ b/Services/SmartIedOnboardingBehavior.cs @@ -0,0 +1,323 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +public sealed record SmartIedBulkActionState( + bool ShowConnectAll, + int TotalDevices, + int CandidateCount, + string Label, + string ToolTip); + +public static class SmartIedBulkActionPolicy +{ + public static SmartIedBulkActionState Evaluate(IEnumerable devices) + { + var snapshot = devices?.ToArray() ?? Array.Empty(); + var candidateCount = snapshot.Count(IsActionableConnectCandidate); + var show = snapshot.Length > 1 && candidateCount > 0; + var label = candidateCount switch + { + <= 0 => "Connect All", + 1 => "Connect 1 IED", + _ => $"Connect {candidateCount} IEDs" + }; + var toolTip = show + ? $"Connect/start the {candidateCount} IED(s) that are ready for a bulk connection. Already monitoring, busy, or endpoint-unbound IEDs are skipped." + : "Bulk connect appears only when multiple IEDs are loaded and at least one has a usable endpoint that still needs connection/monitoring."; + + return new SmartIedBulkActionState(show, snapshot.Length, candidateCount, label, toolTip); + } + + private static bool IsActionableConnectCandidate(Iec61850MonitorDevice device) + => device is not null + && !device.IsBusy + && !device.IsMonitoring + && !string.IsNullOrWhiteSpace(device.IpAddress) + && device.Port is >= 1 and <= 65535; +} + +/// +/// Presentation-only onboarding refinement for the Engineering IED Explorer. +/// Existing Open SCL, IP discovery, and Connect All handlers remain the execution +/// authorities. All visual mutations are marshalled onto the MainWindow dispatcher. +/// +public static class SmartIedOnboardingBehavior +{ + private static readonly ConditionalWeakTable States = new(); + + public static void Install(MainWindow window) + { + ArgumentNullException.ThrowIfNull(window); + var state = States.GetValue(window, static owner => new SmartIedOnboardingState(owner)); + state.InstallOrRefresh(); + } + + private sealed class SmartIedOnboardingState + { + private readonly MainWindow _window; + private readonly HashSet