Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}

Expand Down Expand Up @@ -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 &&
Expand Down
126 changes: 126 additions & 0 deletions Services/ReportProcessValueSafety.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System.Text.RegularExpressions;

namespace ArIED61850Tester.Services;

/// <summary>
/// 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.
/// </summary>
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";
}
}
6 changes: 3 additions & 3 deletions engines/ARIEC61850.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
34 changes: 34 additions & 0 deletions tests/ARSAS.Tests/ReportProcessValueSafetyIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -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}'.");
}
}
122 changes: 122 additions & 0 deletions tests/ARSAS.Tests/ReportProcessValueSafetyTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading