diff --git a/CHANGELOG.md b/CHANGELOG.md index ddca178..e797849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,15 @@ ### 新增 +- 增加独立 SecsFrame.Trace 程序集和版本化 + SecsFrame-Trace/1 信封,为已解码数据消息提供确定性导出与 + 严格读取,并保留 UTC 时间、方向和可选 HSMS 诊断标识。 +- 增加按 S/F 与 Item List 路径执行的结构化替换脱敏;路径漂移和重叠规则 + 明确失败,避免敏感值因字符串替换或 Schema 变化而静默泄漏。 +- 增加显式 allowlist 的受控重放;只重放 Sent 记录,忽略旧 Session ID、 + System Bytes、时间和回复令牌,通过公共发送 API 创建新事务。 +- 增加 Trace 黄金向量、畸形信封、资源限制、脱敏泄漏、预验证和真实 TCP + 新事务测试。 - 增加独立 SecsFrame.Sml 程序集,为动态消息和全部 Item 类型 提供确定性 SML 调试文本写出与严格读取。 - 增加 ASCII 可逆转义、JIS-8 原始十六进制字节、InvariantCulture 数字、 diff --git a/README.md b/README.md index 3f0c492..926e42f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ | `SecsFrame` | SECS-II 数据模型、HSMS-SS 会话与事务基础 | | `SecsFrame.Gem` | GEM 通用状态模型与能力服务 | | `SecsFrame.Sml` | 动态消息的确定性 SML 调试读写 | +| `SecsFrame.Trace` | 已解码消息的导出、结构化脱敏与受控重放 | | `SecsFrame.Smn` | E173 SMN 日志、文档与消息表示 | | `SecsFrame.Sedd` | E172 SEDD 设备接口数据字典 | @@ -40,6 +41,9 @@ Equipment 应用发送策略的 Collection Event、报警目录查询、单报 独立 SecsFrame.Sml 为相同动态消息模型提供确定性文本写出、 严格读取、全部 Item 类型往返和显式资源边界;它是非规范性调试 profile, 不进入线上通讯路径。 +SecsFrame.Trace 在该文本层上提供版本化的数据消息 Trace 信封、 +严格的 Item 路径脱敏和显式 allowlist 重放;它不会接管连接事件消费者, +也不会复用原事务标识。 Item 用法与边界见 [docs/SECS-II-ITEMS.md](docs/SECS-II-ITEMS.md),消息集成见 [docs/SECS-MESSAGES.md](docs/SECS-MESSAGES.md),传输适配边界见 @@ -59,6 +63,8 @@ Host/Equipment 端点见 [docs/GEM-FOUNDATION.md](docs/GEM-FOUNDATION.md), SML 调试文本见 [docs/SML.md](docs/SML.md), +Trace 工具边界见 +[docs/TRACE.md](docs/TRACE.md), 官方 secs4net 跨实现证据见 [docs/SECS4NET-INTEROP.md](docs/SECS4NET-INTEROP.md), 整体路线图见 diff --git a/SecsFrame.slnx b/SecsFrame.slnx index 0e90820..273bb9e 100644 --- a/SecsFrame.slnx +++ b/SecsFrame.slnx @@ -2,6 +2,7 @@ + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 40e1cbd..4f26f72 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,6 +13,8 @@ StreamFrame TCP、Pipe、帧边界、连接生命周期 E172 SEDD 与 E173 SMN 是可选元数据/表示层,不进入核心通讯路径。 SecsFrame.Sml 同样是只依赖核心动态消息模型的可选调试表示层; 核心包不反向依赖它。 +SecsFrame.Trace 依赖 SML 表示层和核心公共发送 API,只处理已经 +解码的数据消息;它不订阅内部 transport 或创建第二个公共事件消费者。 ## 核心原则 @@ -160,5 +162,7 @@ HSMS 头 Session ID 与 System Bytes。没有 W-Bit 的出站消息在写出后 - `SecsFrame` 可以依赖 `StreamFrame`。 - `SecsFrame.Gem` 依赖 `SecsFrame`。 - `SecsFrame.Sml` 依赖 `SecsFrame`,不得依赖 GEM 或线上传输实现。 +- `SecsFrame.Trace` 依赖 `SecsFrame.Sml` 与 `SecsFrame`,重放必须回到 + 公共发送 API,不得直接构造或写出保留旧事务标识的线上帧。 - 核心包不得依赖 GEM、SML/SMN、依赖注入容器或具体日志实现。 - 状态机计时使用可替换的时间抽象,测试不得依赖真实长时间等待。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index feba74b..af9d459 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -95,15 +95,21 @@ Collection Event 发送策略沿用相同的默认不注册模式;拒绝发生 E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写已经基于许可 清晰的公开实现完成非规范性 profile,为现有动态 SecsMessage 和全部 SecsItem 类型提供确定性文本写出、严格读取、资源边界与 -自主协议向量,不宣称标准 SML 合规。下一切片在该稳定文本表示上增加 Trace -导出、结构化脱敏与受控重放。 +自主协议向量,不宣称标准 SML 合规。已在该稳定文本表示上增加已解码数据 +消息的版本化 Trace 导出、结构化脱敏与显式 allowlist 受控重放;重放通过 +公共发送 API 创建新事务,不复用旧会话或 System Bytes。 ## 阶段 3:元数据与工具 - [x] SML 调试读写。 - E173 SMN 日志与消息文档。 - E172 SEDD 设备字典导入、校验与发现。 -- Trace 重放、脱敏和互操作诊断工具。 +- [x] 已解码数据消息的 Trace 导出、结构化脱敏和受控重放基线。 +- 控制消息、结构化诊断、故障原始样本和重放时序等互操作诊断扩展。 + +当前 Trace 基线不接管连接事件流,也不把原始标识写回线上。后续控制与故障 +诊断扩展必须先定义原始字节的数据分级、默认不导出策略和可验证脱敏边界。 +E173 SMN 与 E172 SEDD 仍需先核对授权标准及允许分发的 Schema/表示形式。 ## 发布门槛 diff --git a/docs/TRACE.md b/docs/TRACE.md new file mode 100644 index 0000000..f8bc555 --- /dev/null +++ b/docs/TRACE.md @@ -0,0 +1,114 @@ +# Trace 导出、脱敏与重放 + +## 范围 + +SecsFrame.Trace 为已经解码的 SECS-II Data Message 提供: + +- 确定性的 SecsFrame-Trace/1 文本导出和严格读取; +- 按 Stream、Function 与 Item List 路径执行的结构化替换脱敏; +- 只选择显式允许的本地发送记录,并通过正常公共发送 API 串行重放。 + +当前切片不是原始网络抓包器,不记录 TCP 字节、transport Session generation、 +控制帧或异常对象,也不会接管 HsmsConnection 的单消费者事件流。 +应用应在自己的事件循环中从 HsmsIncomingDataMessage 创建接收记录, +并在调用发送 API 前显式创建发送记录。 + +## 记录与导出 + +SecsTraceRecord 保存 UTC 时间、本地方向、动态 +SecsMessage,以及可选的协议 Session ID 和 System Bytes。 +发送前通常尚不知道新事务的 System Bytes,因此这些 HSMS 字段允许缺省: + +~~~csharp +using SecsFrame.Trace; + +var sent = SecsTraceRecord.CreateSent( + DateTimeOffset.UtcNow, + primary, + sessionId: connection.Options.SessionId); + +var received = SecsTraceRecord.CreateReceived( + DateTimeOffset.UtcNow, + connectionEvent.IncomingMessage!); + +var codec = new SecsTraceCodec(); +var text = codec.Encode(new[] { sent, received }); +var records = codec.Decode(text); +~~~ + +文件信封固定使用 LF,并在每条记录前写出随后 SML 块的字符长度: + +~~~text +SecsFrame-Trace/1 +Record 2026-08-29T05:00:00.0000000Z Sent 10 - 10 +'S1F1'W +. +~~~ + +字符长度使 ASCII 转义、句点和换行不会被误识别成记录边界。Trace codec +限制总字符数与记录数;每个消息继续受 SmlMessageCodec 的深度、 +Item、值和文本长度限制。解析错误使用包含记录索引与字符偏移的 +SecsTraceParseException。 + +该格式是 SecsFrame 的版本化诊断信封,不是 SEMI 标准、PCAP 或原始 HSMS +wire trace。协议标识只用于问题关联,不能作为新连接上的重放标识。 + +## 结构化脱敏 + +脱敏发生在不可变 Item 树上,而不是对 SML 文本做字符串替换: + +~~~csharp +var redactor = new SecsTraceRedactor(new[] +{ + new SecsTraceRedactionRule( + stream: 6, + function: 11, + itemPath: new[] { 0 }, + replacement: SecsItem.Ascii("REDACTED")), +}); + +var safeRecord = redactor.Redact(received); +var safeText = codec.Encode(new[] { safeRecord }); +~~~ + +空路径选择根 Item,后续整数逐层选择 List 子项。匹配消息缺少根 Item、路径 +进入非 List 或索引越界时立即失败,避免消息 Schema 漂移后静默漏脱敏。 +同一 S/F 上相同或祖先/后代重叠的规则在构造时拒绝;互不重叠的兄弟路径 +可以一起替换。原记录和原 Item 树不会被修改。 + +SecsTraceCodec 本身不会自动脱敏。写入磁盘、日志、Issue 或测试 +附件前,应用必须根据自身数据分级先执行规则,并对导出结果使用常规访问控制 +和保留策略。 + +## 受控重放 + +SecsTraceReplayer 要求调用方提供显式 allowlist predicate: + +~~~csharp +var results = await new SecsTraceReplayer().ReplayAsync( + records, + connection, + record => record.Message.Stream == 1 && + record.Message.Function == 1, + cancellationToken); +~~~ + +重放具有以下固定边界: + +- 先完整枚举、验证记录数量并计算 allowlist,再开始第一次发送; +- 只考虑 Sent 记录,Received 永远跳过; +- 按源顺序逐条等待正常 SendAsync,W-Bit 继续使用现有 T3; +- 忽略原时间、Session ID、System Bytes 和回复令牌,由活连接创建新事务; +- 不等待连接进入 Selected,不自动重试,也不按原时间间隔暂停; +- 返回每条已发送记录及新事务获得的可选 Secondary。 + +allowlist 是业务安全边界。库不会依据 Function 奇偶、消息名称或未知设备状态 +推断 Primary/Secondary、幂等性或命令权限;应用只能允许已确认可在目标设备 +与当前状态执行的消息。 + +## 验证边界 + +测试覆盖确定性信封、多记录往返、畸形头与长度、SML 嵌入错误、记录和文本 +资源限制、路径漂移、规则重叠、敏感值不进入导出文本、预验证零发送,以及 +真实 TCP 上重新分配 Session ID/System Bytes 并获得新 Secondary。它证明 +当前工具组合边界,不代表设备命令安全性或 SEMI 一致性认证。 diff --git a/src/SecsFrame.Trace/SecsFrame.Trace.csproj b/src/SecsFrame.Trace/SecsFrame.Trace.csproj new file mode 100644 index 0000000..b33455e --- /dev/null +++ b/src/SecsFrame.Trace/SecsFrame.Trace.csproj @@ -0,0 +1,24 @@ + + + netstandard2.0;net8.0;net10.0 + Deterministic trace export, redaction, and controlled replay for SecsFrame. + SecsFrame.Trace + MIT + README.md + secs;hsms;trace;redaction;replay;diagnostics;secsframe + 0.1.0-alpha.1 + true + false + true + snupkg + + + + + + + + + + + diff --git a/src/SecsFrame.Trace/SecsTraceCodec.cs b/src/SecsFrame.Trace/SecsTraceCodec.cs new file mode 100644 index 0000000..9e560f7 --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceCodec.cs @@ -0,0 +1,250 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; +using SecsFrame.Sml; + +namespace SecsFrame.Trace; + +/// Encodes and decodes deterministic decoded-message trace files. +public sealed class SecsTraceCodec +{ + /// The exact trace format identifier. + public const string FormatIdentifier = "SecsFrame-Trace/1"; + + /// The default maximum number of records in one trace. + public const int DefaultMaxRecordCount = 100_000; + + /// The default maximum trace text length. + public const int DefaultMaxTextLength = 64 * 1024 * 1024; + + /// Creates a trace codec with explicit resource limits. + public SecsTraceCodec( + SmlMessageCodec? messageCodec = null, + int maxRecordCount = DefaultMaxRecordCount, + int maxTextLength = DefaultMaxTextLength) + { + if (maxRecordCount <= 0) + throw new ArgumentOutOfRangeException(nameof(maxRecordCount), maxRecordCount, "The maximum record count must be positive."); + if (maxTextLength <= 0) + throw new ArgumentOutOfRangeException(nameof(maxTextLength), maxTextLength, "The maximum text length must be positive."); + + MessageCodec = messageCodec ?? new SmlMessageCodec(); + MaxRecordCount = maxRecordCount; + MaxTextLength = maxTextLength; + } + + /// Gets the SML codec used for each decoded data message. + public SmlMessageCodec MessageCodec { get; } + + /// Gets the maximum number of records in one trace. + public int MaxRecordCount { get; } + + /// Gets the maximum accepted or produced trace text length. + public int MaxTextLength { get; } + + /// Encodes records in enumeration order using LF line endings. + public string Encode(IEnumerable records) + { + if (records is null) + throw new ArgumentNullException(nameof(records)); + + var text = new StringBuilder(FormatIdentifier); + text.Append('\n'); + var recordCount = 0; + foreach (var record in records) + { + if (record is null) + throw new ArgumentException("The trace sequence contains a null record.", nameof(records)); + if (++recordCount > MaxRecordCount) + throw new InvalidOperationException($"The trace record count exceeds the configured maximum {MaxRecordCount}."); + + var messageText = MessageCodec.Encode(record.Message); + AppendRecordHeader(text, record, messageText.Length); + text.Append(messageText); + if (text.Length > MaxTextLength) + throw new InvalidOperationException($"The trace text length exceeds the configured maximum {MaxTextLength}."); + } + + return text.ToString(); + } + + /// Strictly decodes one complete trace file. + public IReadOnlyList Decode(string text) + { + if (text is null) + throw new ArgumentNullException(nameof(text)); + if (text.Length > MaxTextLength) + throw new ArgumentOutOfRangeException(nameof(text), text.Length, $"The trace text length cannot exceed {MaxTextLength} characters."); + + return new SecsTraceParser(text, this).Parse(); + } + + private static void AppendRecordHeader(StringBuilder text, SecsTraceRecord record, int messageLength) + { + text.Append("Record "); + text.Append(record.Timestamp.UtcDateTime.ToString("O", CultureInfo.InvariantCulture)); + text.Append(' '); + text.Append(record.Direction == SecsTraceDirection.Sent ? "Sent" : "Received"); + text.Append(' '); + text.Append(record.SessionId.HasValue + ? record.SessionId.Value.ToString(CultureInfo.InvariantCulture) + : "-"); + text.Append(' '); + text.Append(record.SystemBytes.HasValue + ? "0x" + record.SystemBytes.Value.ToString("X8", CultureInfo.InvariantCulture) + : "-"); + text.Append(' '); + text.Append(messageLength.ToString(CultureInfo.InvariantCulture)); + text.Append('\n'); + } + + private sealed class SecsTraceParser + { + private readonly string _text; + private readonly SecsTraceCodec _options; + private int _index; + private int _recordIndex; + + public SecsTraceParser(string text, SecsTraceCodec options) + { + _text = text; + _options = options; + } + + public IReadOnlyList Parse() + { + var headerOffset = _index; + var formatIdentifier = ReadLine(); + if (!string.Equals(formatIdentifier, SecsTraceCodec.FormatIdentifier, StringComparison.Ordinal)) + throw Error("The trace format identifier is missing or unsupported", recordIndex: -1, headerOffset); + + var records = new List(); + while (!IsEnd) + { + if (_recordIndex >= _options.MaxRecordCount) + throw Error($"The trace record count exceeds the configured maximum {_options.MaxRecordCount}", _recordIndex, _index); + + records.Add(ParseRecord()); + _recordIndex++; + } + + return new ReadOnlyCollection(records); + } + + private SecsTraceRecord ParseRecord() + { + var headerOffset = _index; + var header = ReadLine(); + var fields = header.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length != 6 || !string.Equals(fields[0], "Record", StringComparison.Ordinal)) + throw Error("A record header must contain exactly six fields", _recordIndex, headerOffset); + + var timestamp = ParseTimestamp(fields[1], headerOffset); + var direction = ParseDirection(fields[2], headerOffset); + var sessionId = ParseSessionId(fields[3], headerOffset); + var systemBytes = ParseSystemBytes(fields[4], headerOffset); + var messageLength = ParseMessageLength(fields[5], headerOffset); + if (messageLength > _text.Length - _index) + throw Error("The SML block is shorter than its declared character length", _recordIndex, _index); + + var messageOffset = _index; + var messageText = _text.Substring(_index, messageLength); + _index += messageLength; + SecsMessage message; + try + { + message = _options.MessageCodec.Decode(messageText); + } + catch (SmlParseException ex) + { + throw Error("The record contains invalid SML", _recordIndex, messageOffset + ex.Offset, ex); + } + + return new SecsTraceRecord(timestamp, direction, message, sessionId, systemBytes); + } + + private DateTimeOffset ParseTimestamp(string value, int offset) + { + if (!DateTimeOffset.TryParseExact( + value, + "O", + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var timestamp)) + { + throw Error("The record timestamp must use the round-trip format", _recordIndex, offset); + } + + return timestamp; + } + + private SecsTraceDirection ParseDirection(string value, int offset) + => value switch + { + "Sent" => SecsTraceDirection.Sent, + "Received" => SecsTraceDirection.Received, + _ => throw Error("The record direction must be Sent or Received", _recordIndex, offset), + }; + + private ushort? ParseSessionId(string value, int offset) + { + if (string.Equals(value, "-", StringComparison.Ordinal)) + return null; + if (!ushort.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var sessionId)) + throw Error("The protocol Session ID must be '-' or an unsigned 16-bit integer", _recordIndex, offset); + return sessionId; + } + + private uint? ParseSystemBytes(string value, int offset) + { + if (string.Equals(value, "-", StringComparison.Ordinal)) + return null; + if (value.Length != 10 || value[0] != '0' || value[1] != 'x') + throw Error("System Bytes must be '-' or use the exact form 0xHHHHHHHH", _recordIndex, offset); + + for (var index = 2; index < value.Length; index++) + { + var character = value[index]; + if (!((character >= '0' && character <= '9') || (character >= 'A' && character <= 'F'))) + throw Error("System Bytes contain an invalid uppercase hexadecimal digit", _recordIndex, offset); + } + + if (!uint.TryParse(value.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var systemBytes)) + throw Error("System Bytes are outside the unsigned 32-bit range", _recordIndex, offset); + return systemBytes; + } + + private int ParseMessageLength(string value, int offset) + { + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var length) || length <= 0) + throw Error("The SML character length must be a positive integer", _recordIndex, offset); + if (length > _options.MessageCodec.MaxTextLength) + throw Error($"The SML character length exceeds the configured maximum {_options.MessageCodec.MaxTextLength}", _recordIndex, offset); + return length; + } + + private string ReadLine() + { + var start = _index; + while (!IsEnd && _text[_index] != '\n') + _index++; + + var length = _index - start; + if (length > 0 && _text[start + length - 1] == '\r') + length--; + var line = _text.Substring(start, length); + if (!IsEnd) + _index++; + return line; + } + + private bool IsEnd => _index >= _text.Length; + + private static SecsTraceParseException Error( + string message, + int recordIndex, + int offset, + Exception? innerException = null) + => new(message, recordIndex, offset, innerException); + } +} diff --git a/src/SecsFrame.Trace/SecsTraceDirection.cs b/src/SecsFrame.Trace/SecsTraceDirection.cs new file mode 100644 index 0000000..7fd9e6d --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceDirection.cs @@ -0,0 +1,11 @@ +namespace SecsFrame.Trace; + +/// Identifies the local direction of a decoded data message. +public enum SecsTraceDirection +{ + /// The local endpoint sent the message. + Sent, + + /// The local endpoint received the message. + Received, +} diff --git a/src/SecsFrame.Trace/SecsTraceParseException.cs b/src/SecsFrame.Trace/SecsTraceParseException.cs new file mode 100644 index 0000000..4347e1e --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceParseException.cs @@ -0,0 +1,18 @@ +namespace SecsFrame.Trace; + +/// Reports a strict trace envelope parsing failure. +public sealed class SecsTraceParseException : FormatException +{ + internal SecsTraceParseException(string message, int recordIndex, int offset, Exception? innerException = null) + : base($"{message} (record {recordIndex}, offset {offset}).", innerException) + { + RecordIndex = recordIndex; + Offset = offset; + } + + /// Gets the zero-based record index, or -1 for the file header. + public int RecordIndex { get; } + + /// Gets the zero-based character offset. + public int Offset { get; } +} diff --git a/src/SecsFrame.Trace/SecsTraceRecord.cs b/src/SecsFrame.Trace/SecsTraceRecord.cs new file mode 100644 index 0000000..43c177f --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceRecord.cs @@ -0,0 +1,65 @@ +namespace SecsFrame.Trace; + +/// +/// Describes one decoded SECS-II data message with optional HSMS diagnostic +/// identifiers. +/// +public sealed class SecsTraceRecord +{ + /// Creates an immutable decoded-message trace record. + public SecsTraceRecord( + DateTimeOffset timestamp, + SecsTraceDirection direction, + SecsMessage message, + ushort? sessionId = null, + uint? systemBytes = null) + { + if (direction != SecsTraceDirection.Sent && direction != SecsTraceDirection.Received) + throw new ArgumentOutOfRangeException(nameof(direction), direction, "Unknown trace direction."); + + Timestamp = timestamp.ToUniversalTime(); + Direction = direction; + Message = message ?? throw new ArgumentNullException(nameof(message)); + SessionId = sessionId; + SystemBytes = systemBytes; + } + + /// Gets the UTC timestamp associated with the local observation. + public DateTimeOffset Timestamp { get; } + + /// Gets the local message direction. + public SecsTraceDirection Direction { get; } + + /// Gets the optional HSMS protocol Session ID. + public ushort? SessionId { get; } + + /// Gets the optional original System Bytes value. + public uint? SystemBytes { get; } + + /// Gets the decoded dynamic SECS-II message. + public SecsMessage Message { get; } + + /// Creates a sent record before a public connection send. + public static SecsTraceRecord CreateSent( + DateTimeOffset timestamp, + SecsMessage message, + ushort? sessionId = null) + => new(timestamp, SecsTraceDirection.Sent, message, sessionId); + + /// Creates a received record from a decoded incoming message. + public static SecsTraceRecord CreateReceived( + DateTimeOffset timestamp, + HsmsIncomingDataMessage incoming) + { + if (incoming is null) + throw new ArgumentNullException(nameof(incoming)); + + var dataMessage = incoming.DataMessage; + return new SecsTraceRecord( + timestamp, + SecsTraceDirection.Received, + dataMessage.Message, + dataMessage.SessionId, + dataMessage.SystemBytes); + } +} diff --git a/src/SecsFrame.Trace/SecsTraceRedactionRule.cs b/src/SecsFrame.Trace/SecsTraceRedactionRule.cs new file mode 100644 index 0000000..021a59d --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceRedactionRule.cs @@ -0,0 +1,52 @@ +using System.Collections.ObjectModel; + +namespace SecsFrame.Trace; + +/// +/// Replaces one Item selected by Stream, Function, and zero-based List path. +/// +public sealed class SecsTraceRedactionRule +{ + private readonly ReadOnlyCollection _itemPath; + + /// Creates one exact structural redaction rule. + /// The matching message stream. + /// The matching message function. + /// List indexes from the root Item; empty selects the root. + /// The replacement Item written into the exported copy. + public SecsTraceRedactionRule( + byte stream, + byte function, + IEnumerable itemPath, + SecsItem replacement) + { + if (stream > 0x7F) + throw new ArgumentOutOfRangeException(nameof(stream), stream, "The stream number must be between 0 and 127."); + if (itemPath is null) + throw new ArgumentNullException(nameof(itemPath)); + + var path = itemPath.ToArray(); + for (var index = 0; index < path.Length; index++) + { + if (path[index] < 0) + throw new ArgumentOutOfRangeException(nameof(itemPath), path[index], "Item path indexes cannot be negative."); + } + + Stream = stream; + Function = function; + _itemPath = Array.AsReadOnly(path); + Replacement = replacement ?? throw new ArgumentNullException(nameof(replacement)); + } + + /// Gets the matching message stream. + public byte Stream { get; } + + /// Gets the matching message function. + public byte Function { get; } + + /// Gets the zero-based List path from the root Item. + public IReadOnlyList ItemPath => _itemPath; + + /// Gets the immutable replacement Item. + public SecsItem Replacement { get; } +} diff --git a/src/SecsFrame.Trace/SecsTraceRedactor.cs b/src/SecsFrame.Trace/SecsTraceRedactor.cs new file mode 100644 index 0000000..bdb3b43 --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceRedactor.cs @@ -0,0 +1,100 @@ +namespace SecsFrame.Trace; + +/// Applies exact structural replacement rules before trace export. +public sealed class SecsTraceRedactor +{ + private readonly SecsTraceRedactionRule[] _rules; + + /// Creates a redactor and rejects ambiguous overlapping rules. + public SecsTraceRedactor(IEnumerable rules) + { + if (rules is null) + throw new ArgumentNullException(nameof(rules)); + + _rules = rules.ToArray(); + for (var index = 0; index < _rules.Length; index++) + { + if (_rules[index] is null) + throw new ArgumentException("The redaction rule sequence contains a null rule.", nameof(rules)); + for (var otherIndex = index + 1; otherIndex < _rules.Length; otherIndex++) + { + if (_rules[otherIndex] is null) + throw new ArgumentException("The redaction rule sequence contains a null rule.", nameof(rules)); + if (RulesOverlap(_rules[index], _rules[otherIndex])) + throw new ArgumentException("Redaction rules for the same message cannot target identical or ancestor paths.", nameof(rules)); + } + } + } + + /// + /// Returns the original record when no rule matches, otherwise a copy with + /// replacements applied. + /// + public SecsTraceRecord Redact(SecsTraceRecord record) + { + if (record is null) + throw new ArgumentNullException(nameof(record)); + + var rootItem = record.Message.RootItem; + var changed = false; + foreach (var rule in _rules) + { + if (rule.Stream != record.Message.Stream || rule.Function != record.Message.Function) + continue; + if (rootItem is null) + throw new InvalidOperationException("A matching redaction rule cannot resolve because the message has no root Item."); + + rootItem = ReplaceAtPath(rootItem, rule.ItemPath, pathIndex: 0, rule.Replacement); + changed = true; + } + + if (!changed) + return record; + + var message = new SecsMessage( + record.Message.Stream, + record.Message.Function, + record.Message.ReplyExpected, + rootItem); + return new SecsTraceRecord( + record.Timestamp, + record.Direction, + message, + record.SessionId, + record.SystemBytes); + } + + private static SecsItem ReplaceAtPath( + SecsItem item, + IReadOnlyList path, + int pathIndex, + SecsItem replacement) + { + if (pathIndex == path.Count) + return replacement; + if (item.Format != SecsItemFormat.List) + throw new InvalidOperationException($"The redaction path enters a non-List Item at depth {pathIndex}."); + + var childIndex = path[pathIndex]; + if (childIndex >= item.Items.Count) + throw new InvalidOperationException($"The redaction path index {childIndex} is outside the List at depth {pathIndex}."); + + var items = item.Items.ToArray(); + items[childIndex] = ReplaceAtPath(items[childIndex], path, pathIndex + 1, replacement); + return SecsItem.List(items); + } + + private static bool RulesOverlap(SecsTraceRedactionRule left, SecsTraceRedactionRule right) + { + if (left.Stream != right.Stream || left.Function != right.Function) + return false; + + var sharedLength = Math.Min(left.ItemPath.Count, right.ItemPath.Count); + for (var index = 0; index < sharedLength; index++) + { + if (left.ItemPath[index] != right.ItemPath[index]) + return false; + } + return true; + } +} diff --git a/src/SecsFrame.Trace/SecsTraceReplayResult.cs b/src/SecsFrame.Trace/SecsTraceReplayResult.cs new file mode 100644 index 0000000..2e35c27 --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceReplayResult.cs @@ -0,0 +1,17 @@ +namespace SecsFrame.Trace; + +/// Describes one sent trace record and its optional new Secondary. +public sealed class SecsTraceReplayResult +{ + internal SecsTraceReplayResult(SecsTraceRecord record, HsmsDataMessage? secondary) + { + Record = record; + Secondary = secondary; + } + + /// Gets the source trace record. + public SecsTraceRecord Record { get; } + + /// Gets the Secondary produced by the new transaction, when requested. + public HsmsDataMessage? Secondary { get; } +} diff --git a/src/SecsFrame.Trace/SecsTraceReplayer.cs b/src/SecsFrame.Trace/SecsTraceReplayer.cs new file mode 100644 index 0000000..fa29005 --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceReplayer.cs @@ -0,0 +1,81 @@ +using System.Collections.ObjectModel; + +namespace SecsFrame.Trace; + +/// Replays explicitly allowed sent records through a normal send API. +public sealed class SecsTraceReplayer +{ + /// The default maximum number of source records in one replay request. + public const int DefaultMaxRecordCount = SecsTraceCodec.DefaultMaxRecordCount; + + /// Creates a controlled replayer with an explicit source-record limit. + public SecsTraceReplayer(int maxRecordCount = DefaultMaxRecordCount) + { + if (maxRecordCount <= 0) + throw new ArgumentOutOfRangeException(nameof(maxRecordCount), maxRecordCount, "The maximum record count must be positive."); + MaxRecordCount = maxRecordCount; + } + + /// Gets the maximum number of source records in one replay request. + public int MaxRecordCount { get; } + + /// + /// Replays allowed sent records through an existing selected connection. + /// Original Session ID, System Bytes, timestamps, and reply tokens are ignored. + /// + public Task> ReplayAsync( + IEnumerable records, + HsmsConnection connection, + Func isAllowed, + CancellationToken cancellationToken = default) + { + if (connection is null) + throw new ArgumentNullException(nameof(connection)); + + return ReplayAsync(records, connection.SendAsync, isAllowed, cancellationToken); + } + + /// + /// Replays allowed sent records through a caller-supplied normal send API. + /// This overload can target a role endpoint or a test boundary. + /// + public async Task> ReplayAsync( + IEnumerable records, + Func> sendAsync, + Func isAllowed, + CancellationToken cancellationToken = default) + { + if (records is null) + throw new ArgumentNullException(nameof(records)); + if (sendAsync is null) + throw new ArgumentNullException(nameof(sendAsync)); + if (isAllowed is null) + throw new ArgumentNullException(nameof(isAllowed)); + + var selected = new List(); + var sourceCount = 0; + foreach (var record in records) + { + cancellationToken.ThrowIfCancellationRequested(); + if (record is null) + throw new ArgumentException("The replay sequence contains a null record.", nameof(records)); + if (++sourceCount > MaxRecordCount) + throw new InvalidOperationException($"The replay source record count exceeds the configured maximum {MaxRecordCount}."); + if (record.Direction == SecsTraceDirection.Sent && isAllowed(record)) + selected.Add(record); + } + + var results = new List(selected.Count); + foreach (var record in selected) + { + cancellationToken.ThrowIfCancellationRequested(); + var operation = sendAsync(record.Message, cancellationToken); + if (operation is null) + throw new InvalidOperationException("The replay send delegate returned a null task."); + var secondary = await operation.ConfigureAwait(false); + results.Add(new SecsTraceReplayResult(record, secondary)); + } + + return new ReadOnlyCollection(results); + } +} diff --git a/test/SecsFrame.Tests/SecsFrame.Tests.csproj b/test/SecsFrame.Tests/SecsFrame.Tests.csproj index 2a9e300..118c13a 100644 --- a/test/SecsFrame.Tests/SecsFrame.Tests.csproj +++ b/test/SecsFrame.Tests/SecsFrame.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/test/SecsFrame.Tests/SecsTraceTests.cs b/test/SecsFrame.Tests/SecsTraceTests.cs new file mode 100644 index 0000000..47ba50b --- /dev/null +++ b/test/SecsFrame.Tests/SecsTraceTests.cs @@ -0,0 +1,319 @@ +using System.Net; +using SecsFrame.Trace; + +namespace SecsFrame.Tests; + +public sealed class SecsTraceTests +{ + [Fact] + public void Codec_produces_a_deterministic_trace_vector() + { + var timestamp = new DateTimeOffset(2026, 8, 29, 13, 0, 0, TimeSpan.FromHours(8)); + var record = SecsTraceRecord.CreateSent(timestamp, new SecsMessage(1, 1, true)); + var codec = new SecsTraceCodec(); + + var text = codec.Encode(new[] { record }); + + Assert.Equal( + "SecsFrame-Trace/1\n" + + "Record 2026-08-29T05:00:00.0000000Z Sent - - 10\n" + + "'S1F1'W\n" + + ".\n", + text); + var decoded = Assert.Single(codec.Decode(text)); + Assert.Equal(timestamp.ToUniversalTime(), decoded.Timestamp); + Assert.Equal(SecsTraceDirection.Sent, decoded.Direction); + Assert.Null(decoded.SessionId); + Assert.Null(decoded.SystemBytes); + AssertMessageEqual(record.Message, decoded.Message); + } + + [Fact] + public void Codec_round_trips_multiple_records_and_optional_hsms_identifiers() + { + var records = new[] + { + new SecsTraceRecord( + DateTimeOffset.Parse("2026-08-29T05:00:00Z", System.Globalization.CultureInfo.InvariantCulture), + SecsTraceDirection.Received, + new SecsMessage(6, 11, true, SecsItem.List(SecsItem.Ascii("LOT-1"))), + sessionId: 10, + systemBytes: 0x01020304), + SecsTraceRecord.CreateSent( + DateTimeOffset.Parse("2026-08-29T05:00:01Z", System.Globalization.CultureInfo.InvariantCulture), + new SecsMessage(6, 12, rootItem: SecsItem.Binary(0x00))), + }; + var codec = new SecsTraceCodec(); + + var decoded = codec.Decode(codec.Encode(records)); + + Assert.Equal(2, decoded.Count); + Assert.Equal(SecsTraceDirection.Received, decoded[0].Direction); + Assert.Equal((ushort)10, decoded[0].SessionId); + Assert.Equal(0x01020304u, decoded[0].SystemBytes); + AssertMessageEqual(records[0].Message, decoded[0].Message); + AssertMessageEqual(records[1].Message, decoded[1].Message); + } + + [Fact] + public void Received_factory_preserves_decoded_protocol_identifiers() + { + var dataMessage = new HsmsDataMessage(42, 0x89ABCDEF, new SecsMessage(5, 1)); + var incoming = new HsmsIncomingDataMessage(new object(), new HsmsTransportSessionId(7), dataMessage); + + var record = SecsTraceRecord.CreateReceived(DateTimeOffset.UtcNow, incoming); + + Assert.Equal(SecsTraceDirection.Received, record.Direction); + Assert.Equal((ushort)42, record.SessionId); + Assert.Equal(0x89ABCDEFu, record.SystemBytes); + Assert.Same(dataMessage.Message, record.Message); + } + + [Fact] + public void Codec_rejects_malformed_headers_lengths_and_embedded_sml() + { + var codec = new SecsTraceCodec(); + var valid = codec.Encode(new[] + { + new SecsTraceRecord( + Epoch, + SecsTraceDirection.Received, + new SecsMessage(1, 1), + 1, + 0x01020304), + }); + + Assert.Throws(() => codec.Decode(string.Empty)); + Assert.Throws(() => codec.Decode(valid.Replace("Trace/1", "Trace/2"))); + Assert.Throws(() => codec.Decode(valid.Replace("Received", "Sideways"))); + Assert.Throws(() => codec.Decode(valid.Replace("0x01020304", "0x0102030a"))); + Assert.Throws(() => codec.Decode(valid.Replace(" 9\n'S1F1'", " 99\n'S1F1'"))); + Assert.Throws(() => codec.Decode( + "SecsFrame-Trace/1\nRecord 1970-01-01T00:00:00.0000000Z Sent - - 4\nnope")); + } + + [Fact] + public void Codec_enforces_record_and_text_limits() + { + var record = SecsTraceRecord.CreateSent(Epoch, new SecsMessage(1, 1)); + var twoRecords = new[] { record, record }; + var encoded = new SecsTraceCodec().Encode(twoRecords); + + Assert.Throws(() => new SecsTraceCodec(maxRecordCount: 1).Encode(twoRecords)); + Assert.Throws(() => new SecsTraceCodec(maxRecordCount: 1).Decode(encoded)); + Assert.Throws(() => new SecsTraceCodec(maxTextLength: 20).Encode(new[] { record })); + Assert.Throws(() => new SecsTraceCodec(maxTextLength: 20).Decode(encoded)); + Assert.Throws(() => new SecsTraceCodec(maxRecordCount: 0)); + Assert.Throws(() => new SecsTraceCodec(maxTextLength: 0)); + } + + [Fact] + public void Structural_redaction_removes_sensitive_values_before_export() + { + var original = new SecsTraceRecord( + Epoch, + SecsTraceDirection.Received, + new SecsMessage( + 6, + 11, + true, + SecsItem.List( + SecsItem.Ascii("SECRET-LOT"), + SecsItem.List(SecsItem.U4(42), SecsItem.Ascii("SECRET-USER")))), + 10, + 0x01020304); + var redactor = new SecsTraceRedactor(new[] + { + new SecsTraceRedactionRule(6, 11, new[] { 0 }, SecsItem.Ascii("REDACTED")), + new SecsTraceRedactionRule(6, 11, new[] { 1, 1 }, SecsItem.Ascii("REDACTED")), + }); + + var redacted = redactor.Redact(original); + var exported = new SecsTraceCodec().Encode(new[] { redacted }); + + Assert.Equal("SECRET-LOT", original.Message.RootItem![0].GetString()); + Assert.Equal("REDACTED", redacted.Message.RootItem![0].GetString()); + Assert.Equal("REDACTED", redacted.Message.RootItem[1][1].GetString()); + Assert.Equal(original.Timestamp, redacted.Timestamp); + Assert.Equal(original.SessionId, redacted.SessionId); + Assert.Equal(original.SystemBytes, redacted.SystemBytes); + Assert.DoesNotContain("SECRET", exported, StringComparison.Ordinal); + Assert.Contains("REDACTED", exported, StringComparison.Ordinal); + } + + [Fact] + public void Redaction_is_strict_about_paths_and_ambiguous_rules() + { + var record = SecsTraceRecord.CreateSent( + Epoch, + new SecsMessage(1, 1, rootItem: SecsItem.List(SecsItem.Ascii("value")))); + var missing = new SecsTraceRedactor(new[] + { + new SecsTraceRedactionRule(1, 1, new[] { 1 }, SecsItem.Ascii("x")), + }); + + Assert.Throws(() => missing.Redact(record)); + Assert.Throws( + () => new SecsTraceRedactionRule(1, 1, new[] { -1 }, SecsItem.Ascii("x"))); + Assert.Throws(() => new SecsTraceRedactor(new[] + { + new SecsTraceRedactionRule(1, 1, new[] { 0 }, SecsItem.Ascii("x")), + new SecsTraceRedactionRule(1, 1, new[] { 0, 1 }, SecsItem.Ascii("y")), + })); + } + + [Fact] + public async Task Replay_prevalidates_filters_and_sends_in_source_order() + { + var received = new SecsTraceRecord(Epoch, SecsTraceDirection.Received, new SecsMessage(1, 9)); + var first = SecsTraceRecord.CreateSent(Epoch, new SecsMessage(1, 1, true)); + var denied = SecsTraceRecord.CreateSent(Epoch, new SecsMessage(2, 1)); + var second = SecsTraceRecord.CreateSent(Epoch, new SecsMessage(1, 3)); + var sent = new List(); + + var results = await new SecsTraceReplayer().ReplayAsync( + new[] { received, first, denied, second }, + (message, _) => + { + sent.Add(message); + HsmsDataMessage? secondary = message.ReplyExpected + ? new HsmsDataMessage(10, 99, new SecsMessage(message.Stream, (byte)(message.Function + 1))) + : null; + return Task.FromResult(secondary); + }, + record => record.Message.Stream == 1).ConfigureAwait(true); + + Assert.Equal(new byte[] { 1, 3 }, sent.Select(message => message.Function)); + Assert.Equal(2, results.Count); + Assert.Same(first, results[0].Record); + Assert.NotNull(results[0].Secondary); + Assert.Same(second, results[1].Record); + Assert.Null(results[1].Secondary); + } + + [Fact] + public async Task Replay_validation_failure_occurs_before_any_send() + { + var record = SecsTraceRecord.CreateSent(Epoch, new SecsMessage(1, 1)); + var sends = 0; + var replayer = new SecsTraceReplayer(); + + await Assert.ThrowsAsync(() => replayer.ReplayAsync( + new SecsTraceRecord[] { record, null! }, + (_, _) => + { + sends++; + return Task.FromResult(null); + }, + _ => true)).ConfigureAwait(true); + + Assert.Equal(0, sends); + + await Assert.ThrowsAsync(() => new SecsTraceReplayer(1).ReplayAsync( + new[] { record, record }, + (_, _) => + { + sends++; + return Task.FromResult(null); + }, + _ => true)).ConfigureAwait(true); + Assert.Equal(0, sends); + + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => replayer.ReplayAsync( + new[] { record }, + (_, _) => + { + sends++; + return Task.FromResult(null); + }, + _ => true, + cancellation.Token)).ConfigureAwait(true); + Assert.Equal(0, sends); + Assert.Throws(() => new SecsTraceReplayer(0)); + } + + [Fact] + public async Task Replay_uses_a_new_real_tcp_transaction_and_ignores_received_records() + { + var port = GetFreePort(); + await using var passive = new HsmsConnection(CreateOptions(port, HsmsConnectionMode.Passive)); + await using var active = new HsmsConnection(CreateOptions(port, HsmsConnectionMode.Active)); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + passive.Start(); + active.Start(); + await using var events = passive.GetEventsAsync(cancellation.Token).GetAsyncEnumerator(); + await Task.WhenAll( + passive.WaitUntilSelectedAsync(cancellation.Token), + active.WaitUntilSelectedAsync(cancellation.Token)).ConfigureAwait(true); + + var primary = new SecsMessage(1, 1, true, SecsItem.Ascii("REPLAY")); + var source = new[] + { + new SecsTraceRecord(Epoch, SecsTraceDirection.Received, new SecsMessage(9, 9)), + new SecsTraceRecord(Epoch, SecsTraceDirection.Sent, primary, 999, 0xDEADBEEF), + }; + var replay = new SecsTraceReplayer().ReplayAsync(source, active, _ => true, cancellation.Token); + var received = await NextDataMessageAsync(events).ConfigureAwait(true); + + Assert.Equal((ushort)10, received.DataMessage.SessionId); + Assert.NotEqual(0xDEADBEEFu, received.DataMessage.SystemBytes); + AssertMessageEqual(primary, received.DataMessage.Message); + await passive.ReplyAsync( + received, + new SecsMessage(1, 2, rootItem: SecsItem.Boolean(true)), + cancellation.Token).ConfigureAwait(true); + var result = Assert.Single(await replay.ConfigureAwait(true)); + + Assert.NotNull(result.Secondary); + Assert.Equal((ushort)10, result.Secondary.SessionId); + Assert.NotEqual(0xDEADBEEFu, result.Secondary.SystemBytes); + Assert.Equal(2, result.Secondary.Message.Function); + } + + private static HsmsConnectionOptions CreateOptions(int port, HsmsConnectionMode mode) + => new( + IPAddress.Loopback, + port, + mode, + sessionId: 10, + t3: TimeSpan.FromSeconds(5), + t5: TimeSpan.FromMilliseconds(10), + t6: TimeSpan.FromSeconds(5), + t7: TimeSpan.FromSeconds(10), + t8: TimeSpan.FromSeconds(5)); + + private static DateTimeOffset Epoch + => new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static int GetFreePort() + { + var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static async Task NextDataMessageAsync( + IAsyncEnumerator events) + { + while (await events.MoveNextAsync().ConfigureAwait(true)) + { + if (events.Current.Kind == HsmsConnectionEventKind.DataMessageReceived) + return events.Current.IncomingMessage!; + } + + Assert.Fail("The replayed data message was not received."); + return null!; + } + + private static void AssertMessageEqual(SecsMessage expected, SecsMessage actual) + { + Assert.Equal(expected.Stream, actual.Stream); + Assert.Equal(expected.Function, actual.Function); + Assert.Equal(expected.ReplyExpected, actual.ReplyExpected); + Assert.Equal(expected.RootItem, actual.RootItem); + } +}