diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 294041c7f..9307240b6 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -38,6 +38,7 @@ private sealed class RuntimePointState public bool AwaitingCommandReportEdge { get; set; } public bool CommandReportMissLogged { get; set; } public bool StaleReportSuppressedLogged { get; set; } + public bool ReportValueRejectedLogged { get; set; } public int ConsecutiveErrors { get; set; } } @@ -913,6 +914,34 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT if (update.HasValue && LooksLikeReferenceEcho(display, update.Reference, point.IecReference)) continue; + if (update.HasValue && !ReportProcessValueSafety.IsSafe( + update.Value, + display, + point.IecDataType, + point.IecReference, + out var rejectionReason)) + { + // A malformed/misaligned report is still useful as proof that the + // RCB is alive, but its process value is not authoritative. Do not + // mutate state or SOE history; keep MMS verification/fallback active. + state.ReportTrafficSeen = true; + state.LastReportUtc = DateTime.UtcNow; + state.ReportChangeVerified = false; + state.ReportMissLogged = false; + if (!state.ReportValueRejectedLogged) + { + state.ReportValueRejectedLogged = true; + var rawSummary = update.Value ?? "-"; + if (rawSummary.Length > 120) + rawSummary = rawSummary[..120] + "…"; + Log("WARN", session.Device.Name, + $"REPORT_VALUE_REJECTED: {point.SignalName} ({point.IecReference}) rejected report value '{rawSummary}'. {rejectionReason} MMS verification/fallback remains authoritative."); + } + continue; + } + if (update.HasValue) + state.ReportValueRejectedLogged = false; + var receivedUtc = update.UpdatedAt == default ? DateTime.UtcNow : update.UpdatedAt.UtcDateTime; var hasSourceTimestamp = TryParseReportTimestampUtc(update.ReportTimestamp, out var reportSourceUtc); var commandValueMatches = update.HasValue && state.AwaitingCommandReportEdge && diff --git a/Services/ReportProcessValueSafety.cs b/Services/ReportProcessValueSafety.cs new file mode 100644 index 000000000..1e07cf4f8 --- /dev/null +++ b/Services/ReportProcessValueSafety.cs @@ -0,0 +1,126 @@ +using System.Text.RegularExpressions; + +namespace ArIED61850Tester.Services; + +/// +/// Final consumer-side safety boundary for event-driven report values. +/// The IEC 61850 engine remains authoritative for report decoding, but a report +/// sample that is impossible for the selected signal type must never overwrite a +/// previously verified process value or create a false SOE edge. Rejected report +/// samples are left to the existing MMS verification/fallback path. +/// +public static class ReportProcessValueSafety +{ + private static readonly Regex DbposBitString = new( + @"^\s*bits\(\s*(?:0x)?[0-9a-f]{2}\s*,\s*unused\s*=\s*6\s*\)\s*$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + + public static bool IsSafe( + string? rawValue, + string? formattedValue, + string? dataType, + string? reference, + out string rejectionReason) + { + rejectionReason = string.Empty; + var raw = (rawValue ?? string.Empty).Trim(); + var formatted = (formattedValue ?? string.Empty).Trim(); + var type = (dataType ?? string.Empty).Trim(); + var signalReference = (reference ?? string.Empty).Trim(); + + if (raw.Length == 0 || formatted.Length == 0) + return true; + + var rawIsBits = raw.StartsWith("bits(", StringComparison.OrdinalIgnoreCase); + var formattedIsBits = formatted.StartsWith("bits(", StringComparison.OrdinalIgnoreCase); + var formattedIsContainer = IsContainer(formatted); + + if (IsBoolean(type)) + { + if (rawIsBits || formattedIsBits) + return Reject($"Boolean signal {signalReference} received a BIT STRING report value.", out rejectionReason); + if (formattedIsContainer) + return Reject($"Boolean signal {signalReference} remained a structured report value after scalar projection.", out rejectionReason); + return true; + } + + if (IsDbpos(type)) + { + // A DPC/Dbpos process value is exactly two significant bits in one MMS + // BIT STRING octet (unused=6). Larger bitmaps are report metadata such as + // inclusion/OptFlds and must never be accepted as the process state. + if (rawIsBits && !DbposBitString.IsMatch(raw)) + return Reject($"DPC/Dbpos signal {signalReference} received a non-2-bit BIT STRING report value.", out rejectionReason); + if (formattedIsBits) + return Reject($"DPC/Dbpos signal {signalReference} could not normalize its BIT STRING report value.", out rejectionReason); + if (formattedIsContainer) + return Reject($"DPC/Dbpos signal {signalReference} remained a structured report value after scalar projection.", out rejectionReason); + return true; + } + + // Quality and explicitly typed BIT STRING DataAttributes legitimately use MMS + // BIT STRING encoding. Do not mistake those process values for report metadata. + if (IsNativeBitString(type)) + return true; + + if (IsScalar(type)) + { + if (formattedIsBits) + return Reject($"Scalar signal {signalReference} received report BIT STRING metadata instead of its process value.", out rejectionReason); + if (formattedIsContainer) + return Reject($"Scalar signal {signalReference} remained a structured/array report value after projection.", out rejectionReason); + } + + // If metadata names are absent or vendor-specific, do not invent a type. + // The engine's strict frame mapper remains the primary authority. + // We only fail closed where ARSAS already has enough signal typing evidence. + return true; + } + + private static bool Reject(string reason, out string rejectionReason) + { + rejectionReason = reason; + return false; + } + + private static bool IsContainer(string value) + => value.StartsWith("Structure(", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("Struct(", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("Array(", StringComparison.OrdinalIgnoreCase); + + private static bool IsBoolean(string dataType) + { + var normalized = dataType.Trim().ToLowerInvariant(); + return normalized is "bool" or "boolean" or "sps" or "singlepointstatus"; + } + + private static bool IsDbpos(string dataType) + { + var normalized = dataType.Trim().ToLowerInvariant(); + return normalized is "dbpos" or "dpc" or "doublepointstatus"; + } + + private static bool IsNativeBitString(string dataType) + { + var normalized = dataType.Trim().ToLowerInvariant().Replace(" ", string.Empty); + return normalized.Contains("bitstring", StringComparison.Ordinal) || + normalized is "bit-string" or "bits" or "quality"; + } + + private static bool IsScalar(string dataType) + { + var normalized = dataType.Trim().ToLowerInvariant(); + if (normalized.Length == 0) + return false; + + return IsBoolean(dataType) || IsDbpos(dataType) || + normalized.Contains("int", StringComparison.Ordinal) || + normalized.Contains("uint", StringComparison.Ordinal) || + normalized.Contains("float", StringComparison.Ordinal) || + normalized.Contains("double", StringComparison.Ordinal) || + normalized.Contains("decimal", StringComparison.Ordinal) || + normalized.Contains("counter", StringComparison.Ordinal) || + normalized.Contains("bcr", StringComparison.Ordinal) || + normalized is "enum" or "enumerated" or "timestamp"; + } +} diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 0662ab095..51f5571ba 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "e23b295b87760be8f7f0ce978a6987027ea50523", - "sourcePullRequest": 80, - "purpose": "Pins the ARIEC61850 engine used by ARSAS. 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 resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, and PR #80 normalizes validated DataRef-enabled InformationReport ordering so metadata cannot shift into process values." + "commit": "becda399b4a3ae34831215fc915798b4f846c1be", + "sourcePullRequest": 81, + "purpose": "Pins the ARIEC61850 engine used by ARSAS. 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 resolved runtime primary leaves from the 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, and PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values." } diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs index ff13b7a0a..5cb5c3e37 100644 --- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -72,11 +72,13 @@ public void EngineLock_PinsMergedReportProjectionEngineWithoutLosingMemberCentri { var source = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); - Assert.Contains("e23b295b87760be8f7f0ce978a6987027ea50523", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 80", source, StringComparison.Ordinal); + Assert.Contains("becda399b4a3ae34831215fc915798b4f846c1be", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 81", source, StringComparison.Ordinal); Assert.Contains("one descriptor per static DataSet member", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("generic Boolean status structures", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("DataRef-enabled InformationReport ordering", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("zero OptFlds", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("quarantining unmapped canonical report metadata", source, StringComparison.OrdinalIgnoreCase); } [Fact] diff --git a/tests/ARSAS.Tests/ReportProcessValueSafetyIntegrationTests.cs b/tests/ARSAS.Tests/ReportProcessValueSafetyIntegrationTests.cs new file mode 100644 index 000000000..a4ec1978f --- /dev/null +++ b/tests/ARSAS.Tests/ReportProcessValueSafetyIntegrationTests.cs @@ -0,0 +1,34 @@ +namespace ARSAS.Tests; + +public sealed class ReportProcessValueSafetyIntegrationTests +{ + [Fact] + public void ReportSafetyGate_RunsBefore_ReportValueMutation() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + + var gate = source.IndexOf("ReportProcessValueSafety.IsSafe", StringComparison.Ordinal); + var rejection = source.IndexOf("REPORT_VALUE_REJECTED", StringComparison.Ordinal); + var apply = source.IndexOf("ApplyValueUpdate(", gate >= 0 ? gate : 0, StringComparison.Ordinal); + + Assert.True(gate >= 0, "Report process-value safety gate is missing from the runtime report ingestion path."); + Assert.True(rejection > gate, "Rejected report values must emit explicit diagnostic evidence."); + Assert.True(apply > rejection, "Report value safety must execute before ApplyValueUpdate can mutate state or raise SOE events."); + Assert.Contains("state.ReportChangeVerified = false", 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}'."); + } +} diff --git a/tests/ARSAS.Tests/ReportProcessValueSafetyTests.cs b/tests/ARSAS.Tests/ReportProcessValueSafetyTests.cs new file mode 100644 index 000000000..a92729858 --- /dev/null +++ b/tests/ARSAS.Tests/ReportProcessValueSafetyTests.cs @@ -0,0 +1,122 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class ReportProcessValueSafetyTests +{ + [Fact] + public void BooleanSignal_Rejects_ReportBitStringMetadata() + { + const string raw = "bits(0000, unused=6)"; + var formatted = Iec61850ValueFormatter.Format(raw, "Boolean", string.Empty); + + var safe = ReportProcessValueSafety.IsSafe( + raw, + formatted, + "Boolean", + "AA1C1F13R4DSQZ1/CILO1.EnaOpn.stVal", + out var reason); + + Assert.False(safe); + Assert.Contains("Boolean", reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("BIT STRING", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void DbposSignal_Allows_ExactTwoBitProcessValue() + { + const string raw = "bits(40, unused=6)"; + var formatted = Iec61850ValueFormatter.Format(raw, "Dbpos", string.Empty); + + var safe = ReportProcessValueSafety.IsSafe( + raw, + formatted, + "Dbpos", + "AA1C1F13R4DSQZ1/CSWI1.Pos.stVal", + out var reason); + + Assert.True(safe, reason); + Assert.Equal("Open [01]", formatted); + } + + [Fact] + public void DbposSignal_Rejects_InclusionBitmapMasqueradingAsValue() + { + const string raw = "bits(FFFFFFFFF0, unused=4)"; + var formatted = Iec61850ValueFormatter.Format(raw, "Dbpos", string.Empty); + + var safe = ReportProcessValueSafety.IsSafe( + raw, + formatted, + "Dbpos", + "AA1C1F13R4DSQZ1/CSWI1.Pos.stVal", + out var reason); + + Assert.False(safe); + Assert.Contains("non-2-bit", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BooleanSignal_Allows_StructuredStVal_WhenFormatterProjectsScalar() + { + const string raw = "Structure(3) {stVal=false, q=bits(0000, unused=3), t=2026-08-16 04:00:00 UTC}"; + var formatted = Iec61850ValueFormatter.Format(raw, "Boolean", string.Empty); + + var safe = ReportProcessValueSafety.IsSafe( + raw, + formatted, + "Boolean", + "AA1C1F13R4ADD/GGIO6.CBOpnd.stVal", + out var reason); + + Assert.True(safe, reason); + Assert.Equal("False", formatted); + } + + [Fact] + public void ScalarSignal_Rejects_UnprojectedStructure() + { + const string raw = "Structure(3) {mag=123.4, q=bits(0000, unused=3), t=2026-08-16 04:00:00 UTC}"; + var formatted = Iec61850ValueFormatter.Format(raw, "Float", "A"); + + var safe = ReportProcessValueSafety.IsSafe( + raw, + formatted, + "Float", + "AA1C1F13R4RPRE_MMXU1/A.phsA.cVal.mag.f", + out var reason); + + Assert.False(safe); + Assert.Contains("structured", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void NativeBitStringSignal_RemainsAllowed() + { + const string raw = "bits(A0, unused=3)"; + + var safe = ReportProcessValueSafety.IsSafe( + raw, + raw, + "BitString", + "IEDLD0/GGIO1.SomeBits.stVal", + out var reason); + + Assert.True(safe, reason); + } + + [Fact] + public void QualitySignal_RemainsAllowedAsNativeBitString() + { + const string raw = "bits(0000, unused=3)"; + + var safe = ReportProcessValueSafety.IsSafe( + raw, + raw, + "Quality", + "IEDLD0/GGIO1.Ind1.q", + out var reason); + + Assert.True(safe, reason); + } +}