diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index 3bd675c3c..f7e01dbca 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -271,7 +271,7 @@ private static void AppendStaticInitialImage( builder.AppendLine($" Additional pending: {image.PendingPoints.Count - 12} (showing first 12; never inferred unavailable)"); } - private static void AppendStaticIngressParity( + internal static void AppendStaticIngressParity( StringBuilder builder, StaticAcquisitionParitySnapshot parity) { @@ -285,6 +285,7 @@ private static void AppendStaticIngressParity( builder.AppendLine($" traffic : {discovery.RoutedTrafficSummary}; first={discovery.FirstRoutedReportAtUtc?.ToString("O") ?? "pending"}"); foreach (var target in discovery.RuntimeTargets.Take(8)) builder.AppendLine($" runtime : {target} [{(discovery.RoutedReportTargets.Contains(target, StringComparer.Ordinal) ? "ROUTED" : "PENDING")}]"); + AppendStaticSemanticBasis(builder, discovery); } if (parity.OpenScl is { } openScl) @@ -293,12 +294,47 @@ private static void AppendStaticIngressParity( builder.AppendLine($" traffic : {openScl.RoutedTrafficSummary}; first={openScl.FirstRoutedReportAtUtc?.ToString("O") ?? "pending"}"); foreach (var target in openScl.RuntimeTargets.Take(8)) builder.AppendLine($" runtime : {target} [{(openScl.RoutedReportTargets.Contains(target, StringComparer.Ordinal) ? "ROUTED" : "PENDING")}]"); + AppendStaticSemanticBasis(builder, openScl); } foreach (var difference in parity.Differences.Take(12)) builder.AppendLine($" difference : {difference}"); } + /// + /// Read-only, bounded provenance for explaining why two independent field runs + /// produced different hashes. These exact source-neutral rows feed the existing + /// hash; indexed live RCB instances are intentionally excluded from the rows. + /// Truncation is explicit: partial rows must not be used as parity proof. + /// + private static void AppendStaticSemanticBasis( + StringBuilder builder, + StaticAcquisitionIngressEvidence evidence) + { + const int maxRows = 12; + const int maxCharsPerRow = 4096; + + builder.AppendLine($" semantic gate : {(evidence.IsComparable ? "COMPARABLE" : "INCOMPLETE")}" + + (evidence.IsComparable ? string.Empty : $"; {evidence.IncomparableReason}")); + builder.AppendLine($" semantic basis : {evidence.SemanticLines.Count} fingerprint row(s); " + + "indexed live RCB slots excluded; diagnostic copy only"); + for (var i = 0; i < Math.Min(evidence.SemanticLines.Count, maxRows); i++) + { + var row = evidence.SemanticLines[i] ?? string.Empty; + var part = row.Length > maxCharsPerRow + ? row[..maxCharsPerRow] + : row; + // Avoid synthetic diagnostic lines if an external source contains CR/LF. + part = part.Replace("\r", @"\r", StringComparison.Ordinal) + .Replace("\n", @"\n", StringComparison.Ordinal); + builder.AppendLine($" semantic[{i}] : {part}" + + (row.Length > maxCharsPerRow ? $" [TRUNCATED; full length={row.Length}]" : string.Empty)); + } + + if (evidence.SemanticLines.Count > maxRows) + builder.AppendLine($" semantic rows : TRUNCATED; shown={maxRows}, total={evidence.SemanticLines.Count}"); + } + private static RouteAnalysis AnalyzeRoute(string targetText) { if (!IPAddress.TryParse(targetText, out var target) || target.AddressFamily != AddressFamily.InterNetwork) diff --git a/tests/ARSAS.Tests/StaticParityDiagnosticBasisP76BTests.cs b/tests/ARSAS.Tests/StaticParityDiagnosticBasisP76BTests.cs new file mode 100644 index 000000000..5feb6ad72 --- /dev/null +++ b/tests/ARSAS.Tests/StaticParityDiagnosticBasisP76BTests.cs @@ -0,0 +1,92 @@ +using System.Text; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class StaticParityDiagnosticBasisP76BTests +{ + [Fact] + public void IndependentlyCapturedIngress_ExposesExactFingerprintInputs() + { + var discovery = Evidence(StaticAcquisitionIngressKind.LiveDiscovery, "abc", + ["ied=ied-a|requested=1|staticBrcbSignals=1", + "static-brcb|dataset=ied-a/lln0.events|members=1[0:ied-a/xcbr1.pos.stval@ST]|selected=1[ied-a/xcbr1.pos.stval@ST]"]); + var openScl = Evidence(StaticAcquisitionIngressKind.OpenScl, "def", + ["ied=ied-a|requested=1|staticBrcbSignals=1", + "static-brcb|dataset=ied-a/lln0.events|members=1[0:ied-a/xcbr1.pos.stval@MX]|selected=1[ied-a/xcbr1.pos.stval@ST]"]); + + var output = new StringBuilder(); + DiagnosticReportBuilder.AppendStaticIngressParity( + output, StaticAcquisitionParityTracker.Compare(discovery, openScl)); + var diagnostic = output.ToString(); + + Assert.Contains("semantic[1]", diagnostic, StringComparison.Ordinal); + Assert.Contains("xcbr1.pos.stval@ST", diagnostic, StringComparison.Ordinal); + Assert.Contains("xcbr1.pos.stval@MX", diagnostic, StringComparison.Ordinal); + Assert.Contains("semantic gate : COMPARABLE", diagnostic, StringComparison.Ordinal); + Assert.Contains("indexed live RCB slots excluded", diagnostic, StringComparison.Ordinal); + Assert.Equal("abc", discovery.SemanticFingerprint); + Assert.Equal("def", openScl.SemanticFingerprint); + } + + [Fact] + public void LongAndManyRows_AreExplicitlyTruncatedWithoutAllocatingFullDiagnosticCopies() + { + var evidence = Evidence(StaticAcquisitionIngressKind.LiveDiscovery, "abc", + Enumerable.Repeat(new string('X', 9000), 14).ToArray()); + var output = new StringBuilder(); + DiagnosticReportBuilder.AppendStaticIngressParity( + output, new StaticAcquisitionParitySnapshot { Discovery = evidence }); + + var report = output.ToString(); + Assert.Contains("TRUNCATED; full length=9000", report, StringComparison.Ordinal); + Assert.Contains("shown=12, total=14", report, StringComparison.Ordinal); + Assert.DoesNotContain("semantic[12]", report, StringComparison.Ordinal); + Assert.True(report.Length < 60000, $"Unbounded diagnostic length: {report.Length}"); + } + + [Fact] + public void MultilineMemberIdentity_CannotInjectFakeDiagnosticRows() + { + var evidence = Evidence(StaticAcquisitionIngressKind.LiveDiscovery, "abc", + ["one\r\nStatic parity : forged"]); + var output = new StringBuilder(); + DiagnosticReportBuilder.AppendStaticIngressParity( + output, new StaticAcquisitionParitySnapshot { Discovery = evidence }); + var report = output.ToString(); + + Assert.Contains(@"one\r\nStatic parity", report, StringComparison.Ordinal); + Assert.DoesNotContain("one\r\nStatic parity", report, StringComparison.Ordinal); + } + + [Fact] + public void IncompleteEvidence_IsExplicitRatherThanReportedAsHashMatch() + { + var evidence = Evidence(StaticAcquisitionIngressKind.LiveDiscovery, "abc", []); + var incomplete = evidence with + { + IsComparable = false, + IncomparableReason = "member FC missing" + }; + var output = new StringBuilder(); + DiagnosticReportBuilder.AppendStaticIngressParity( + output, StaticAcquisitionParityTracker.Compare(incomplete, incomplete with { Ingress = StaticAcquisitionIngressKind.OpenScl })); + var result = output.ToString(); + Assert.Contains("semantic gate : INCOMPLETE; member FC missing", result, StringComparison.Ordinal); + Assert.Contains("INCOMPLETE EVIDENCE", result, StringComparison.Ordinal); + } + + private static StaticAcquisitionIngressEvidence Evidence( + StaticAcquisitionIngressKind ingress, + string fingerprint, + IReadOnlyList rows) + => new() + { + Ingress = ingress, + IedName = "IED-A", + SemanticFingerprint = fingerprint, + IsComparable = true, + SemanticLines = rows + }; +}