From 91ae1a7ed7949f37563e8f829e75f22f97d09c33 Mon Sep 17 00:00:00 2001 From: Marci Date: Sat, 29 Aug 2026 17:25:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Trace=20=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8C=96=E8=AF=8A=E6=96=AD=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + README.md | 5 +- docs/ARCHITECTURE.md | 5 +- docs/HSMS-DIAGNOSTICS.md | 8 + docs/ROADMAP.md | 11 +- docs/TRACE.md | 40 ++- .../SecsTraceDiagnosticCodec.cs | 275 ++++++++++++++++++ .../SecsTraceDiagnosticRecord.cs | 103 +++++++ .../SecsTraceDiagnosticTests.cs | 148 ++++++++++ 9 files changed, 590 insertions(+), 9 deletions(-) create mode 100644 src/SecsFrame.Trace/SecsTraceDiagnosticCodec.cs create mode 100644 src/SecsFrame.Trace/SecsTraceDiagnosticRecord.cs create mode 100644 test/SecsFrame.Tests/SecsTraceDiagnosticTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 989da62..a30798c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ ### 新增 +- 增加独立 SecsFrame-DiagnosticTrace/1 信封,为公共 + HsmsDiagnostic 提供确定性受限字段快照导出与严格读取。 +- 诊断快照只保留代码、层级、操作、状态、计时器和可选协议标量,明确排除 + 原异常与未解码帧;增加黄金向量、畸形字段、资源限制和敏感内容不泄漏测试。 - 增加默认关闭的 Trace 时序重放;按 allowlist 筛选后的 Sent 记录时间戳 计算间隔,支持显式速度倍率和缩放后单次等待封顶。 - 增加时序倒退预验证、筛选间隔、缩放/封顶、等待取消和默认零等待测试; diff --git a/README.md b/README.md index 67505aa..4a13fbf 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ | `SecsFrame` | SECS-II 数据模型、HSMS-SS 会话与事务基础 | | `SecsFrame.Gem` | GEM 通用状态模型与能力服务 | | `SecsFrame.Sml` | 动态消息的确定性 SML 调试读写 | -| `SecsFrame.Trace` | 已解码消息的导出、结构化脱敏与受控重放 | +| `SecsFrame.Trace` | 已解码消息与受限诊断快照的导出、结构化脱敏与受控重放 | | `SecsFrame.Smn` | E173 SMN 日志、文档与消息表示 | | `SecsFrame.Sedd` | E172 SEDD 设备接口数据字典 | @@ -43,7 +43,8 @@ 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 a52e807..ec3af8e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -17,6 +17,8 @@ E172 SEDD 与 E173 SMN 是可选元数据/表示层,不进入核心通讯路 解码的数据消息;它不订阅内部 transport 或创建第二个公共事件消费者。 Trace 的时序重放必须显式启用,只在允许发送记录之间等待,并继续通过公共 发送 API 创建新事务;默认重放不引入时间等待。 +结构化诊断使用独立只读快照信封:只复制公共诊断的稳定标量,明确排除原 +异常和未解码帧,也不能进入重放路径。 ## 核心原则 @@ -166,6 +168,7 @@ HSMS 头 Session ID 与 System Bytes。没有 W-Bit 的出站消息在写出后 - `SecsFrame.Sml` 依赖 `SecsFrame`,不得依赖 GEM 或线上传输实现。 - `SecsFrame.Trace` 依赖 `SecsFrame.Sml` 与 `SecsFrame`,重放必须回到 公共发送 API,不得直接构造或写出保留旧事务标识的线上帧;可选时序只 - 控制相邻发送前的等待,不得绕过连接状态、T3 或显式 allowlist。 + 控制相邻发送前的等待,不得绕过连接状态、T3 或显式 allowlist。诊断 + 导出不得隐式包含 Exception 或 HsmsFrame。 - 核心包不得依赖 GEM、SML/SMN、依赖注入容器或具体日志实现。 - 状态机计时使用可替换的时间抽象,测试不得依赖真实长时间等待。 diff --git a/docs/HSMS-DIAGNOSTICS.md b/docs/HSMS-DIAGNOSTICS.md index 691ffb2..01a306d 100644 --- a/docs/HSMS-DIAGNOSTICS.md +++ b/docs/HSMS-DIAGNOSTICS.md @@ -71,6 +71,14 @@ Session ID 与 System Bytes;拒绝诊断保留远端状态或原因字节。 工艺数据。库不自动格式化、写日志或上传它。应用在记录前应按自己的数据 分类策略做脱敏和长度限制;常规指标优先只使用代码、层级、操作和计时器。 +SecsFrame.Trace 提供独立的 +SecsFrame-DiagnosticTrace/1 受限字段快照信封。 +SecsTraceDiagnosticRecord.Create 只复制稳定代码、层级、操作、 +状态、计时器与可选协议标识/状态字节,不复制 Error 或 +Frame。这使结构化诊断可以确定性导出,但协议 Session ID、 +System Bytes 和远端状态字节仍属于运维元数据,导出文件仍应使用适当的 +访问控制与保留策略。具体格式和示例见 [TRACE.md](TRACE.md)。 + ## 标准边界 诊断名称描述 SecsFrame 已实现并测试的工程行为,不新增 SEMI 默认值,也 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5a0b6d5..08cf76f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -99,7 +99,8 @@ E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写 消息的版本化 Trace 导出、结构化脱敏与显式 allowlist 受控重放;重放通过 公共发送 API 创建新事务,不复用旧会话或 System Bytes。可选时序重放也已 完成:只使用允许发送记录的相邻时间戳,支持显式加速和缩放后单次等待封顶, -默认重放仍不等待。 +默认重放仍不等待。结构化 HSMS 诊断现可通过独立受限信封导出;快照只包含 +稳定标量,明确排除异常和原始帧。 ## 阶段 3:元数据与工具 @@ -108,11 +109,13 @@ E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写 - E172 SEDD 设备字典导入、校验与发现。 - [x] 已解码数据消息的 Trace 导出、结构化脱敏和受控重放基线。 - [x] 显式启用、可缩放且单次等待封顶的 Trace 重放时序。 -- 控制消息、结构化诊断和故障原始样本等互操作诊断扩展。 +- [x] 不包含异常与原始帧的受限结构化诊断 Trace 导出和严格读取。 +- 控制消息和故障原始样本等互操作诊断扩展。 当前 Trace 基线不接管连接事件流,也不把原始标识写回线上。时序重放默认 -关闭,并继续经过显式 allowlist 与公共发送 API。后续控制与故障诊断扩展 -必须先定义原始字节的数据分级、默认不导出策略和可验证脱敏边界。 +关闭,并继续经过显式 allowlist 与公共发送 API。诊断快照始终排除 +Error 与 Frame。后续控制与故障原始样本扩展必须先 +定义原始字节的数据分级、默认不导出策略和可验证脱敏边界。 E173 SMN 与 E172 SEDD 仍需先核对授权标准及允许分发的 Schema/表示形式。 ## 发布门槛 diff --git a/docs/TRACE.md b/docs/TRACE.md index 1640791..0a52ec5 100644 --- a/docs/TRACE.md +++ b/docs/TRACE.md @@ -7,7 +7,8 @@ - 确定性的 SecsFrame-Trace/1 文本导出和严格读取; - 按 Stream、Function 与 Item List 路径执行的结构化替换脱敏; - 只选择显式允许的本地发送记录,并通过正常公共发送 API 串行重放; -- 可选按缩放且封顶的源时间间隔执行受控时序重放。 +- 可选按缩放且封顶的源时间间隔执行受控时序重放; +- 不包含异常和原始帧的结构化 HSMS 诊断快照导出与严格读取。 当前切片不是原始网络抓包器,不记录 TCP 字节、transport Session generation、 控制帧或异常对象,也不会接管 HsmsConnection 的单消费者事件流。 @@ -54,6 +55,39 @@ Item、值和文本长度限制。解析错误使用包含记录索引与字符 该格式是 SecsFrame 的版本化诊断信封,不是 SEMI 标准、PCAP 或原始 HSMS wire trace。协议标识只用于问题关联,不能作为新连接上的重放标识。 +## 结构化诊断信封 + +SecsTraceDiagnosticRecord 从公共 HsmsDiagnostic +创建只包含稳定标量的受限字段快照: + +~~~csharp +if (connectionEvent.Diagnostic is { } diagnostic) +{ + var record = SecsTraceDiagnosticRecord.Create( + DateTimeOffset.UtcNow, + diagnostic); + var text = new SecsTraceDiagnosticCodec().Encode(new[] { record }); +} +~~~ + +独立信封固定使用 LF 与 11 个单空格分隔字段: + +~~~text +SecsFrame-DiagnosticTrace/1 +Diagnostic 2026-08-29T05:00:00.0000000Z T3Timeout Transaction WaitForSecondary Selected T3 10 0x10203040 - - +~~~ + +字段依次为 UTC 时间、诊断代码、层级、操作、会话状态、可选计时器、协议 +Session ID、System Bytes、远端状态字节和被拒绝的 SType。枚举只接受当前 +已定义的精确名称;协议字节使用固定宽度大写十六进制。codec 同样限制记录数 +和总文本长度,并通过 SecsTraceParseException 报告记录索引与 +字符偏移。 + +快照 API 不提供 Error 或 Frame 字段,创建快照时也 +不会读取或格式化它们,因此异常消息和未解码帧负载不会进入导出文本。该格式 +与 SecsFrame-Trace/1 消息信封相互独立,不能交给重放器。协议 +标识和状态字节仍是运维元数据;存储和共享文件时仍需执行访问控制与保留策略。 + ## 结构化脱敏 脱敏发生在不可变 Item 树上,而不是对 SML 文本做字符串替换: @@ -143,4 +177,6 @@ var results = await new SecsTraceReplayer().ReplayWithTimingAsync( 资源限制、路径漂移、规则重叠、敏感值不进入导出文本、预验证零发送,以及 时序缩放/封顶、筛选间隔、时间倒退预验证、等待取消和默认零等待。真实 TCP 测试还验证重新分配 Session ID/System Bytes 并获得新 Secondary。它证明 -当前工具组合边界,不代表设备命令安全性或 SEMI 一致性认证。 +当前工具组合边界,不代表设备命令安全性或 SEMI 一致性认证。诊断信封另有 +黄金向量、可选字段往返、严格枚举/十六进制、资源限制及异常/帧内容不泄漏 +测试。 diff --git a/src/SecsFrame.Trace/SecsTraceDiagnosticCodec.cs b/src/SecsFrame.Trace/SecsTraceDiagnosticCodec.cs new file mode 100644 index 0000000..9ab61f4 --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceDiagnosticCodec.cs @@ -0,0 +1,275 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; + +namespace SecsFrame.Trace; + +/// Encodes and decodes restricted-field structured-diagnostic trace files. +public sealed class SecsTraceDiagnosticCodec +{ + /// The exact diagnostic trace format identifier. + public const string FormatIdentifier = "SecsFrame-DiagnosticTrace/1"; + + /// The default maximum number of records in one diagnostic trace. + public const int DefaultMaxRecordCount = SecsTraceCodec.DefaultMaxRecordCount; + + /// The default maximum diagnostic trace text length. + public const int DefaultMaxTextLength = SecsTraceCodec.DefaultMaxTextLength; + + /// Creates a diagnostic trace codec with explicit resource limits. + public SecsTraceDiagnosticCodec( + 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 diagnostic trace. + public int MaxRecordCount { get; } + + /// Gets the maximum accepted or produced diagnostic 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 diagnostic trace sequence contains a null record.", nameof(records)); + if (++recordCount > MaxRecordCount) + throw new InvalidOperationException($"The diagnostic trace record count exceeds the configured maximum {MaxRecordCount}."); + + AppendRecord(text, record); + EnsureTextLength(text); + } + + return text.ToString(); + } + + /// Strictly decodes one complete diagnostic 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 diagnostic trace text length cannot exceed {MaxTextLength} characters."); + + return new Parser(text, this).Parse(); + } + + private static void AppendRecord(StringBuilder text, SecsTraceDiagnosticRecord record) + { + text.Append("Diagnostic "); + text.Append(record.Timestamp.UtcDateTime.ToString("O", CultureInfo.InvariantCulture)); + text.Append(' '); + text.Append(record.Code); + text.Append(' '); + text.Append(record.Layer); + text.Append(' '); + text.Append(record.Operation); + text.Append(' '); + text.Append(record.State); + text.Append(' '); + text.Append(record.Timer.HasValue ? record.Timer.Value.ToString() : "-"); + text.Append(' '); + text.Append(record.ProtocolSessionId.HasValue + ? record.ProtocolSessionId.Value.ToString(CultureInfo.InvariantCulture) + : "-"); + text.Append(' '); + text.Append(FormatOptionalUInt32(record.SystemBytes)); + text.Append(' '); + text.Append(FormatOptionalByte(record.PeerStatus)); + text.Append(' '); + text.Append(FormatOptionalByte(record.RejectedMessageType)); + text.Append('\n'); + } + + private static string FormatOptionalUInt32(uint? value) + => value.HasValue + ? "0x" + value.Value.ToString("X8", CultureInfo.InvariantCulture) + : "-"; + + private static string FormatOptionalByte(byte? value) + => value.HasValue + ? "0x" + value.Value.ToString("X2", CultureInfo.InvariantCulture) + : "-"; + + private void EnsureTextLength(StringBuilder text) + { + if (text.Length > MaxTextLength) + throw new InvalidOperationException($"The diagnostic trace text length exceeds the configured maximum {MaxTextLength}."); + } + + private sealed class Parser + { + private readonly string _text; + private readonly SecsTraceDiagnosticCodec _options; + private int _index; + private int _recordIndex; + + public Parser(string text, SecsTraceDiagnosticCodec options) + { + _text = text; + _options = options; + } + + public IReadOnlyList Parse() + { + var headerOffset = _index; + var formatIdentifier = ReadLine(); + if (!string.Equals(formatIdentifier, FormatIdentifier, StringComparison.Ordinal)) + throw Error("The diagnostic trace format identifier is missing or unsupported", -1, headerOffset); + + var records = new List(); + while (!IsEnd) + { + if (_recordIndex >= _options.MaxRecordCount) + throw Error($"The diagnostic trace record count exceeds the configured maximum {_options.MaxRecordCount}", _recordIndex, _index); + + records.Add(ParseRecord()); + _recordIndex++; + } + + return new ReadOnlyCollection(records); + } + + private SecsTraceDiagnosticRecord ParseRecord() + { + var headerOffset = _index; + var fields = ReadLine().Split(new[] { ' ' }, StringSplitOptions.None); + if (fields.Length != 11 || !string.Equals(fields[0], "Diagnostic", StringComparison.Ordinal)) + throw Error("A diagnostic record must contain exactly eleven single-space-separated fields", _recordIndex, headerOffset); + + return new SecsTraceDiagnosticRecord( + ParseTimestamp(fields[1], headerOffset), + ParseEnum(fields[2], "diagnostic code", headerOffset), + ParseEnum(fields[3], "diagnostic layer", headerOffset), + ParseEnum(fields[4], "diagnostic operation", headerOffset), + ParseEnum(fields[5], "session state", headerOffset), + ParseOptionalEnum(fields[6], "timer", headerOffset), + ParseOptionalUInt16(fields[7], "protocol Session ID", headerOffset), + ParseOptionalUInt32(fields[8], "System Bytes", headerOffset), + ParseOptionalByte(fields[9], "peer status", headerOffset), + ParseOptionalByte(fields[10], "rejected message type", headerOffset)); + } + + private DateTimeOffset ParseTimestamp(string value, int offset) + { + if (!DateTimeOffset.TryParseExact( + value, + "O", + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var timestamp)) + { + throw Error("The diagnostic timestamp must use the round-trip format", _recordIndex, offset); + } + + return timestamp; + } + + private TEnum ParseEnum(string value, string fieldName, int offset) + where TEnum : struct, Enum + { + if (!Enum.TryParse(value, ignoreCase: false, out TEnum parsed) || + !Enum.IsDefined(typeof(TEnum), parsed) || + !string.Equals(value, parsed.ToString(), StringComparison.Ordinal)) + { + throw Error($"The {fieldName} is unknown", _recordIndex, offset); + } + + return parsed; + } + + private TEnum? ParseOptionalEnum(string value, string fieldName, int offset) + where TEnum : struct, Enum + => string.Equals(value, "-", StringComparison.Ordinal) + ? null + : ParseEnum(value, fieldName, offset); + + private ushort? ParseOptionalUInt16(string value, string fieldName, int offset) + { + if (string.Equals(value, "-", StringComparison.Ordinal)) + return null; + if (!ushort.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed)) + throw Error($"The {fieldName} must be '-' or an unsigned 16-bit integer", _recordIndex, offset); + return parsed; + } + + private uint? ParseOptionalUInt32(string value, string fieldName, int offset) + { + if (string.Equals(value, "-", StringComparison.Ordinal)) + return null; + if (!HasExactHexForm(value, 8) || + !uint.TryParse(value.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed)) + { + throw Error($"The {fieldName} must be '-' or use the exact form 0xHHHHHHHH", _recordIndex, offset); + } + + return parsed; + } + + private byte? ParseOptionalByte(string value, string fieldName, int offset) + { + if (string.Equals(value, "-", StringComparison.Ordinal)) + return null; + if (!HasExactHexForm(value, 2) || + !byte.TryParse(value.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed)) + { + throw Error($"The {fieldName} must be '-' or use the exact form 0xHH", _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/SecsTraceDiagnosticRecord.cs b/src/SecsFrame.Trace/SecsTraceDiagnosticRecord.cs new file mode 100644 index 0000000..9643b46 --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceDiagnosticRecord.cs @@ -0,0 +1,103 @@ +namespace SecsFrame.Trace; + +/// +/// Describes a restricted-field, immutable snapshot of one structured HSMS diagnostic. +/// +/// +/// The snapshot deliberately excludes the original exception and undecodable +/// frame because either can contain environment or application data. +/// +public sealed class SecsTraceDiagnosticRecord +{ + /// Creates a structured diagnostic trace record. + public SecsTraceDiagnosticRecord( + DateTimeOffset timestamp, + HsmsDiagnosticCode code, + HsmsDiagnosticLayer layer, + HsmsOperation operation, + HsmsSessionState state, + HsmsTimer? timer = null, + ushort? protocolSessionId = null, + uint? systemBytes = null, + byte? peerStatus = null, + byte? rejectedMessageType = null) + { + ValidateEnum(code, nameof(code)); + ValidateEnum(layer, nameof(layer)); + ValidateEnum(operation, nameof(operation)); + ValidateEnum(state, nameof(state)); + if (timer.HasValue) + ValidateEnum(timer.Value, nameof(timer)); + + Timestamp = timestamp.ToUniversalTime(); + Code = code; + Layer = layer; + Operation = operation; + State = state; + Timer = timer; + ProtocolSessionId = protocolSessionId; + SystemBytes = systemBytes; + PeerStatus = peerStatus; + RejectedMessageType = rejectedMessageType; + } + + /// Gets the UTC timestamp associated with the local observation. + public DateTimeOffset Timestamp { get; } + + /// Gets the stable diagnostic code. + public HsmsDiagnosticCode Code { get; } + + /// Gets the layer that produced the diagnostic. + public HsmsDiagnosticLayer Layer { get; } + + /// Gets the operation associated with the diagnostic. + public HsmsOperation Operation { get; } + + /// Gets the observed HSMS session state. + public HsmsSessionState State { get; } + + /// Gets the associated timer, when present. + public HsmsTimer? Timer { get; } + + /// Gets the protocol Session ID, when present. + public ushort? ProtocolSessionId { get; } + + /// Gets the transaction System Bytes, when present. + public uint? SystemBytes { get; } + + /// Gets the peer-provided status or reject-reason byte, when present. + public byte? PeerStatus { get; } + + /// Gets the rejected HSMS SType byte, when present. + public byte? RejectedMessageType { get; } + + /// + /// Creates a restricted-field snapshot without copying the diagnostic exception or frame. + /// + public static SecsTraceDiagnosticRecord Create( + DateTimeOffset timestamp, + HsmsDiagnostic diagnostic) + { + if (diagnostic is null) + throw new ArgumentNullException(nameof(diagnostic)); + + return new SecsTraceDiagnosticRecord( + timestamp, + diagnostic.Code, + diagnostic.Layer, + diagnostic.Operation, + diagnostic.State, + diagnostic.Timer, + diagnostic.ProtocolSessionId, + diagnostic.SystemBytes, + diagnostic.PeerStatus, + diagnostic.RejectedMessageType); + } + + private static void ValidateEnum(TEnum value, string parameterName) + where TEnum : struct, Enum + { + if (!Enum.IsDefined(typeof(TEnum), value)) + throw new ArgumentOutOfRangeException(parameterName, value, "Unknown diagnostic field value."); + } +} diff --git a/test/SecsFrame.Tests/SecsTraceDiagnosticTests.cs b/test/SecsFrame.Tests/SecsTraceDiagnosticTests.cs new file mode 100644 index 0000000..849d387 --- /dev/null +++ b/test/SecsFrame.Tests/SecsTraceDiagnosticTests.cs @@ -0,0 +1,148 @@ +using System.Text; +using SecsFrame.Trace; + +namespace SecsFrame.Tests; + +public sealed class SecsTraceDiagnosticTests +{ + [Fact] + public void Codec_produces_a_deterministic_restricted_diagnostic_vector() + { + var diagnostic = HsmsDiagnostic.Classify( + new HsmsDataTransactionTimeoutException(new HsmsDataMessage( + sessionId: 10, + systemBytes: 0x10203040, + new SecsMessage(6, 11, true))), + HsmsSessionState.Selected)!; + var timestamp = new DateTimeOffset(2026, 8, 29, 13, 0, 0, TimeSpan.FromHours(8)); + var record = SecsTraceDiagnosticRecord.Create(timestamp, diagnostic); + var codec = new SecsTraceDiagnosticCodec(); + + var text = codec.Encode(new[] { record }); + + Assert.Equal( + "SecsFrame-DiagnosticTrace/1\n" + + "Diagnostic 2026-08-29T05:00:00.0000000Z T3Timeout Transaction WaitForSecondary Selected T3 10 0x10203040 - -\n", + text); + var decoded = Assert.Single(codec.Decode(text)); + AssertRecordEqual(record, decoded); + } + + [Fact] + public void Codec_round_trips_optional_peer_fields_and_empty_values() + { + var records = new[] + { + new SecsTraceDiagnosticRecord( + Epoch, + HsmsDiagnosticCode.ControlRejected, + HsmsDiagnosticLayer.Session, + HsmsOperation.Deselect, + HsmsSessionState.Selected, + peerStatus: 0x03, + rejectedMessageType: 0x07), + new SecsTraceDiagnosticRecord( + Epoch.AddSeconds(1), + HsmsDiagnosticCode.TransportFailure, + HsmsDiagnosticLayer.Transport, + HsmsOperation.Connect, + HsmsSessionState.Disconnected), + }; + var codec = new SecsTraceDiagnosticCodec(); + + var decoded = codec.Decode(codec.Encode(records)); + + Assert.Equal(2, decoded.Count); + AssertRecordEqual(records[0], decoded[0]); + AssertRecordEqual(records[1], decoded[1]); + } + + [Fact] + public void Snapshot_and_export_exclude_exception_and_frame_content() + { + const string secret = "SECRET-PROCESS-DATA"; + var frame = new HsmsFrame( + HsmsMessageHeader.CreateData(23, 1, 2, false, 0xAABBCCDD), + Encoding.ASCII.GetBytes(secret)); + var connectionEvent = HsmsConnectionEvent.DataMessageDecodeFailed( + frame, + new SecsProtocolException(secret)); + + var record = SecsTraceDiagnosticRecord.Create(Epoch, connectionEvent.Diagnostic!); + var text = new SecsTraceDiagnosticCodec().Encode(new[] { record }); + + Assert.Equal(HsmsDiagnosticCode.DataMessageDecodeFailed, record.Code); + Assert.Equal((ushort)23, record.ProtocolSessionId); + Assert.Equal(0xAABBCCDDu, record.SystemBytes); + Assert.DoesNotContain(secret, text, StringComparison.Ordinal); + Assert.DoesNotContain("Exception", text, StringComparison.Ordinal); + } + + [Fact] + public void Codec_rejects_unknown_fields_noncanonical_hex_and_spacing() + { + const string valid = + "SecsFrame-DiagnosticTrace/1\n" + + "Diagnostic 1970-01-01T00:00:00.0000000Z ControlRejected Session Deselect Selected - - - 0x03 0x07\n"; + var codec = new SecsTraceDiagnosticCodec(); + + Assert.Throws(() => codec.Decode(string.Empty)); + Assert.Throws(() => codec.Decode(valid.Replace("DiagnosticTrace/1", "DiagnosticTrace/2"))); + Assert.Throws(() => codec.Decode(valid.Replace("ControlRejected", "UnknownCode"))); + Assert.Throws(() => codec.Decode(valid.Replace("ControlRejected", "10"))); + Assert.Throws(() => codec.Decode(valid.Replace("Session", "session"))); + Assert.Throws(() => codec.Decode(valid.Replace("0x03", "0x3"))); + Assert.Throws(() => codec.Decode(valid.Replace("0x07", "0x0a"))); + Assert.Throws(() => codec.Decode(valid.Replace("Diagnostic ", "Diagnostic "))); + } + + [Fact] + public void Codec_and_record_enforce_resource_and_enum_boundaries() + { + var record = new SecsTraceDiagnosticRecord( + Epoch, + HsmsDiagnosticCode.TransportFailure, + HsmsDiagnosticLayer.Transport, + HsmsOperation.Connect, + HsmsSessionState.Disconnected); + var records = new[] { record, record }; + var codec = new SecsTraceDiagnosticCodec(); + var text = codec.Encode(records); + + Assert.Throws(() => codec.Encode(null!)); + Assert.Throws(() => codec.Decode(null!)); + Assert.Throws(() => codec.Encode(new SecsTraceDiagnosticRecord[] { record, null! })); + Assert.Throws(() => new SecsTraceDiagnosticCodec(maxRecordCount: 1).Encode(records)); + Assert.Throws(() => new SecsTraceDiagnosticCodec(maxRecordCount: 1).Decode(text)); + Assert.Throws(() => new SecsTraceDiagnosticCodec(maxTextLength: 20).Encode(Array.Empty())); + Assert.Throws(() => new SecsTraceDiagnosticCodec(maxTextLength: 20).Decode(text)); + Assert.Throws(() => new SecsTraceDiagnosticCodec(maxRecordCount: 0)); + Assert.Throws(() => new SecsTraceDiagnosticCodec(maxTextLength: 0)); + Assert.Throws(() => new SecsTraceDiagnosticRecord( + Epoch, + (HsmsDiagnosticCode)int.MaxValue, + HsmsDiagnosticLayer.Transport, + HsmsOperation.Connect, + HsmsSessionState.Disconnected)); + Assert.Throws(() => SecsTraceDiagnosticRecord.Create(Epoch, null!)); + } + + private static DateTimeOffset Epoch + => new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static void AssertRecordEqual( + SecsTraceDiagnosticRecord expected, + SecsTraceDiagnosticRecord actual) + { + Assert.Equal(expected.Timestamp, actual.Timestamp); + Assert.Equal(expected.Code, actual.Code); + Assert.Equal(expected.Layer, actual.Layer); + Assert.Equal(expected.Operation, actual.Operation); + Assert.Equal(expected.State, actual.State); + Assert.Equal(expected.Timer, actual.Timer); + Assert.Equal(expected.ProtocolSessionId, actual.ProtocolSessionId); + Assert.Equal(expected.SystemBytes, actual.SystemBytes); + Assert.Equal(expected.PeerStatus, actual.PeerStatus); + Assert.Equal(expected.RejectedMessageType, actual.RejectedMessageType); + } +}