diff --git a/src/AR.Iec61850/Mms/MmsG26GuardedLegacyCompatibility.cs b/src/AR.Iec61850/Mms/MmsG26GuardedLegacyCompatibility.cs new file mode 100644 index 0000000..60c10ad --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsG26GuardedLegacyCompatibility.cs @@ -0,0 +1,248 @@ +namespace AR.Iec61850.Mms; + +/// +/// Application-supplied compatibility evidence for a legacy InformationReportProven profile +/// whose persisted InformationReport proof predates an explicit spontaneous data-change proof. +/// +/// This evidence is intentionally separate from the persisted qualification profile. It may +/// only unlock the existing guarded-runtime planner after the engine verifies exact current +/// identity, exact proven RCB, exact ordered member sequence, a real dchg InformationReport, +/// NO-GI operation, association health, and complete cleanup. It never authorizes +/// ProductionEligible and it is never persisted by this policy. +/// +public sealed record MmsDynamicReportLegacyDataChangeCompatibilityEvidence +{ + public string EvidenceId { get; init; } = string.Empty; + public string StableIdentityKey { get; init; } = string.Empty; + public string ModelFingerprint { get; init; } = string.Empty; + public string ProfileRevision { get; init; } = string.Empty; + public string RcbReference { get; init; } = string.Empty; + public IReadOnlyList MemberReferences { get; init; } = Array.Empty(); + public bool ActualInformationReportReceived { get; init; } + public bool DataChangeReasonVerified { get; init; } + public bool GeneralInterrogationDisabled { get; init; } + public bool ExactMemberMappingVerified { get; init; } + public bool AssociationHealthyAfterReport { get; init; } + public bool CleanupSucceeded { get; init; } + + public bool IsSuccess => + !string.IsNullOrWhiteSpace(EvidenceId) && + !string.IsNullOrWhiteSpace(StableIdentityKey) && + !string.IsNullOrWhiteSpace(ModelFingerprint) && + !string.IsNullOrWhiteSpace(RcbReference) && + MemberReferences.Count > 0 && + ActualInformationReportReceived && + DataChangeReasonVerified && + GeneralInterrogationDisabled && + ExactMemberMappingVerified && + AssociationHealthyAfterReport && + CleanupSucceeded; +} + +/// +/// P1.5 compatibility adapter for legacy InformationReportProven profiles. +/// +/// The persisted profile is treated as untrusted input and is never mutated. If the stored +/// proof is already DataChange, the original context is returned unchanged. Otherwise a +/// compatibility view is created in memory only after the supplied physical evidence matches +/// the exact current identity, RCB, and ordered member sequence already present in the valid +/// persisted chain. The normal guarded planner then re-runs all of its fresh capability, +/// availability, exact-envelope and one-dynamic-group gates. +/// +public static class MmsGuardedDynamicReportLegacyCompatibilityPolicy +{ + public static bool TryBuildCompatibleContext( + MmsDynamicReportGuardedRuntimePlanningContext sourceContext, + MmsDynamicReportLegacyDataChangeCompatibilityEvidence? evidence, + out MmsDynamicReportGuardedRuntimePlanningContext compatibleContext, + out string reason) + { + ArgumentNullException.ThrowIfNull(sourceContext); + + compatibleContext = sourceContext; + var profile = sourceContext.Profile; + var currentIdentity = sourceContext.CurrentIdentity; + var report = profile.InformationReportProof; + + if (report?.Kind == MmsDynamicInformationReportKind.DataChange) + { + reason = "Stored InformationReport proof is already DataChange; no legacy compatibility adaptation is required."; + return true; + } + + if (profile.SchemaVersion != MmsDynamicReportQualificationProfile.CurrentSchemaVersion) + { + reason = $"Unsupported dynamic qualification profile schema {profile.SchemaVersion}; legacy compatibility is withheld."; + return false; + } + + var identityCompatibility = MmsDynamicReportQualificationProfilePolicy.CheckIdentityCompatibility( + profile, + currentIdentity); + if (!identityCompatibility.IsCompatible) + { + reason = identityCompatibility.Reason; + return false; + } + + if (profile.State < MmsDynamicReportQualificationState.InformationReportProven) + { + reason = $"Dynamic qualification profile is {profile.State}; legacy compatibility requires InformationReportProven or stronger evidence."; + return false; + } + + var envelope = profile.AcceptedEnvelope; + var activation = profile.RcbActivationProof; + if (envelope is null || activation is null || report is null) + { + reason = "InformationReportProven profile is missing accepted-envelope, activation, or InformationReport evidence."; + return false; + } + + if (!activation.IsSuccess || !report.IsSuccess) + { + reason = "Stored activation/report evidence is unsuccessful; legacy compatibility is withheld."; + return false; + } + + if (!SameRcb(activation.RcbReference, report.RcbReference)) + { + reason = "Stored activation/report RCB identities differ."; + return false; + } + + if (!SameRcb(activation.DataSetReference, report.DataSetReference)) + { + reason = "Stored activation/report DataSet identities differ."; + return false; + } + + if (!ExactMemberSequenceEquals(activation.MemberReferences, report.MemberReferences)) + { + reason = "Stored activation/report member sequences differ."; + return false; + } + + if (!IsOrderedMemberSubset(report.MemberReferences, envelope.ExactProvenMemberReferences)) + { + reason = "Stored InformationReport members are outside the exact accepted envelope."; + return false; + } + + if (report.MemberReferences.Count == 0 || report.MemberReferences.Count > envelope.ProvenMemberCount) + { + reason = "Stored InformationReport member count is outside the accepted envelope."; + return false; + } + + if (evidence?.IsSuccess != true) + { + reason = $"Stored InformationReport kind is {report.Kind}; no complete physical legacy dchg compatibility evidence was supplied."; + return false; + } + + if (!SameText(evidence.StableIdentityKey, currentIdentity.StableIdentityKey)) + { + reason = "Legacy dchg compatibility stable identity does not match the current IED identity."; + return false; + } + + if (!SameText(evidence.ModelFingerprint, currentIdentity.ModelFingerprint)) + { + reason = "Legacy dchg compatibility model fingerprint does not match the current IED model."; + return false; + } + + if (!SameText(evidence.ProfileRevision, currentIdentity.ProfileRevision)) + { + reason = "Legacy dchg compatibility profile revision does not match the current IED profile revision."; + return false; + } + + if (!SameRcb(evidence.RcbReference, report.RcbReference)) + { + reason = "Legacy dchg compatibility RCB does not match the persisted proven RCB."; + return false; + } + + if (!ExactMemberSequenceEquals(evidence.MemberReferences, report.MemberReferences)) + { + reason = "Legacy dchg compatibility member sequence does not exactly match the persisted proven member sequence."; + return false; + } + + // Compatibility view only. The original profile object is not modified or saved. + // The legacy evidence independently proves the later NO-GI dchg event on the same + // exact RCB/member envelope; the existing planner still validates the original + // activation/report DataSet chain plus fresh live availability before any write. + var compatibilityView = profile with + { + InformationReportProof = report with + { + EvidenceId = "legacy-compatibility-view:" + evidence.EvidenceId.Trim(), + Kind = MmsDynamicInformationReportKind.DataChange + } + }; + + compatibleContext = sourceContext with { Profile = compatibilityView }; + reason = + "Legacy InformationReportProven compatibility accepted from complete physical NO-GI dchg evidence on the exact current identity, proven RCB, and ordered member sequence. Persisted profile remains unchanged and ProductionEligible remains separate."; + return true; + } + + private static bool SameText(string? left, string? right) + => string.Equals((left ?? string.Empty).Trim(), (right ?? string.Empty).Trim(), StringComparison.OrdinalIgnoreCase); + + private static bool SameRcb(string? left, string? right) + => string.Equals( + MmsRcbAvailabilityEvaluator.NormalizeReference(left).Replace('\\', '/'), + MmsRcbAvailabilityEvaluator.NormalizeReference(right).Replace('\\', '/'), + StringComparison.OrdinalIgnoreCase); + + private static bool ExactMemberSequenceEquals( + IReadOnlyList left, + IReadOnlyList right) + { + if (left.Count != right.Count) + return false; + + for (var index = 0; index < left.Count; index++) + { + if (!string.Equals( + MmsFcReferenceNormalizer.NormalizeMmsReference(left[index] ?? string.Empty), + MmsFcReferenceNormalizer.NormalizeMmsReference(right[index] ?? string.Empty), + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + + private static bool IsOrderedMemberSubset( + IReadOnlyList subset, + IReadOnlyList full) + { + var searchIndex = 0; + foreach (var candidate in subset.Select(item => MmsFcReferenceNormalizer.NormalizeMmsReference(item ?? string.Empty))) + { + var found = false; + while (searchIndex < full.Count) + { + var fullCandidate = MmsFcReferenceNormalizer.NormalizeMmsReference(full[searchIndex] ?? string.Empty); + searchIndex++; + if (!string.Equals(candidate, fullCandidate, StringComparison.OrdinalIgnoreCase)) + continue; + + found = true; + break; + } + + if (!found) + return false; + } + + return true; + } +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsG26GuardedLegacyCompatibilityTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsG26GuardedLegacyCompatibilityTests.cs new file mode 100644 index 0000000..610b448 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsG26GuardedLegacyCompatibilityTests.cs @@ -0,0 +1,352 @@ +using AR.Iec61850.Acse; +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsG26GuardedLegacyCompatibilityTests +{ + [Fact] + public void LegacyGiProfile_WithExactPhysicalDchgEvidence_BuildsGuardedCompatibilityView() + { + var legacyContext = LegacyContext(); + var originalProof = legacyContext.Profile.InformationReportProof!; + + var accepted = MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext( + legacyContext, + Evidence(), + out var compatible, + out var reason); + + Assert.True(accepted, reason); + Assert.Equal(MmsDynamicInformationReportKind.GeneralInterrogation, originalProof.Kind); + Assert.Equal(MmsDynamicInformationReportKind.DataChange, compatible.Profile.InformationReportProof!.Kind); + Assert.StartsWith("legacy-compatibility-view:", compatible.Profile.InformationReportProof.EvidenceId, StringComparison.Ordinal); + Assert.Contains("Persisted profile remains unchanged", reason, StringComparison.OrdinalIgnoreCase); + + var plan = BuildPlan(compatible); + Assert.False(plan.AutomaticDynamicActivationQuarantined); + Assert.False(plan.ProductionDynamicActivationAuthorized); + Assert.Equal(2, plan.AcquisitionPlan.DynamicUrcbSignalCount); + Assert.Equal(0, plan.AcquisitionPlan.PollingFallbackSignalCount); + var dynamic = Assert.Single(plan.AcquisitionPlan.Segments, segment => segment.Kind == MmsHybridAcquisitionKind.DynamicUrcb); + Assert.Equal(ProvenRcbReference, dynamic.ReportControlReference); + Assert.Equal(Members(), dynamic.ReportPlan!.DynamicPoints.Select(point => point.MmsReference).ToArray()); + } + + [Fact] + public void LegacyGiProfile_WithoutCompatibilityEvidence_RemainsWithheld() + { + var accepted = MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext( + LegacyContext(), + null, + out _, + out var reason); + + Assert.False(accepted); + Assert.Contains("no complete physical legacy dchg compatibility evidence", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LegacyGiProfile_IdentityMismatch_RemainsWithheld() + { + var accepted = MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext( + LegacyContext(), + Evidence() with { ModelFingerprint = "sha256:different" }, + out _, + out var reason); + + Assert.False(accepted); + Assert.Contains("fingerprint", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LegacyGiProfile_MemberMismatch_RemainsWithheld() + { + var accepted = MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext( + LegacyContext(), + Evidence() with { MemberReferences = [Member(2), Member(1)] }, + out _, + out var reason); + + Assert.False(accepted); + Assert.Contains("member sequence", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LegacyGiProfile_IncompleteCleanupEvidence_RemainsWithheld() + { + var accepted = MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext( + LegacyContext(), + Evidence() with { CleanupSucceeded = false }, + out _, + out var reason); + + Assert.False(accepted); + Assert.Contains("no complete physical legacy dchg compatibility evidence", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void StoredDataChangeProfile_DoesNotRequireLegacyEvidence() + { + var native = NativeContext(); + var accepted = MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext( + native, + null, + out var compatible, + out var reason); + + Assert.True(accepted, reason); + Assert.Same(native.Profile, compatible.Profile); + Assert.Contains("no legacy compatibility adaptation", reason, StringComparison.OrdinalIgnoreCase); + } + + private static MmsCapabilityAwareHybridReportAcquisitionPlan BuildPlan( + MmsDynamicReportGuardedRuntimePlanningContext context) + { + var signals = new[] { Signal(1), Signal(2) }; + var catalog = new Iec61850SignalCatalogDocument + { + IedName = "G26_P15_IED", + Source = "P1.5 legacy compatibility fixture", + Signals = signals + }; + + return MmsGuardedDynamicReportRuntimePlanner.Build( + catalog, + signals, + DynamicInventory(), + DynamicAvailability(), + Directory(), + new AcseMmsNegotiatedCapabilities + { + IsDecoded = true, + SupportsWrite = true, + SupportsDefineNamedVariableList = true, + SupportsDeleteNamedVariableList = true + }, + new MmsHybridReportAcquisitionOptions + { + AllowStaticBrcb = false, + AllowStaticUrcb = false, + AllowDynamicBrcb = false, + AllowDynamicUrcb = true, + AllowCallerOwnedReports = false, + AllowPollingFallback = true, + RequireExactAvailabilityEvidence = true + }, + context); + } + + private static MmsDynamicReportGuardedRuntimePlanningContext LegacyContext() + { + var native = NativeContext(); + return native with + { + Profile = native.Profile with + { + InformationReportProof = native.Profile.InformationReportProof! with + { + Kind = MmsDynamicInformationReportKind.GeneralInterrogation + } + } + }; + } + + private static MmsDynamicReportGuardedRuntimePlanningContext NativeContext() + => new() + { + Profile = BuildProfile(), + CurrentIdentity = Identity() + }; + + private static MmsDynamicReportLegacyDataChangeCompatibilityEvidence Evidence() + => new() + { + EvidenceId = "field-a3-dchg-proof", + StableIdentityKey = Identity().StableIdentityKey, + ModelFingerprint = Identity().ModelFingerprint, + ProfileRevision = Identity().ProfileRevision, + RcbReference = ProvenRcbReference, + MemberReferences = Members(), + ActualInformationReportReceived = true, + DataChangeReasonVerified = true, + GeneralInterrogationDisabled = true, + ExactMemberMappingVerified = true, + AssociationHealthyAfterReport = true, + CleanupSucceeded = true + }; + + private static MmsDynamicReportQualificationProfile BuildProfile() + { + var members = Members(); + var assessment = MmsDynamicDataSetQualificationLadder.Assess( + [ + new MmsDynamicDataSetQualificationAttemptEvidence + { + AttemptId = "p15-envelope", + ObservedAtUtc = Time(1), + DataSetReference = ProvenDataSetReference, + MemberReferences = members, + DefineRequestByteCount = 200, + NegotiatedMaxMmsPduSize = 65000, + RequestWithinKnownNegotiatedPdu = true, + IsSuccess = true, + FailureStage = MmsDynamicDataSetQualificationFailureStage.None, + DynamicMutationAttempted = true, + AssociationSurvived = true, + CleanupSucceeded = true + } + ]); + var envelope = MmsDynamicDataSetQualificationLadder.AcceptExactEnvelope(assessment, "p15-envelope"); + var profile = MmsDynamicReportQualificationProfilePolicy.CreateEnvelopeQualifiedProfile( + Identity(), + envelope, + assessment, + new MmsDynamicReportCapacityEvidence + { + ObservedFreeBrcbSlots = 0, + ObservedFreeUrcbSlots = 1, + ObservedAtUtc = Time(2), + EvidenceId = "p15-capacity" + }, + "p15-envelope-evidence", + Time(3)); + + var activated = MmsDynamicReportQualificationProfilePolicy.RecordRcbActivationProof( + profile, + Identity(), + new MmsDynamicRcbActivationProof + { + EvidenceId = "p15-activation", + ObservedAtUtc = Time(4), + RcbReference = ProvenRcbReference, + DataSetReference = ProvenDataSetReference, + MemberReferences = members, + FreshRcbAvailabilityVerified = true, + DataSetReadbackVerified = true, + RcbDataSetBindingAccepted = true, + RptEnaAccepted = true, + AssociationHealthyAfterActivation = true + }); + + return MmsDynamicReportQualificationProfilePolicy.RecordInformationReportProof( + activated, + Identity(), + new MmsDynamicInformationReportProof + { + EvidenceId = "p15-report", + ObservedAtUtc = Time(5), + RcbReference = ProvenRcbReference, + DataSetReference = ProvenDataSetReference, + MemberReferences = members, + Kind = MmsDynamicInformationReportKind.DataChange, + ActualInformationReportReceived = true, + ReportIdentityVerified = true, + ExactMemberMappingVerified = true, + AssociationHealthyAfterReport = true, + ReportAuthoritativePointCount = members.Length + }); + } + + private static MmsDynamicReportIedIdentity Identity() + => new() + { + StableIdentityKey = "ied:g26:p15", + ModelFingerprint = "sha256:g26-p15-model", + Manufacturer = "Example", + Model = "P15IED", + FirmwareRevision = "1.0.0", + ProfileRevision = "cfg-p15" + }; + + private static MmsReportInventory DynamicInventory() + { + var inventory = new MmsReportInventory(); + inventory.ReportControls.Add(new MmsReportControlCandidate + { + Domain = "LD0", + LogicalNode = "LLN0", + FunctionalConstraint = "RP", + Name = "Unbuffer01", + Reference = ProvenRcbReference, + Buffered = false, + DataSetReference = string.Empty, + DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded, + EnabledState = "false", + ReservationState = "false", + TriggerOptions = "dchg", + ReportId = "Unbuffer01", + ConfRev = "1" + }); + return inventory; + } + + private static MmsRcbAvailabilityResult DynamicAvailability() + => new() + { + CheckedAtUtc = Time(10), + ReportControls = + [ + new MmsRcbAvailabilitySnapshot + { + Reference = ProvenRcbReference, + Domain = "LD0", + LogicalNode = "LLN0", + Name = "Unbuffer01", + Mode = "URCB", + Buffered = false, + DataSetReference = string.Empty, + DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded, + ReportId = "Unbuffer01", + ConfRev = "1", + EnabledState = "false", + ReservationState = "false", + TriggerOptions = "dchg", + DataSetDirectoryRead = false, + DataSetDirectorySuccess = false, + DataSetMemberCount = 0, + DataSetMembers = Array.Empty(), + Availability = MmsRcbOperationalAvailability.NoDataSet, + Confidence = MmsRcbAvailabilityConfidence.Exact, + Reason = "P1.5 exact empty URCB fixture", + Attributes = ["DatSet", "RptEna", "TrgOps", "Resv"] + } + ] + }; + + private static MmsIedModelDirectory Directory() + => new(new[] { 1, 2 }.Select(index => new MmsFcResolvedPoint + { + Domain = "LD0", + LogicalNode = "GGIO1", + FunctionalConstraint = "ST", + DataObjectPath = $"Ind{index}.stVal", + MmsItemName = $"GGIO1$ST$Ind{index}$stVal", + Source = "P1.5 synthetic live directory", + Confidence = 100 + })); + + private static Iec61850SignalDescriptor Signal(int index) + => new() + { + DesignReference = $"LD0/GGIO1.Ind{index}.stVal", + ObservedReference = $"LD0/GGIO1.Ind{index}.stVal", + CanonicalMmsReference = Member(index), + EffectiveMmsReference = Member(index), + PrimaryValueReference = $"LD0/GGIO1.Ind{index}.stVal", + PrimaryValueMmsReference = Member(index), + FunctionalConstraint = "ST", + SemanticRole = Iec61850DataAttributeSemanticRole.PrimaryValue, + IsOperationalCandidate = true, + ResolutionStatus = Iec61850SignalCatalogResolutionStatus.DesignAttribute, + LiveStatus = Iec61850DesignLiveStatus.Exact + }; + + private static string[] Members() => [Member(1), Member(2)]; + private static string Member(int index) => $"LD0/GGIO1$ST$Ind{index}$stVal"; + private static DateTimeOffset Time(int minutes) => DateTimeOffset.Parse("2026-08-28T00:00:00Z").AddMinutes(minutes); + + private const string ProvenRcbReference = "LD0/LLN0.RP.Unbuffer01"; + private const string ProvenDataSetReference = "LD0/LLN0.AR_G2Q"; +}