diff --git a/CHANGELOG.md b/CHANGELOG.md index a30798c..e367399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ ### 新增 +- 增加独立 SecsFrame-ControlTrace/1 信封,对公共未认领控制 + 事件提供不含 Body 的十字节头元数据导出与严格读取。 +- 控制记录保留方向、观察状态和原始头字段,允许未知非零 SType 往返;增加 + 未匹配 Reject 黄金向量、Data Message 拒绝、畸形字段与资源限制测试。 - 增加独立 SecsFrame-DiagnosticTrace/1 信封,为公共 HsmsDiagnostic 提供确定性受限字段快照导出与严格读取。 - 诊断快照只保留代码、层级、操作、状态、计时器和可选协议标量,明确排除 diff --git a/README.md b/README.md index 4a13fbf..6d0a0ef 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,9 @@ Equipment 应用发送策略的 Collection Event、报警目录查询、单报 不进入线上通讯路径。 SecsFrame.Trace 在该文本层上提供版本化的数据消息 Trace 信封、 严格的 Item 路径脱敏、显式 allowlist 重放,以及默认关闭的缩放/封顶时序 -重放;独立诊断信封只保存稳定标量,不保存异常或原始帧。该包不会接管连接 -事件消费者,也不会复用原事务标识。 +重放;独立诊断信封只保存稳定标量,不保存异常或原始帧;控制信封可保存 +未认领公共控制事件的十字节头元数据。该包不会接管连接事件消费者,也不会 +复用原事务标识。 Item 用法与边界见 [docs/SECS-II-ITEMS.md](docs/SECS-II-ITEMS.md),消息集成见 [docs/SECS-MESSAGES.md](docs/SECS-MESSAGES.md),传输适配边界见 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ec3af8e..0c15249 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -19,6 +19,8 @@ Trace 的时序重放必须显式启用,只在允许发送记录之间等待 发送 API 创建新事务;默认重放不引入时间等待。 结构化诊断使用独立只读快照信封:只复制公共诊断的稳定标量,明确排除原 异常和未解码帧,也不能进入重放路径。 +控制消息使用另一个只读元数据信封,只从调用方已消费的公共未认领控制事件 +复制十字节头字段;它不增加内部控制面观察者,也不代表完整握手抓包。 ## 核心原则 @@ -169,6 +171,7 @@ HSMS 头 Session ID 与 System Bytes。没有 W-Bit 的出站消息在写出后 - `SecsFrame.Trace` 依赖 `SecsFrame.Sml` 与 `SecsFrame`,重放必须回到 公共发送 API,不得直接构造或写出保留旧事务标识的线上帧;可选时序只 控制相邻发送前的等待,不得绕过连接状态、T3 或显式 allowlist。诊断 - 导出不得隐式包含 Exception 或 HsmsFrame。 + 导出不得隐式包含 Exception 或 HsmsFrame;控制 + 元数据导出不得包含 Body 或隐式订阅连接事件流。 - 核心包不得依赖 GEM、SML/SMN、依赖注入容器或具体日志实现。 - 状态机计时使用可替换的时间抽象,测试不得依赖真实长时间等待。 diff --git a/docs/HSMS-CONNECTION.md b/docs/HSMS-CONNECTION.md index 2189f2f..7ff309a 100644 --- a/docs/HSMS-CONNECTION.md +++ b/docs/HSMS-CONNECTION.md @@ -83,6 +83,12 @@ var secondary = await connection.SendAsync( - ControlMessageReceived:未被当前控制或数据事务认领的帧; - DataMessageDecodeFailed:原始帧和严格解码错误。 +调用方在自己的事件循环中可以使用 +SecsTraceControlRecord.CreateReceived 对 +ControlMessageReceived 创建不含 Body 的头元数据快照。该事件 +不包含已被内部 Select、Linktest、Deselect 或数据事务认领的控制消息, +因此快照不是完整控制面抓包。格式与安全边界见 [TRACE.md](TRACE.md)。 + 带错误的状态事件和解码失败事件还会提供可选 HsmsDiagnostic,用于按稳定代码、层级、操作与计时器处理, 无需解析异常消息。调用任务抛出的异常可通过 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 08cf76f..d9ee4c2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -100,7 +100,8 @@ E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写 公共发送 API 创建新事务,不复用旧会话或 System Bytes。可选时序重放也已 完成:只使用允许发送记录的相邻时间戳,支持显式加速和缩放后单次等待封顶, 默认重放仍不等待。结构化 HSMS 诊断现可通过独立受限信封导出;快照只包含 -稳定标量,明确排除异常和原始帧。 +稳定标量,明确排除异常和原始帧。现有公共未认领控制事件也可导出不含 Body +的十字节头元数据,同时保持未知非零 SType 的原始值。 ## 阶段 3:元数据与工具 @@ -110,12 +111,14 @@ E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写 - [x] 已解码数据消息的 Trace 导出、结构化脱敏和受控重放基线。 - [x] 显式启用、可缩放且单次等待封顶的 Trace 重放时序。 - [x] 不包含异常与原始帧的受限结构化诊断 Trace 导出和严格读取。 -- 控制消息和故障原始样本等互操作诊断扩展。 +- [x] 公共未认领控制消息的受限头元数据 Trace 导出和严格读取。 +- 完整控制面观测和故障原始样本等互操作诊断扩展。 当前 Trace 基线不接管连接事件流,也不把原始标识写回线上。时序重放默认 关闭,并继续经过显式 allowlist 与公共发送 API。诊断快照始终排除 -Error 与 Frame。后续控制与故障原始样本扩展必须先 -定义原始字节的数据分级、默认不导出策略和可验证脱敏边界。 +Error 与 Frame。控制元数据只覆盖应用事件循环已经 +取得的未认领控制帧,不声称观察内部握手。后续完整控制观测与故障原始样本 +扩展必须先定义原始字节的数据分级、默认不导出策略和可验证脱敏边界。 E173 SMN 与 E172 SEDD 仍需先核对授权标准及允许分发的 Schema/表示形式。 ## 发布门槛 diff --git a/docs/TRACE.md b/docs/TRACE.md index 0a52ec5..1d6c28c 100644 --- a/docs/TRACE.md +++ b/docs/TRACE.md @@ -8,10 +8,12 @@ - 按 Stream、Function 与 Item List 路径执行的结构化替换脱敏; - 只选择显式允许的本地发送记录,并通过正常公共发送 API 串行重放; - 可选按缩放且封顶的源时间间隔执行受控时序重放; -- 不包含异常和原始帧的结构化 HSMS 诊断快照导出与严格读取。 +- 不包含异常和原始帧的结构化 HSMS 诊断快照导出与严格读取; +- 公共未认领控制事件的十字节头元数据导出与严格读取。 当前切片不是原始网络抓包器,不记录 TCP 字节、transport Session generation、 -控制帧或异常对象,也不会接管 HsmsConnection 的单消费者事件流。 +控制帧的线上原始字节或异常对象,也不会接管 HsmsConnection 的 +单消费者事件流。 应用应在自己的事件循环中从 HsmsIncomingDataMessage 创建接收记录, 并在调用发送 API 前显式创建发送记录。 @@ -88,6 +90,38 @@ Session ID、System Bytes、远端状态字节和被拒绝的 SType。枚举只 与 SecsFrame-Trace/1 消息信封相互独立,不能交给重放器。协议 标识和状态字节仍是运维元数据;存储和共享文件时仍需执行访问控制与保留策略。 +## 未认领控制消息元数据 + +应用在自己的连接事件循环中可以对 +ControlMessageReceived 创建只包含头字段的快照: + +~~~csharp +if (connectionEvent.Kind == HsmsConnectionEventKind.ControlMessageReceived) +{ + var record = SecsTraceControlRecord.CreateReceived( + DateTimeOffset.UtcNow, + connectionEvent); + var text = new SecsTraceControlCodec().Encode(new[] { record }); +} +~~~ + +独立信封固定使用 LF 与 10 个单空格分隔字段: + +~~~text +SecsFrame-ControlTrace/1 +Control 2026-08-29T05:00:00.0000000Z Received Selected 65535 0x05 0x03 0x00 0x07 0x10203040 +~~~ + +字段依次为 UTC 时间、本地方向、观察到的会话状态,以及原十字节 HSMS 头的 +Session ID、Header Byte 2、Header Byte 3、PType、SType 和 System Bytes。 +字节字段使用固定宽度大写十六进制。SType 以原始字节保存:0x00 +因表示 Data Message 而拒绝,未知非零值允许严格往返,便于保留互操作证据。 + +控制帧按核心模型不能携带 Body,快照类型也没有 Body 字段。当前公共事件只 +转发未被 Select、Linktest、Deselect 或数据事务认领的控制帧,主要用于未匹配 +Reject;这个工具不会订阅内部状态机,也不是完整控制面抓包。控制信封不能 +进入消息重放器,头字段仍应按运维元数据执行访问控制与保留策略。 + ## 结构化脱敏 脱敏发生在不可变 Item 树上,而不是对 SML 文本做字符串替换: @@ -179,4 +213,5 @@ var results = await new SecsTraceReplayer().ReplayWithTimingAsync( 测试还验证重新分配 Session ID/System Bytes 并获得新 Secondary。它证明 当前工具组合边界,不代表设备命令安全性或 SEMI 一致性认证。诊断信封另有 黄金向量、可选字段往返、严格枚举/十六进制、资源限制及异常/帧内容不泄漏 -测试。 +测试。控制信封另有未匹配 Reject 黄金向量、未知 SType 往返、Data Message +拒绝、严格字段与资源限制测试。 diff --git a/src/SecsFrame.Trace/SecsTraceControlCodec.cs b/src/SecsFrame.Trace/SecsTraceControlCodec.cs new file mode 100644 index 0000000..6316d40 --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceControlCodec.cs @@ -0,0 +1,266 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; + +namespace SecsFrame.Trace; + +/// Encodes and decodes restricted-field control-message trace files. +public sealed class SecsTraceControlCodec +{ + /// The exact control-message trace format identifier. + public const string FormatIdentifier = "SecsFrame-ControlTrace/1"; + + /// The default maximum number of records in one control trace. + public const int DefaultMaxRecordCount = SecsTraceCodec.DefaultMaxRecordCount; + + /// The default maximum control trace text length. + public const int DefaultMaxTextLength = SecsTraceCodec.DefaultMaxTextLength; + + /// Creates a control trace codec with explicit resource limits. + public SecsTraceControlCodec( + 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."); + + MaxRecordCount = maxRecordCount; + MaxTextLength = maxTextLength; + } + + /// Gets the maximum number of records in one control trace. + public int MaxRecordCount { get; } + + /// Gets the maximum accepted or produced control trace 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'); + EnsureTextLength(text); + + var recordCount = 0; + foreach (var record in records) + { + if (record is null) + throw new ArgumentException("The control trace sequence contains a null record.", nameof(records)); + if (++recordCount > MaxRecordCount) + throw new InvalidOperationException($"The control trace record count exceeds the configured maximum {MaxRecordCount}."); + + AppendRecord(text, record); + EnsureTextLength(text); + } + + return text.ToString(); + } + + /// Strictly decodes one complete control 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 control trace text length cannot exceed {MaxTextLength} characters."); + + return new Parser(text, this).Parse(); + } + + private static void AppendRecord(StringBuilder text, SecsTraceControlRecord record) + { + text.Append("Control "); + text.Append(record.Timestamp.UtcDateTime.ToString("O", CultureInfo.InvariantCulture)); + text.Append(' '); + text.Append(record.Direction == SecsTraceDirection.Sent ? "Sent" : "Received"); + text.Append(' '); + text.Append(record.State); + text.Append(' '); + text.Append(record.ProtocolSessionId.ToString(CultureInfo.InvariantCulture)); + text.Append(' '); + AppendByte(text, record.HeaderByte2); + text.Append(' '); + AppendByte(text, record.HeaderByte3); + text.Append(' '); + AppendByte(text, record.PresentationType); + text.Append(' '); + AppendByte(text, record.MessageType); + text.Append(' '); + text.Append("0x"); + text.Append(record.SystemBytes.ToString("X8", CultureInfo.InvariantCulture)); + text.Append('\n'); + } + + private static void AppendByte(StringBuilder text, byte value) + { + text.Append("0x"); + text.Append(value.ToString("X2", CultureInfo.InvariantCulture)); + } + + private void EnsureTextLength(StringBuilder text) + { + if (text.Length > MaxTextLength) + throw new InvalidOperationException($"The control trace text length exceeds the configured maximum {MaxTextLength}."); + } + + private sealed class Parser + { + private readonly string _text; + private readonly SecsTraceControlCodec _options; + private int _index; + private int _recordIndex; + + public Parser(string text, SecsTraceControlCodec options) + { + _text = text; + _options = options; + } + + public IReadOnlyList Parse() + { + var headerOffset = _index; + var formatIdentifier = ReadLine(); + if (!string.Equals(formatIdentifier, FormatIdentifier, StringComparison.Ordinal)) + throw Error("The control trace format identifier is missing or unsupported", -1, headerOffset); + + var records = new List(); + while (!IsEnd) + { + if (_recordIndex >= _options.MaxRecordCount) + throw Error($"The control trace record count exceeds the configured maximum {_options.MaxRecordCount}", _recordIndex, _index); + + records.Add(ParseRecord()); + _recordIndex++; + } + + return new ReadOnlyCollection(records); + } + + private SecsTraceControlRecord ParseRecord() + { + var headerOffset = _index; + var fields = ReadLine().Split(new[] { ' ' }, StringSplitOptions.None); + if (fields.Length != 10 || !string.Equals(fields[0], "Control", StringComparison.Ordinal)) + throw Error("A control record must contain exactly ten single-space-separated fields", _recordIndex, headerOffset); + + var messageType = ParseByte(fields[8], "SType", headerOffset); + if (messageType == (byte)HsmsMessageType.DataMessage) + throw Error("A control-message SType must be nonzero", _recordIndex, headerOffset); + + return new SecsTraceControlRecord( + ParseTimestamp(fields[1], headerOffset), + ParseDirection(fields[2], headerOffset), + ParseState(fields[3], headerOffset), + ParseSessionId(fields[4], headerOffset), + ParseByte(fields[5], "header byte 2", headerOffset), + ParseByte(fields[6], "header byte 3", headerOffset), + ParseByte(fields[7], "PType", headerOffset), + messageType, + ParseSystemBytes(fields[9], headerOffset)); + } + + private DateTimeOffset ParseTimestamp(string value, int offset) + { + if (!DateTimeOffset.TryParseExact( + value, + "O", + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var timestamp)) + { + throw Error("The control 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 control direction must be Sent or Received", _recordIndex, offset), + }; + + private HsmsSessionState ParseState(string value, int offset) + { + if (!Enum.TryParse(value, ignoreCase: false, out HsmsSessionState parsed) || + !Enum.IsDefined(typeof(HsmsSessionState), parsed) || + !string.Equals(value, parsed.ToString(), StringComparison.Ordinal)) + { + throw Error("The HSMS session state is unknown", _recordIndex, offset); + } + + return parsed; + } + + private ushort ParseSessionId(string value, int offset) + { + if (!ushort.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed)) + throw Error("The protocol Session ID must be an unsigned 16-bit integer", _recordIndex, offset); + return parsed; + } + + private byte ParseByte(string value, string fieldName, int offset) + { + if (!HasExactHexForm(value, 2) || + !byte.TryParse(value.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed)) + { + throw Error($"The {fieldName} must use the exact form 0xHH", _recordIndex, offset); + } + + return parsed; + } + + private uint ParseSystemBytes(string value, int offset) + { + if (!HasExactHexForm(value, 8) || + !uint.TryParse(value.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed)) + { + throw Error("System Bytes must use the exact form 0xHHHHHHHH", _recordIndex, offset); + } + + return parsed; + } + + private static bool HasExactHexForm(string value, int digitCount) + { + if (value.Length != digitCount + 2 || value[0] != '0' || value[1] != 'x') + return false; + + for (var index = 2; index < value.Length; index++) + { + var character = value[index]; + if (!((character >= '0' && character <= '9') || (character >= 'A' && character <= 'F'))) + return false; + } + + return true; + } + + 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) + => new(message, recordIndex, offset); + } +} diff --git a/src/SecsFrame.Trace/SecsTraceControlRecord.cs b/src/SecsFrame.Trace/SecsTraceControlRecord.cs new file mode 100644 index 0000000..8660198 --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceControlRecord.cs @@ -0,0 +1,114 @@ +namespace SecsFrame.Trace; + +/// +/// Describes a restricted-field snapshot of one HSMS control-message header. +/// +/// +/// The record stores only the ten-byte header fields and never contains a frame body. +/// +public sealed class SecsTraceControlRecord +{ + /// Creates an immutable control-message metadata record. + public SecsTraceControlRecord( + DateTimeOffset timestamp, + SecsTraceDirection direction, + HsmsSessionState state, + ushort protocolSessionId, + byte headerByte2, + byte headerByte3, + byte presentationType, + byte messageType, + uint systemBytes) + { + if (direction != SecsTraceDirection.Sent && direction != SecsTraceDirection.Received) + throw new ArgumentOutOfRangeException(nameof(direction), direction, "Unknown trace direction."); + if (!Enum.IsDefined(typeof(HsmsSessionState), state)) + throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown HSMS session state."); + if (messageType == (byte)HsmsMessageType.DataMessage) + throw new ArgumentOutOfRangeException(nameof(messageType), messageType, "A control-message SType must be nonzero."); + + Timestamp = timestamp.ToUniversalTime(); + Direction = direction; + State = state; + ProtocolSessionId = protocolSessionId; + HeaderByte2 = headerByte2; + HeaderByte3 = headerByte3; + PresentationType = presentationType; + MessageType = messageType; + 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 HSMS session state observed with the control message. + public HsmsSessionState State { get; } + + /// Gets the raw protocol Session ID. + public ushort ProtocolSessionId { get; } + + /// Gets raw HSMS header byte 2. + public byte HeaderByte2 { get; } + + /// Gets raw HSMS header byte 3. + public byte HeaderByte3 { get; } + + /// Gets the raw PType byte. + public byte PresentationType { get; } + + /// Gets the raw nonzero SType byte. + public byte MessageType { get; } + + /// Gets the raw System Bytes value. + public uint SystemBytes { get; } + + /// Creates a metadata record from a control frame. + public static SecsTraceControlRecord Create( + DateTimeOffset timestamp, + SecsTraceDirection direction, + HsmsSessionState state, + HsmsFrame frame) + { + if (frame is null) + throw new ArgumentNullException(nameof(frame)); + if (frame.Header.IsDataMessage || !frame.Body.IsEmpty) + throw new ArgumentException("The frame must be an HSMS control message without a body.", nameof(frame)); + + var header = frame.Header; + return new SecsTraceControlRecord( + timestamp, + direction, + state, + header.SessionId, + header.HeaderByte2, + header.HeaderByte3, + header.PresentationType, + (byte)header.MessageType, + header.SystemBytes); + } + + /// + /// Creates a received metadata record from an unclaimed public control event. + /// + public static SecsTraceControlRecord CreateReceived( + DateTimeOffset timestamp, + HsmsConnectionEvent connectionEvent) + { + if (connectionEvent is null) + throw new ArgumentNullException(nameof(connectionEvent)); + if (connectionEvent.Kind != HsmsConnectionEventKind.ControlMessageReceived || + connectionEvent.Frame is null) + { + throw new ArgumentException("The connection event must contain an unclaimed control message.", nameof(connectionEvent)); + } + + return Create( + timestamp, + SecsTraceDirection.Received, + connectionEvent.State, + connectionEvent.Frame); + } +} diff --git a/test/SecsFrame.Tests/SecsTraceControlTests.cs b/test/SecsFrame.Tests/SecsTraceControlTests.cs new file mode 100644 index 0000000..0bebee3 --- /dev/null +++ b/test/SecsFrame.Tests/SecsTraceControlTests.cs @@ -0,0 +1,156 @@ +using SecsFrame.Trace; + +namespace SecsFrame.Tests; + +public sealed class SecsTraceControlTests +{ + [Fact] + public void Codec_produces_a_deterministic_unclaimed_reject_vector() + { + var frame = new HsmsFrame(HsmsMessageHeader.CreateReject( + systemBytes: 0x10203040, + rejectedMessageType: (byte)HsmsMessageType.LinktestRequest, + reason: 0x03)); + var connectionEvent = HsmsConnectionEvent.ControlMessageReceived( + HsmsSessionState.Selected, + frame); + var timestamp = new DateTimeOffset(2026, 8, 29, 13, 0, 0, TimeSpan.FromHours(8)); + var record = SecsTraceControlRecord.CreateReceived(timestamp, connectionEvent); + var codec = new SecsTraceControlCodec(); + + var text = codec.Encode(new[] { record }); + + Assert.Equal( + "SecsFrame-ControlTrace/1\n" + + "Control 2026-08-29T05:00:00.0000000Z Received Selected 65535 0x05 0x03 0x00 0x07 0x10203040\n", + text); + AssertRecordEqual(record, Assert.Single(codec.Decode(text))); + } + + [Fact] + public void Codec_preserves_unknown_nonzero_stype_and_sent_direction() + { + var record = new SecsTraceControlRecord( + Epoch, + SecsTraceDirection.Sent, + HsmsSessionState.Connected, + protocolSessionId: 0x1234, + headerByte2: 0x56, + headerByte3: 0x78, + presentationType: 0x9A, + messageType: 0x7F, + systemBytes: 0xABCDEF01); + var codec = new SecsTraceControlCodec(); + + var decoded = Assert.Single(codec.Decode(codec.Encode(new[] { record }))); + + AssertRecordEqual(record, decoded); + } + + [Fact] + public void Factories_reject_data_frames_and_non_control_events() + { + var dataFrame = new HsmsFrame( + HsmsMessageHeader.CreateData(10, 1, 1, false, 1)); + var decodeEvent = HsmsConnectionEvent.DataMessageDecodeFailed( + dataFrame, + new SecsProtocolException("Invalid data.")); + + Assert.Throws(() => SecsTraceControlRecord.Create( + Epoch, + SecsTraceDirection.Received, + HsmsSessionState.Selected, + dataFrame)); + Assert.Throws(() => SecsTraceControlRecord.CreateReceived(Epoch, decodeEvent)); + Assert.Throws(() => SecsTraceControlRecord.Create( + Epoch, + SecsTraceDirection.Received, + HsmsSessionState.Selected, + null!)); + Assert.Throws(() => SecsTraceControlRecord.CreateReceived(Epoch, null!)); + } + + [Fact] + public void Codec_rejects_malformed_fields_data_stype_and_spacing() + { + const string valid = + "SecsFrame-ControlTrace/1\n" + + "Control 1970-01-01T00:00:00.0000000Z Received Selected 65535 0x05 0x03 0x00 0x07 0x10203040\n"; + var codec = new SecsTraceControlCodec(); + + Assert.Throws(() => codec.Decode(string.Empty)); + Assert.Throws(() => codec.Decode(valid.Replace("ControlTrace/1", "ControlTrace/2"))); + Assert.Throws(() => codec.Decode(valid.Replace("Received", "Inbound"))); + Assert.Throws(() => codec.Decode(valid.Replace("Selected", "selected"))); + Assert.Throws(() => codec.Decode(valid.Replace("0x05", "0x5"))); + Assert.Throws(() => codec.Decode(valid.Replace("0x03", "0x0a"))); + Assert.Throws(() => codec.Decode(valid.Replace("0x07", "0x00"))); + Assert.Throws(() => codec.Decode(valid.Replace("Control ", "Control "))); + } + + [Fact] + public void Codec_and_record_enforce_resource_and_value_boundaries() + { + var record = new SecsTraceControlRecord( + Epoch, + SecsTraceDirection.Received, + HsmsSessionState.Selected, + ushort.MaxValue, + 0, + 0, + 0, + (byte)HsmsMessageType.LinktestRequest, + 1); + var records = new[] { record, record }; + var codec = new SecsTraceControlCodec(); + var text = codec.Encode(records); + + Assert.Throws(() => codec.Encode(null!)); + Assert.Throws(() => codec.Decode(null!)); + Assert.Throws(() => codec.Encode(new SecsTraceControlRecord[] { record, null! })); + Assert.Throws(() => new SecsTraceControlCodec(maxRecordCount: 1).Encode(records)); + Assert.Throws(() => new SecsTraceControlCodec(maxRecordCount: 1).Decode(text)); + Assert.Throws(() => new SecsTraceControlCodec(maxTextLength: 20).Encode(Array.Empty())); + Assert.Throws(() => new SecsTraceControlCodec(maxTextLength: 20).Decode(text)); + Assert.Throws(() => new SecsTraceControlCodec(maxRecordCount: 0)); + Assert.Throws(() => new SecsTraceControlCodec(maxTextLength: 0)); + Assert.Throws(() => new SecsTraceControlRecord( + Epoch, + (SecsTraceDirection)int.MaxValue, + HsmsSessionState.Selected, + ushort.MaxValue, + 0, + 0, + 0, + 1, + 1)); + Assert.Throws(() => new SecsTraceControlRecord( + Epoch, + SecsTraceDirection.Received, + HsmsSessionState.Selected, + ushort.MaxValue, + 0, + 0, + 0, + 0, + 1)); + } + + private static DateTimeOffset Epoch + => new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static void AssertRecordEqual( + SecsTraceControlRecord expected, + SecsTraceControlRecord actual) + { + Assert.Equal(expected.Timestamp, actual.Timestamp); + Assert.Equal(expected.Direction, actual.Direction); + Assert.Equal(expected.State, actual.State); + Assert.Equal(expected.ProtocolSessionId, actual.ProtocolSessionId); + Assert.Equal(expected.HeaderByte2, actual.HeaderByte2); + Assert.Equal(expected.HeaderByte3, actual.HeaderByte3); + Assert.Equal(expected.PresentationType, actual.PresentationType); + Assert.Equal(expected.MessageType, actual.MessageType); + Assert.Equal(expected.SystemBytes, actual.SystemBytes); + } +}