diff --git a/Services/TrustedSclIdentityAuthority.cs b/Services/TrustedSclIdentityAuthority.cs index 2e894f5c0..6d7bd4726 100644 --- a/Services/TrustedSclIdentityAuthority.cs +++ b/Services/TrustedSclIdentityAuthority.cs @@ -29,7 +29,8 @@ public static class TrustedSclIdentityAuthority source, liveModel.LogicalDevices.Select(ld => ld.MmsDomain), device.IpAddress, - device.SclIedName); + device.SclIedName, + device.SclAccessPointName); } /// @@ -87,7 +88,8 @@ public static class TrustedSclIdentityAuthority continue; var identity = TrustedSclIedIdentityMatcher.TryMatch( - document, domains, device.IpAddress, candidate.IedName); + document, domains, device.IpAddress, candidate.IedName, + candidate.AccessPointName); if (identity is null || identity.Source != "TrustedSclExactDomainMatch" || identity.Confidence != LiveIedDiscoveryConfidenceLevel.High) @@ -122,7 +124,7 @@ public static Iec61850DeviceIdentity ToConsumerIdentity( { IedName=verified.IedName, Source=$"ARIEC61850 {verified.Source} ({verified.Confidence})", - LogicalDevices=domains.Distinct(StringComparer.OrdinalIgnoreCase).Select(domain => + LogicalDevices=domains.Distinct(StringComparer.Ordinal).Select(domain => new Iec61850LogicalDeviceIdentity { Domain=domain, diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 8cc894215..3e4fc588a 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "51c2eefd61d6bcbe1116fda6b781b74bb9cf06da", - "sourcePullRequest": 173, - "purpose": "M2.17-K isolated C264 acceptance trial: ARIEC typed ST enumerated Mod/Beh/Health/PhyHealth Report FCD projection with exact ST/quality/timestamp checks, preserving P10.9V control and fail-closed unknowns. ARIEC #173 Windows engine CI passed; integrated consumer CI/physical C264 replay pending. Do not promote or merge into main without acceptance.", + "commit": "ccf31f60095ea65f9095514f0fa0102060962049", + "sourcePullRequest": 177, + "purpose": "P0-A identity code-only trial: exact case-sensitive MMS domain identity, provisional heuristics, SHA-pinned SCL AP/endpoint/domain evidence. Engine #177 Windows CI pending final verification; no physical promotion.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, @@ -270,5 +270,24 @@ "physicalQualificationRequired": true, "mainlinePromotionForbidden": true, "cyclicMmsProcessPollingForbidden": true + }, + "previousIdentityTrialPin": { + "commit": "51c2eefd61d6bcbe1116fda6b781b74bb9cf06da", + "sourcePullRequest": 173, + "purpose": "M2.17-K isolated C264 acceptance trial: ARIEC typed ST enumerated Mod/Beh/Health/PhyHealth Report FCD projection with exact ST/quality/timestamp checks, preserving P10.9V control and fail-closed unknowns. ARIEC #173 Windows engine CI passed; integrated consumer CI/physical C264 replay pending. Do not promote or merge into main without acceptance." + }, + "p0AIdentityTrial": { + "status": "code-trial-not-physical", + "baseEngineCommit": "51c2eefd61d6bcbe1116fda6b781b74bb9cf06da", + "exactCommit": "ccf31f60095ea65f9095514f0fa0102060962049", + "enginePullRequest": 177, + "consumerPullRequest": 624, + "engineCiRun": 39228439974, + "exactDomainCaseRequired": true, + "sclShaPinnedRequired": true, + "exactEndpointAndAccessPointRequired": true, + "discoveryWritesForbidden": true, + "physicalQualificationRequired": true, + "mainlinePromotionForbidden": true } } diff --git a/scripts/test-m217k-typed-status-trial.ps1 b/scripts/test-m217k-typed-status-trial.ps1 index ecf716301..a9683cc1a 100644 --- a/scripts/test-m217k-typed-status-trial.ps1 +++ b/scripts/test-m217k-typed-status-trial.ps1 @@ -10,6 +10,42 @@ function Test-M217KTypedStatusTrial { $previous = '036e2c00d83a0815b6160147b14ea05975057965' $candidate = '51c2eefd61d6bcbe1116fda6b781b74bb9cf06da' + # P0-A is a descendant of the exact M2.17-K code trial, never a replacement + # for the historical physical authority. Re-run the original unmodified + # M2.17-K guard against its preserved pin before admitting one exact child. + if ([int]$Lock.sourcePullRequest -eq 177) { + $identity = $Lock.p0AIdentityTrial + $saved = $Lock.previousIdentityTrialPin + if ($null -eq $identity -or $null -eq $saved) { return $false } + $historicalView = $Lock | Select-Object * + $historicalView.commit = $candidate + $historicalView.sourcePullRequest = 173 + $historicalView.purpose = [string]$saved.purpose + $parentValid = Test-M217KTypedStatusTrial -Lock $historicalView + return ( + $parentValid -and + [string]$Lock.repository -ceq 'masarray/ARIEC61850' -and + [string]$Lock.ref -ceq 'main' -and + [string]$saved.commit -ceq $candidate -and + [int]$saved.sourcePullRequest -eq 173 -and + [string]$saved.purpose -match '^M2.17-K isolated C264 acceptance trial' -and + [string]$identity.baseEngineCommit -ceq $candidate -and + [string]$identity.exactCommit -ceq 'ccf31f60095ea65f9095514f0fa0102060962049' -and + [string]$Lock.commit -ceq [string]$identity.exactCommit -and + [string]$Lock.purpose -match '^P0-A identity code-only trial' -and + [int]$identity.enginePullRequest -eq 177 -and + [int]$identity.consumerPullRequest -eq 624 -and + [long]$identity.engineCiRun -eq 39228439974 -and + [string]$identity.status -ceq 'code-trial-not-physical' -and + [bool]$identity.exactDomainCaseRequired -and + [bool]$identity.sclShaPinnedRequired -and + [bool]$identity.exactEndpointAndAccessPointRequired -and + [bool]$identity.discoveryWritesForbidden -and + [bool]$identity.physicalQualificationRequired -and + [bool]$identity.mainlinePromotionForbidden + ) + } + # Audit the COMPLETE previously accepted P10.9V code-only ancestry, # including physical R10 history, routing, subscriber integrity, canonical # ingress, ZIV BSC, commissioning and typed control gates. A new head diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index e3351a288..c518cac56 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -474,9 +474,14 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter typedStatusTrial.GetProperty("previousConsumerPin").GetString()); Assert.Equal("51c2eefd61d6bcbe1116fda6b781b74bb9cf06da", typedStatusTrial.GetProperty("exactCommit").GetString()); - Assert.Equal(typedStatusTrial.GetProperty("exactCommit").GetString(), + var identityTrial = root.GetProperty("p0AIdentityTrial"); + Assert.Equal(typedStatusTrial.GetProperty("exactCommit").GetString(), identityTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal(identityTrial.GetProperty("exactCommit").GetString(), root.GetProperty("commit").GetString()); - Assert.Equal(173, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("code-trial-not-physical", identityTrial.GetProperty("status").GetString()); + Assert.True(identityTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(identityTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.Equal(177, root.GetProperty("sourcePullRequest").GetInt32()); Assert.Equal(618, typedStatusTrial.GetProperty("consumerPullRequest").GetInt32()); Assert.True(typedStatusTrial.GetProperty("strictFcAndTupleEvidenceRequired").GetBoolean()); Assert.True(typedStatusTrial.GetProperty("physicalQualificationRequired").GetBoolean()); @@ -520,7 +525,7 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter Assert.True(zivTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); // The current C264 report trial is PR #173, while the previous // P10.9V control authority remains the unchanged PR #169. - Assert.Equal(173, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal(177, root.GetProperty("sourcePullRequest").GetInt32()); Assert.Equal(169, typedBscTrial.GetProperty("enginePullRequest").GetInt32()); Assert.True(genericTrial.GetProperty("typedSemanticGateRequired").GetBoolean()); Assert.True(genericTrial.GetProperty("associationScopedInventoryRequired").GetBoolean()); diff --git a/tests/ARSAS.Tests/M217KC264TypedStatusReportIntegrationTests.cs b/tests/ARSAS.Tests/M217KC264TypedStatusReportIntegrationTests.cs index 431c34b23..04d31d84e 100644 --- a/tests/ARSAS.Tests/M217KC264TypedStatusReportIntegrationTests.cs +++ b/tests/ARSAS.Tests/M217KC264TypedStatusReportIntegrationTests.cs @@ -68,9 +68,13 @@ public void TestProductUsesExplicitlyApprovedEngineCommitNotPreviouslyPinnedDll( using var lockDocument = JsonDocument.Parse(File.ReadAllText( Path.Combine(root!.FullName, "engines", "ARIEC61850.lock.json"))); var lockRoot = lockDocument.RootElement; + var identityTrial = lockRoot.GetProperty("p0AIdentityTrial"); + Assert.Equal(ExpectedEngineSha, identityTrial.GetProperty("baseEngineCommit").GetString()); Assert.Equal(ExpectedEngineSha, + lockRoot.GetProperty("previousIdentityTrialPin").GetProperty("commit").GetString()); + Assert.Equal(identityTrial.GetProperty("exactCommit").GetString(), lockRoot.GetProperty("commit").GetString()); - Assert.Equal(173, lockRoot.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal(177, lockRoot.GetProperty("sourcePullRequest").GetInt32()); var trial = lockRoot.GetProperty("m217KTypedStatusReportTrial"); Assert.Equal("036e2c00d83a0815b6160147b14ea05975057965", trial.GetProperty("previousConsumerPin").GetString()); diff --git a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs index e4f1db0b9..dc8fef921 100644 --- a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs +++ b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs @@ -73,6 +73,11 @@ public sealed class P10MainlinePolicyExecutionTests [InlineData("p10-cyclic-fallback-denied", false)] [InlineData("p10-history-tampered-denied", false)] [InlineData("p10-dataset-lineage-tampered-denied", false)] + [InlineData("p0a-wrong-exact-sha-denied", false)] + [InlineData("p0a-wrong-parent-denied", false)] + [InlineData("p0a-wrong-ci-denied", false)] + [InlineData("p0a-wrong-pr-denied", false)] + [InlineData("p0a-physical-promote-denied", false)] [InlineData("m217k-exact-sha-denied", false)] [InlineData("m217k-engine-ci-denied", false)] [InlineData("m217k-previous-pin-denied", false)] @@ -124,6 +129,16 @@ public void Exact_CI_Gate_Fails_Closed_Without_False_Physical_Promotion( switch (scenario) { case "p10-pr-main-denied": baseRef = "main"; break; + case "p0a-wrong-exact-sha-denied": + lockDoc["p0AIdentityTrial"]!["exactCommit"] = new string('0',40); break; + case "p0a-wrong-parent-denied": + lockDoc["p0AIdentityTrial"]!["baseEngineCommit"] = new string('0',40); break; + case "p0a-wrong-ci-denied": + lockDoc["p0AIdentityTrial"]!["engineCiRun"] = 0; break; + case "p0a-wrong-pr-denied": + lockDoc["p0AIdentityTrial"]!["enginePullRequest"] = 0; break; + case "p0a-physical-promote-denied": + lockDoc["p0AIdentityTrial"]!["mainlinePromotionForbidden"] = false; break; case "m217k-exact-sha-denied": lockDoc["m217KTypedStatusReportTrial"]!["exactCommit"] = new string('0', 40); break; case "m217k-engine-ci-denied": diff --git a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs index 0225ca225..1148031ab 100644 --- a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs @@ -118,9 +118,14 @@ public void TrialLock_PreservesHistoricalPhysicalAuthorityAndExplicitlyRequiresP typedStatusTrial.GetProperty("previousConsumerPin").GetString()); Assert.Equal("51c2eefd61d6bcbe1116fda6b781b74bb9cf06da", typedStatusTrial.GetProperty("exactCommit").GetString()); - Assert.Equal(typedStatusTrial.GetProperty("exactCommit").GetString(), - root.GetProperty("commit").GetString()); - Assert.Equal(173, root.GetProperty("sourcePullRequest").GetInt32()); + var identityTrial = root.GetProperty("p0AIdentityTrial"); + Assert.Equal(typedStatusTrial.GetProperty("exactCommit").GetString(), identityTrial.GetProperty("baseEngineCommit").GetString()); + Assert.Equal(typedStatusTrial.GetProperty("exactCommit").GetString(), root.GetProperty("previousIdentityTrialPin").GetProperty("commit").GetString()); + Assert.Equal(identityTrial.GetProperty("exactCommit").GetString(), root.GetProperty("commit").GetString()); + Assert.Equal("code-trial-not-physical", identityTrial.GetProperty("status").GetString()); + Assert.True(identityTrial.GetProperty("physicalQualificationRequired").GetBoolean()); + Assert.True(identityTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); + Assert.Equal(177, root.GetProperty("sourcePullRequest").GetInt32()); Assert.Equal(618, typedStatusTrial.GetProperty("consumerPullRequest").GetInt32()); Assert.True(typedStatusTrial.GetProperty("strictFcAndTupleEvidenceRequired").GetBoolean()); Assert.True(typedStatusTrial.GetProperty("physicalQualificationRequired").GetBoolean()); @@ -168,7 +173,7 @@ public void TrialLock_PreservesHistoricalPhysicalAuthorityAndExplicitlyRequiresP Assert.True(zivTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); // The current C264 report trial is PR #173, while the previous // P10.9V control authority remains the unchanged PR #169. - Assert.Equal(173, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal(177, root.GetProperty("sourcePullRequest").GetInt32()); Assert.Equal(169, typedBscTrial.GetProperty("enginePullRequest").GetInt32()); Assert.Equal(540, genericTrial.GetProperty("issue").GetInt32()); Assert.Equal(38231327892L, genericTrial.GetProperty("engineCiRun").GetInt64()); diff --git a/tests/ARSAS.Tests/TrustedSclIdentityConsumerTests.cs b/tests/ARSAS.Tests/TrustedSclIdentityConsumerTests.cs index f2476d095..03f59af0c 100644 --- a/tests/ARSAS.Tests/TrustedSclIdentityConsumerTests.cs +++ b/tests/ARSAS.Tests/TrustedSclIdentityConsumerTests.cs @@ -44,6 +44,47 @@ public async Task Missing_Trusted_SCL_And_Wrong_MMS_Endpoint_Does_Not_Guess() temp.Device,Model("BCUGEF650"))); } + + [Fact] + public async Task Scl_Identity_Requires_The_Selected_AccessPoint() + { + using var temp=TempScl.Create("STATIONBCU","CTRL","192.0.2.20"); + temp.Device.SclAccessPointName="OTHER_AP"; + Assert.Null(await TrustedSclIdentityAuthority.TryMatchAsync( + temp.Device,Model("STATIONBCUCTRL"))); + } + + [Fact] + public async Task Five_Live_Domains_With_Verified_Scl_Preserve_Exact_Ied_And_Original_Mms_Refs() + { + using var temp=TempScl.Create("SIEBCU","CTRL","192.0.2.20", + "MEAS","PROT","UD1","UD2"); + var domains=new[] {"SIEBCUCTRL","SIEBCUMEAS","SIEBCUPROT","SIEBCUUD1","SIEBCUUD2"}; + var model=Model(domains); + var verified=await TrustedSclIdentityAuthority.TryMatchAsync(temp.Device,model); + Assert.NotNull(verified); + Assert.Equal("SIEBCU",verified.IedName); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High,verified.Confidence); + var consumer=TrustedSclIdentityAuthority.ToConsumerIdentity(verified,domains); + Assert.Equal("SIEBCU",consumer.IedName); + Assert.Equal(domains,consumer.LogicalDevices.Select(d=>d.Domain).ToArray()); + Assert.Equal("UD2",consumer.LogicalDevices.Last().Instance); + } + + + [Fact] + public async Task Verified_Identity_Preserves_Case_Distinct_Raw_Mms_Domains_In_Consumer() + { + using var temp=TempScl.Create("STATIONBCU","CTRL","192.0.2.20","ctrl"); + var domains=new[] {"STATIONBCUCTRL","STATIONBCUctrl"}; + var verified=await TrustedSclIdentityAuthority.TryMatchAsync(temp.Device,Model(domains)); + Assert.NotNull(verified); + var projected=TrustedSclIdentityAuthority.ToConsumerIdentity(verified,domains); + Assert.Equal(domains,projected.LogicalDevices.Select(x=>x.Domain).ToArray()); + Assert.Equal(new[] {"CTRL","ctrl"}, + projected.LogicalDevices.Select(x=>x.Instance).ToArray()); + } + [Fact] public void Canonical_Save_And_RCB_Export_Must_Consume_Verified_Identity() { @@ -60,10 +101,10 @@ public void Canonical_Save_And_RCB_Export_Must_Consume_Verified_Identity() Assert.Contains("TrustedSclIdentityAuthority.ToConsumerIdentity",runtime); } - private static LiveIedModelDiscoveryDocument Model(string domain) => new() + private static LiveIedModelDiscoveryDocument Model(params string[] domains) => new() { Host="192.16.1.33",IedName="IED_192_16_1_33", - LogicalDevices=[new LiveIedLogicalDeviceModel{MmsDomain=domain,Inst=domain}] + LogicalDevices=domains.Select(domain=>new LiveIedLogicalDeviceModel{MmsDomain=domain,Inst=domain}).ToArray() }; private sealed class TempScl : IDisposable @@ -74,15 +115,17 @@ private TempScl(string folder,Iec61850MonitorDevice device) { _folder=folder;Device=device; } - public static TempScl Create(string ied,string ld,string ip) + public static TempScl Create(string ied,string ld,string ip,params string[] otherLdInstances) { var dir=Path.Combine(Path.GetTempPath(),"arsas-ied-p10-"+Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); var path=Path.Combine(dir,"engineering.iid"); + var remaining=string.Concat(otherLdInstances.Select(inst=> + $"""""")); var xml=$"""

{ip}

- + {remaining}
"""; File.WriteAllText(path,xml);