From 6819aa8c71551b7ce199580df924baeabe341d14 Mon Sep 17 00:00:00 2001 From: Marci Date: Sat, 29 Aug 2026 19:22:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=A7=A3=E7=A0=81?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=95=85=E9=9A=9C=E6=A0=B7=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 + README.md | 4 +- docs/ARCHITECTURE.md | 11 +- docs/HSMS-CONNECTION.md | 4 + docs/HSMS-DIAGNOSTICS.md | 7 + docs/ROADMAP.md | 9 +- docs/TRACE.md | 57 +- .../SecsTraceByteRedactionRange.cs | 28 + .../SecsTraceFaultSampleCaptureOptions.cs | 95 ++++ .../SecsTraceFaultSampleCodec.cs | 486 ++++++++++++++++++ .../SecsTraceFaultSampleDataClassification.cs | 14 + .../SecsTraceFaultSampleRecord.cs | 232 +++++++++ .../SecsTraceFaultSampleTests.cs | 264 ++++++++++ 13 files changed, 1210 insertions(+), 7 deletions(-) create mode 100644 src/SecsFrame.Trace/SecsTraceByteRedactionRange.cs create mode 100644 src/SecsFrame.Trace/SecsTraceFaultSampleCaptureOptions.cs create mode 100644 src/SecsFrame.Trace/SecsTraceFaultSampleCodec.cs create mode 100644 src/SecsFrame.Trace/SecsTraceFaultSampleDataClassification.cs create mode 100644 src/SecsFrame.Trace/SecsTraceFaultSampleRecord.cs create mode 100644 test/SecsFrame.Tests/SecsTraceFaultSampleTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b3e82..722577d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ ### 新增 +- 增加独立 SecsFrame-FaultSampleTrace/1 信封,为公共 + DataMessageDecodeFailed 事件提供已成帧 HSMS data body 样本。 +- 捕获必须显式选择 MetadataOnly、RedactedPayload 或 RawPayload;默认 codec + 拒绝 payload 导入导出,Redacted 范围固定清零并在严格读取时重新验证。 +- 增加 64 KiB 默认 Body 上限、无截断失败、黄金向量、敏感字节移除、不可变 + 复制、畸形范围/十六进制和资源限制测试;该信封不进入重放路径。 - 增加默认关闭的完整 HSMS 控制消息元数据观测流,覆盖内部 Select、 Linktest、Deselect、Reject 与 Separate 的收发,而不改变现有业务事件流。 - 观测只保留方向、actor 观察状态与原十字节头;入站在协议处理前发布, diff --git a/README.md b/README.md index 56044bf..b3433be 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 设备接口数据字典 | @@ -45,6 +45,8 @@ Equipment 应用发送策略的 Collection Event、报警目录查询、单报 严格的 Item 路径脱敏、显式 allowlist 重放,以及默认关闭的缩放/封顶时序 重放;独立诊断信封只保存稳定标量,不保存异常或原始帧;控制信封可保存 显式启用的完整控制消息观测流或既有未认领控制事件的十字节头元数据。 +解码失败样本另用独立信封,并以元数据为默认导出边界;Body 捕获与 codec +导入导出都必须显式开启,声明的脱敏字节范围会固定清零并严格复核。 控制观测默认关闭,并与业务事件流独立;该包不会自行接管任何连接消费者, 也不会复用原事务标识。 Item 用法与边界见 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1d5d31b..cc63be2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,8 +13,9 @@ StreamFrame TCP、Pipe、帧边界、连接生命周期 E172 SEDD 与 E173 SMN 是可选元数据/表示层,不进入核心通讯路径。 SecsFrame.Sml 同样是只依赖核心动态消息模型的可选调试表示层; 核心包不反向依赖它。 -SecsFrame.Trace 依赖 SML 表示层和核心公共发送 API,只处理已经 -解码的数据消息;它不订阅内部 transport 或创建第二个公共事件消费者。 +SecsFrame.Trace 依赖 SML 表示层和核心公共 API,处理已经解码 +的数据消息,以及调用方显式提供的公共解码失败事件;它不订阅内部 transport +或创建第二个公共事件消费者。 Trace 的时序重放必须显式启用,只在允许发送记录之间等待,并继续通过公共 发送 API 创建新事务;默认重放不引入时间等待。 结构化诊断使用独立只读快照信封:只复制公共诊断的稳定标量,明确排除原 @@ -22,6 +23,9 @@ Trace 的时序重放必须显式启用,只在允许发送记录之间等待 控制消息使用另一个只读元数据信封。核心连接可显式启用独立控制观测流, 从会话 actor 复制全部收发控制消息的十字节头字段;Trace 仍由调用方消费 该流并创建记录,不自行订阅连接。既有公共未认领控制事件工厂继续可用。 +已成帧但 SECS-II Body 解码失败的样本使用独立信封:捕获和 codec 分别要求 +显式数据边界,默认只允许头与原 Body 长度;payload-bearing 记录不能进入 +重放。T8 和成帧失败片段尚未进入该模型。 ## 核心原则 @@ -179,6 +183,7 @@ transport Session generation、StreamFrame 公共发送 API,不得直接构造或写出保留旧事务标识的线上帧;可选时序只 控制相邻发送前的等待,不得绕过连接状态、T3 或显式 allowlist。诊断 导出不得隐式包含 Exception 或 HsmsFrame;控制 - 元数据导出不得包含 Body 或隐式订阅任何连接流。 + 元数据导出不得包含 Body 或隐式订阅任何连接流。故障样本只能从调用方 + 已取得的解码失败事件显式创建,默认不得导入或导出 Body,也不得进入重放。 - 核心包不得依赖 GEM、SML/SMN、依赖注入容器或具体日志实现。 - 状态机计时使用可替换的时间抽象,测试不得依赖真实长时间等待。 diff --git a/docs/HSMS-CONNECTION.md b/docs/HSMS-CONNECTION.md index 828f013..73d810c 100644 --- a/docs/HSMS-CONNECTION.md +++ b/docs/HSMS-CONNECTION.md @@ -86,6 +86,10 @@ var secondary = await connection.SendAsync( - ControlMessageReceived:未被当前控制或数据事务认领的帧; - DataMessageDecodeFailed:原始帧和严格解码错误。 +DataMessageDecodeFailed 的帧可能包含设备或工艺数据。默认应只 +使用结构化诊断;需要保留已成帧互操作样本时,可使用 Trace 包的显式分级 +故障样本信封,见 [TRACE.md](TRACE.md)。它不会自动订阅本事件流。 + 调用方在自己的事件循环中可以使用 SecsTraceControlRecord.CreateReceived 对 ControlMessageReceived 创建不含 Body 的头元数据快照。该事件 diff --git a/docs/HSMS-DIAGNOSTICS.md b/docs/HSMS-DIAGNOSTICS.md index 01a306d..e71c0e8 100644 --- a/docs/HSMS-DIAGNOSTICS.md +++ b/docs/HSMS-DIAGNOSTICS.md @@ -79,6 +79,13 @@ Session ID 与 System Bytes;拒绝诊断保留远端状态或原因字节。 System Bytes 和远端状态字节仍属于运维元数据,导出文件仍应使用适当的 访问控制与保留策略。具体格式和示例见 [TRACE.md](TRACE.md)。 +需要保留互操作样本时,SecsFrame.Trace 另提供 +SecsFrame-FaultSampleTrace/1。它只接受公共 +DataMessageDecodeFailed 事件,并要求调用方显式选择仅元数据、 +声明范围清零后的 Body 或原始 Body。默认 codec 只允许元数据记录;任何 +带 Body 的导入或导出都要再次显式开启。该信封不包含异常、TCP 长度前缀、 +分片、transport Session generation 或 T8 未完成片段。 + ## 标准边界 诊断名称描述 SecsFrame 已实现并测试的工程行为,不新增 SEMI 默认值,也 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index daf9aa6..467bffe 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -113,14 +113,19 @@ E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写 - [x] 不包含异常与原始帧的受限结构化诊断 Trace 导出和严格读取。 - [x] 公共未认领控制消息的受限头元数据 Trace 导出和严格读取。 - [x] 默认关闭、不含 Body/原始字节的完整控制面头元数据观测。 -- 故障原始样本等互操作诊断扩展。 +- [x] 显式分级、默认不导出 Body 的数据消息解码失败样本。 +- T8/成帧失败原始片段等运输层互操作诊断扩展。 当前 Trace 基线不接管连接事件流,也不把原始标识写回线上。时序重放默认 关闭,并继续经过显式 allowlist 与公共发送 API。诊断快照始终排除 Error 与 Frame。完整控制元数据观测必须在连接 选项中显式启用,覆盖内部握手但只复制方向、actor 状态和十字节头,不含 Body、原始线上字节、transport generation 或时间戳。后续故障原始样本 -扩展必须先定义原始字节的数据分级、默认不导出策略和可验证脱敏边界。 +已先覆盖公共事件中忠实保留的完整 data frame:捕获分为 MetadataOnly、 +RedactedPayload 与 RawPayload,payload codec 默认关闭,声明脱敏范围必须 +全为零且可严格复核。它不包含 TCP 长度前缀或分片,也不能进入重放。 +T8/成帧失败片段仍需先把 StreamFrame 原始错误数据与 transport session +安全关联,并保持默认不捕获、容量限制和跨会话隔离。 E173 SMN 与 E172 SEDD 仍需先核对授权标准及允许分发的 Schema/表示形式。 ## 发布门槛 diff --git a/docs/TRACE.md b/docs/TRACE.md index 7850588..a8994fd 100644 --- a/docs/TRACE.md +++ b/docs/TRACE.md @@ -11,6 +11,7 @@ - 不包含异常和原始帧的结构化 HSMS 诊断快照导出与严格读取; - 显式启用的完整控制观测或公共未认领控制事件的十字节头元数据导出与 严格读取。 +- 已成帧但 SECS-II Body 解码失败的显式分级故障样本。 当前切片不是原始网络抓包器,不记录 TCP 字节、transport Session generation、 控制帧的线上原始字节或异常对象,也不会接管 HsmsConnection 的 @@ -131,6 +132,58 @@ PCAP 级线上时序;需要线速证据时不能从本元数据流推断字节 数据事务认领的控制帧,主要用于未匹配 Reject。两条创建路径生成同一信封; 控制信封不能进入消息重放器,头字段仍应按运维元数据执行访问控制与保留策略。 +## 解码失败样本 + +SecsFrame-FaultSampleTrace/1 只接受应用从现有事件循环取得的 +DataMessageDecodeFailed。捕获时必须显式选择三种数据分类之一: + +- MetadataOnly:只保存十字节头字段和原 Body 长度; +- RedactedPayload:复制 Body,并把声明的非重叠字节范围清零; +- RawPayload:显式复制未脱敏 Body。 + +默认 codec 只允许 MetadataOnly。即使已经显式捕获 payload,导入或导出仍 +必须再次设置 allowPayloadRecords: true: + +~~~csharp +if (connectionEvent.Kind == HsmsConnectionEventKind.DataMessageDecodeFailed) +{ + var capture = SecsTraceFaultSampleCaptureOptions.RedactedPayload( + new[] + { + new SecsTraceByteRedactionRange(offset: 4, length: 12), + }); + var record = SecsTraceFaultSampleRecord.Create( + DateTimeOffset.UtcNow, + connectionEvent, + capture); + var codec = new SecsTraceFaultSampleCodec( + allowPayloadRecords: true); + var text = codec.Encode(new[] { record }); +} +~~~ + +独立信封使用 14 个单空格分隔字段: + +~~~text +SecsFrame-FaultSampleTrace/1 +Fault 2026-08-29T05:00:00.0000000Z DataMessageDecodeFailed Selected RedactedPayload 23 0x81 0x02 0x00 0x00 0xAABBCCDD 10 2:6 412D0000000000002D5A +~~~ + +字段依次为 UTC 时间、固定诊断代码、会话状态、数据分类、原十字节头字段、 +原 Body 长度、逗号分隔的 offset:length 脱敏范围,以及大写 +十六进制 Body。范围相对 SECS-II Body,不包含 HSMS 四字节长度前缀或十字节 +头。Redacted 记录必须至少声明一个范围,范围必须有序、非重叠、不越界, +且严格读取时会验证范围内每个字节都为零。 + +payload 捕获默认限制为 64 KiB;超限立即失败,不截断样本。调用方可显式 +收紧或扩大限制,codec 另行执行自己的 Body、记录数和文本长度限制。记录 +防御性复制 Body,后续修改事件原数组不会改变样本。 + +该信封只重建已经完成 HSMS 成帧和头解码的 payload,不是 TCP/PCAP 抓包, +不包含异常、长度前缀、分片时序、transport Session generation 或 T8 +未完成片段,也没有重放 API。清零范围可验证,但哪些范围包含敏感数据仍是 +调用方的数据分类责任;未经独立审查不得把 Redacted 等同于已安全公开。 + ## 结构化脱敏 脱敏发生在不可变 Item 树上,而不是对 SML 文本做字符串替换: @@ -223,4 +276,6 @@ var results = await new SecsTraceReplayer().ReplayWithTimingAsync( 当前工具组合边界,不代表设备命令安全性或 SEMI 一致性认证。诊断信封另有 黄金向量、可选字段往返、严格枚举/十六进制、资源限制及异常/帧内容不泄漏 测试。控制信封另有完整 Select/Linktest 双向观测、未匹配 Reject 黄金向量、 -未知 SType 往返、Data Message 拒绝、严格字段与资源限制测试。 +未知 SType 往返、Data Message 拒绝、严格字段与资源限制测试。故障样本 +信封另有三种分类、payload 默认拒绝、范围清零复核、防御性复制、畸形字段 +和 Body/记录/文本资源限制测试。 diff --git a/src/SecsFrame.Trace/SecsTraceByteRedactionRange.cs b/src/SecsFrame.Trace/SecsTraceByteRedactionRange.cs new file mode 100644 index 0000000..2b374e3 --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceByteRedactionRange.cs @@ -0,0 +1,28 @@ +using System.Runtime.InteropServices; + +namespace SecsFrame.Trace; + +/// Identifies a zero-based range within an HSMS data-message body. +[StructLayout(LayoutKind.Auto)] +public readonly record struct SecsTraceByteRedactionRange +{ + /// Creates a nonempty byte range. + public SecsTraceByteRedactionRange(int offset, int length) + { + if (offset < 0) + throw new ArgumentOutOfRangeException(nameof(offset), offset, "The byte offset cannot be negative."); + if (length <= 0) + throw new ArgumentOutOfRangeException(nameof(length), length, "The byte range length must be positive."); + if (offset > int.MaxValue - length) + throw new ArgumentOutOfRangeException(nameof(length), length, "The byte range end exceeds the supported integer range."); + + Offset = offset; + Length = length; + } + + /// Gets the zero-based body offset. + public int Offset { get; } + + /// Gets the number of bytes in the range. + public int Length { get; } +} diff --git a/src/SecsFrame.Trace/SecsTraceFaultSampleCaptureOptions.cs b/src/SecsFrame.Trace/SecsTraceFaultSampleCaptureOptions.cs new file mode 100644 index 0000000..05f1473 --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceFaultSampleCaptureOptions.cs @@ -0,0 +1,95 @@ +using System.Collections.ObjectModel; + +namespace SecsFrame.Trace; + +/// +/// Defines the explicit data boundary for one decode-fault sample capture. +/// +public sealed class SecsTraceFaultSampleCaptureOptions +{ + /// The default maximum body copied into one sample: 64 KiB. + public const int DefaultMaxBodyBytes = 64 * 1024; + + private SecsTraceFaultSampleCaptureOptions( + SecsTraceFaultSampleDataClassification dataClassification, + IEnumerable? redactionRanges, + int maxBodyBytes) + { + if (maxBodyBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(maxBodyBytes), maxBodyBytes, "The maximum body size must be positive."); + + var ranges = PrepareRanges(redactionRanges); + if (dataClassification == SecsTraceFaultSampleDataClassification.RedactedPayload) + { + if (ranges.Count == 0) + throw new ArgumentException("Redacted payload capture requires at least one byte range.", nameof(redactionRanges)); + } + else if (ranges.Count != 0) + { + throw new ArgumentException("Only redacted payload capture can declare byte ranges.", nameof(redactionRanges)); + } + + DataClassification = dataClassification; + RedactionRanges = ranges; + MaxBodyBytes = maxBodyBytes; + } + + /// Gets how much payload data can be copied. + public SecsTraceFaultSampleDataClassification DataClassification { get; } + + /// Gets the ordered, nonoverlapping ranges zeroed before capture. + public IReadOnlyList RedactionRanges { get; } + + /// Gets the maximum body size copied by payload-bearing modes. + public int MaxBodyBytes { get; } + + /// Creates an option that captures no body bytes. + public static SecsTraceFaultSampleCaptureOptions MetadataOnly() + => new( + SecsTraceFaultSampleDataClassification.MetadataOnly, + null, + DefaultMaxBodyBytes); + + /// Creates an option that copies and zeroes declared body ranges. + public static SecsTraceFaultSampleCaptureOptions RedactedPayload( + IEnumerable redactionRanges, + int maxBodyBytes = DefaultMaxBodyBytes) + { + if (redactionRanges is null) + throw new ArgumentNullException(nameof(redactionRanges)); + + return new( + SecsTraceFaultSampleDataClassification.RedactedPayload, + redactionRanges, + maxBodyBytes); + } + + /// Creates an option that explicitly copies the unredacted body. + public static SecsTraceFaultSampleCaptureOptions RawPayload( + int maxBodyBytes = DefaultMaxBodyBytes) + => new( + SecsTraceFaultSampleDataClassification.RawPayload, + null, + maxBodyBytes); + + private static IReadOnlyList PrepareRanges( + IEnumerable? ranges) + { + if (ranges is null) + return Array.Empty(); + + var prepared = ranges.OrderBy(static item => item.Offset).ToArray(); + for (var index = 1; index < prepared.Length; index++) + { + var previous = prepared[index - 1]; + if (previous.Offset + previous.Length > prepared[index].Offset) + { + throw new ArgumentException( + "Byte redaction ranges cannot overlap.", + nameof(ranges)); + } + } + + return new ReadOnlyCollection(prepared); + } +} diff --git a/src/SecsFrame.Trace/SecsTraceFaultSampleCodec.cs b/src/SecsFrame.Trace/SecsTraceFaultSampleCodec.cs new file mode 100644 index 0000000..5e7d5f4 --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceFaultSampleCodec.cs @@ -0,0 +1,486 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; + +namespace SecsFrame.Trace; + +/// Encodes and decodes explicitly classified decode-fault samples. +public sealed class SecsTraceFaultSampleCodec +{ + /// The exact fault-sample trace format identifier. + public const string FormatIdentifier = "SecsFrame-FaultSampleTrace/1"; + + /// The default maximum number of records in one fault-sample trace. + public const int DefaultMaxRecordCount = SecsTraceCodec.DefaultMaxRecordCount; + + /// The default maximum fault-sample trace text length. + public const int DefaultMaxTextLength = SecsTraceCodec.DefaultMaxTextLength; + + /// The default maximum body accepted in one payload-bearing record. + public const int DefaultMaxBodyBytes = + SecsTraceFaultSampleCaptureOptions.DefaultMaxBodyBytes; + + /// Creates a fault-sample codec with explicit data and resource limits. + public SecsTraceFaultSampleCodec( + bool allowPayloadRecords = false, + int maxBodyBytes = DefaultMaxBodyBytes, + int maxRecordCount = DefaultMaxRecordCount, + int maxTextLength = DefaultMaxTextLength) + { + if (maxBodyBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(maxBodyBytes), maxBodyBytes, "The maximum body size must be positive."); + 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."); + + AllowPayloadRecords = allowPayloadRecords; + MaxBodyBytes = maxBodyBytes; + MaxRecordCount = maxRecordCount; + MaxTextLength = maxTextLength; + } + + /// Gets whether payload-bearing records can be imported or exported. + public bool AllowPayloadRecords { get; } + + /// Gets the maximum body bytes in one payload-bearing record. + public int MaxBodyBytes { get; } + + /// Gets the maximum number of records in one fault-sample trace. + public int MaxRecordCount { get; } + + /// Gets the maximum accepted or produced fault-sample 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 fault-sample trace sequence contains a null record.", nameof(records)); + if (++recordCount > MaxRecordCount) + throw new InvalidOperationException($"The fault-sample trace record count exceeds the configured maximum {MaxRecordCount}."); + + EnsureRecordAllowed(record); + AppendRecord(text, record); + EnsureTextLength(text); + } + + return text.ToString(); + } + + /// Strictly decodes one complete fault-sample 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 fault-sample trace text length cannot exceed {MaxTextLength} characters."); + + return new Parser(text, this).Parse(); + } + + private void EnsureRecordAllowed(SecsTraceFaultSampleRecord record) + { + if (record.DataClassification != + SecsTraceFaultSampleDataClassification.MetadataOnly && + !AllowPayloadRecords) + { + throw new InvalidOperationException( + "Payload-bearing fault samples require AllowPayloadRecords to be enabled explicitly."); + } + + if (record.Body.Length > MaxBodyBytes) + { + throw new InvalidOperationException( + $"The fault-sample body length {record.Body.Length} exceeds the configured maximum {MaxBodyBytes}."); + } + } + + private static void AppendRecord( + StringBuilder text, + SecsTraceFaultSampleRecord record) + { + text.Append("Fault "); + text.Append(record.Timestamp.UtcDateTime.ToString("O", CultureInfo.InvariantCulture)); + text.Append(' '); + text.Append(record.Code); + text.Append(' '); + text.Append(record.State); + text.Append(' '); + text.Append(record.DataClassification); + text.Append(' '); + text.Append(record.Header.SessionId.ToString(CultureInfo.InvariantCulture)); + text.Append(' '); + AppendByte(text, record.Header.HeaderByte2); + text.Append(' '); + AppendByte(text, record.Header.HeaderByte3); + text.Append(' '); + AppendByte(text, record.Header.PresentationType); + text.Append(' '); + AppendByte(text, (byte)record.Header.MessageType); + text.Append(' '); + text.Append("0x"); + text.Append(record.Header.SystemBytes.ToString("X8", CultureInfo.InvariantCulture)); + text.Append(' '); + text.Append(record.OriginalBodyLength.ToString(CultureInfo.InvariantCulture)); + text.Append(' '); + AppendRanges(text, record.RedactionRanges); + text.Append(' '); + AppendBody(text, record.Body); + text.Append('\n'); + } + + private static void AppendByte(StringBuilder text, byte value) + { + text.Append("0x"); + text.Append(value.ToString("X2", CultureInfo.InvariantCulture)); + } + + private static void AppendRanges( + StringBuilder text, + IReadOnlyList ranges) + { + if (ranges.Count == 0) + { + text.Append('-'); + return; + } + + for (var index = 0; index < ranges.Count; index++) + { + if (index != 0) + text.Append(','); + text.Append(ranges[index].Offset.ToString(CultureInfo.InvariantCulture)); + text.Append(':'); + text.Append(ranges[index].Length.ToString(CultureInfo.InvariantCulture)); + } + } + + private static void AppendBody(StringBuilder text, ReadOnlySpan body) + { + if (body.IsEmpty) + { + text.Append('-'); + return; + } + + foreach (var value in body) + text.Append(value.ToString("X2", CultureInfo.InvariantCulture)); + } + + private void EnsureTextLength(StringBuilder text) + { + if (text.Length > MaxTextLength) + throw new InvalidOperationException($"The fault-sample trace text length exceeds the configured maximum {MaxTextLength}."); + } + + private sealed class Parser + { + private readonly string _text; + private readonly SecsTraceFaultSampleCodec _options; + private int _index; + private int _recordIndex; + + public Parser(string text, SecsTraceFaultSampleCodec options) + { + _text = text; + _options = options; + } + + public IReadOnlyList Parse() + { + var headerOffset = _index; + var formatIdentifier = ReadLine(); + if (!string.Equals(formatIdentifier, FormatIdentifier, StringComparison.Ordinal)) + throw Error("The fault-sample trace format identifier is missing or unsupported", -1, headerOffset); + + var records = new List(); + while (!IsEnd) + { + if (_recordIndex >= _options.MaxRecordCount) + throw Error($"The fault-sample trace record count exceeds the configured maximum {_options.MaxRecordCount}", _recordIndex, _index); + + records.Add(ParseRecord()); + _recordIndex++; + } + + return new ReadOnlyCollection(records); + } + + private SecsTraceFaultSampleRecord ParseRecord() + { + var headerOffset = _index; + var fields = ReadLine().Split(new[] { ' ' }, StringSplitOptions.None); + if (fields.Length != 14 || !string.Equals(fields[0], "Fault", StringComparison.Ordinal)) + throw Error("A fault-sample record must contain exactly fourteen single-space-separated fields", _recordIndex, headerOffset); + + ParseCode(fields[2], headerOffset); + var classification = ParseEnum( + fields[4], + "data classification", + headerOffset); + if (classification != SecsTraceFaultSampleDataClassification.MetadataOnly && + !_options.AllowPayloadRecords) + { + throw Error( + "Payload-bearing fault samples require AllowPayloadRecords to be enabled explicitly", + _recordIndex, + headerOffset); + } + + var header = new HsmsMessageHeader + { + SessionId = ParseUInt16(fields[5], "protocol Session ID", headerOffset), + HeaderByte2 = ParseByte(fields[6], "header byte 2", headerOffset), + HeaderByte3 = ParseByte(fields[7], "header byte 3", headerOffset), + PresentationType = ParseByte(fields[8], "PType", headerOffset), + MessageType = (HsmsMessageType)ParseByte(fields[9], "SType", headerOffset), + SystemBytes = ParseUInt32(fields[10], "System Bytes", headerOffset), + }; + var body = ParseBody(fields[13], headerOffset); + var ranges = ParseRanges(fields[12], headerOffset); + + try + { + return new SecsTraceFaultSampleRecord( + ParseTimestamp(fields[1], headerOffset), + ParseEnum(fields[3], "session state", headerOffset), + classification, + header, + ParseInt32(fields[11], "original body length", headerOffset), + body, + ranges); + } + catch (ArgumentException ex) + { + throw Error( + "The fault-sample data boundary is invalid", + _recordIndex, + headerOffset, + ex); + } + } + + private void ParseCode(string value, int offset) + { + if (!string.Equals( + value, + HsmsDiagnosticCode.DataMessageDecodeFailed.ToString(), + StringComparison.Ordinal)) + { + throw Error( + "The fault-sample diagnostic code must be DataMessageDecodeFailed", + _recordIndex, + offset); + } + } + + private DateTimeOffset ParseTimestamp(string value, int offset) + { + if (!DateTimeOffset.TryParseExact( + value, + "O", + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var timestamp)) + { + throw Error("The fault-sample 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 ushort ParseUInt16( + string value, + string fieldName, + int offset) + { + if (!ushort.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed)) + throw Error($"The {fieldName} must be an unsigned 16-bit integer", _recordIndex, offset); + return parsed; + } + + private int ParseInt32( + string value, + string fieldName, + int offset) + { + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed)) + throw Error($"The {fieldName} must be a nonnegative 32-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 ParseUInt32( + string value, + string fieldName, + int offset) + { + if (!HasExactHexForm(value, 8) || + !uint.TryParse(value.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var parsed)) + { + throw Error($"The {fieldName} must use the exact form 0xHHHHHHHH", _recordIndex, offset); + } + + return parsed; + } + + private IReadOnlyList ParseRanges( + string value, + int offset) + { + if (string.Equals(value, "-", StringComparison.Ordinal)) + return Array.Empty(); + + var parts = value.Split(','); + var ranges = new SecsTraceByteRedactionRange[parts.Length]; + for (var index = 0; index < parts.Length; index++) + { + var fields = parts[index].Split(':'); + if (fields.Length != 2) + throw Error("A redaction range must use the exact form offset:length", _recordIndex, offset); + var rangeOffset = ParseCanonicalInt32(fields[0], "redaction offset", offset, allowZero: true); + var length = ParseCanonicalInt32(fields[1], "redaction length", offset, allowZero: false); + try + { + ranges[index] = new SecsTraceByteRedactionRange(rangeOffset, length); + } + catch (ArgumentOutOfRangeException ex) + { + throw Error("A redaction range is outside the supported integer range", _recordIndex, offset, ex); + } + } + + return ranges; + } + + private int ParseCanonicalInt32( + string value, + string fieldName, + int offset, + bool allowZero) + { + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) || + parsed < (allowZero ? 0 : 1) || + !string.Equals(value, parsed.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)) + { + throw Error($"The {fieldName} is not canonical", _recordIndex, offset); + } + + return parsed; + } + + private byte[] ParseBody(string value, int offset) + { + if (string.Equals(value, "-", StringComparison.Ordinal)) + return Array.Empty(); + if (value.Length % 2 != 0) + throw Error("The body must contain an even number of uppercase hexadecimal digits", _recordIndex, offset); + + var bodyLength = value.Length / 2; + if (bodyLength > _options.MaxBodyBytes) + throw Error($"The fault-sample body length exceeds the configured maximum {_options.MaxBodyBytes}", _recordIndex, offset); + + var body = new byte[bodyLength]; + for (var index = 0; index < body.Length; index++) + { + var high = ParseHexDigit(value[index * 2]); + var low = ParseHexDigit(value[(index * 2) + 1]); + if (high < 0 || low < 0) + throw Error("The body must contain only uppercase hexadecimal digits", _recordIndex, offset); + body[index] = (byte)((high << 4) | low); + } + + return body; + } + + private static int ParseHexDigit(char value) + => value switch + { + >= '0' and <= '9' => value - '0', + >= 'A' and <= 'F' => value - 'A' + 10, + _ => -1, + }; + + 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, + Exception? innerException = null) + => new(message, recordIndex, offset, innerException); + } +} diff --git a/src/SecsFrame.Trace/SecsTraceFaultSampleDataClassification.cs b/src/SecsFrame.Trace/SecsTraceFaultSampleDataClassification.cs new file mode 100644 index 0000000..b602956 --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceFaultSampleDataClassification.cs @@ -0,0 +1,14 @@ +namespace SecsFrame.Trace; + +/// Identifies how much payload data a fault-sample record contains. +public enum SecsTraceFaultSampleDataClassification +{ + /// Only the HSMS header and original body length are retained. + MetadataOnly, + + /// The body is retained after declared byte ranges are zeroed. + RedactedPayload, + + /// The original body bytes are retained without redaction. + RawPayload, +} diff --git a/src/SecsFrame.Trace/SecsTraceFaultSampleRecord.cs b/src/SecsFrame.Trace/SecsTraceFaultSampleRecord.cs new file mode 100644 index 0000000..4548e47 --- /dev/null +++ b/src/SecsFrame.Trace/SecsTraceFaultSampleRecord.cs @@ -0,0 +1,232 @@ +using System.Collections.ObjectModel; + +namespace SecsFrame.Trace; + +/// +/// Describes an explicitly classified sample of one undecodable HSMS data message. +/// +/// +/// The sample contains the original HSMS header fields and optionally a defensive +/// copy of the SECS-II body. It never contains an exception, TCP framing bytes, +/// transport-session generation, or socket-fragment timing. +/// +public sealed class SecsTraceFaultSampleRecord +{ + private readonly byte[] _body; + + /// Creates an immutable decode-fault sample record. + public SecsTraceFaultSampleRecord( + DateTimeOffset timestamp, + HsmsSessionState state, + SecsTraceFaultSampleDataClassification dataClassification, + HsmsMessageHeader header, + int originalBodyLength, + ReadOnlySpan body = default, + IEnumerable? redactionRanges = null) + { + if (!Enum.IsDefined(typeof(HsmsSessionState), state)) + throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown HSMS session state."); + if (!Enum.IsDefined(typeof(SecsTraceFaultSampleDataClassification), dataClassification)) + throw new ArgumentOutOfRangeException(nameof(dataClassification), dataClassification, "Unknown fault-sample data classification."); + if (!header.IsDataMessage) + throw new ArgumentException("A decode-fault sample requires an HSMS data-message header.", nameof(header)); + if (originalBodyLength < 0) + throw new ArgumentOutOfRangeException(nameof(originalBodyLength), originalBodyLength, "The original body length cannot be negative."); + + var ranges = PrepareRanges(redactionRanges); + ValidateDataBoundary( + dataClassification, + originalBodyLength, + body, + ranges); + + Timestamp = timestamp.ToUniversalTime(); + State = state; + DataClassification = dataClassification; + Header = header; + OriginalBodyLength = originalBodyLength; + _body = body.ToArray(); + RedactionRanges = ranges; + } + + /// Gets the UTC timestamp associated with the local observation. + public DateTimeOffset Timestamp { get; } + + /// Gets the stable diagnostic code represented by this sample. + public HsmsDiagnosticCode Code => HsmsDiagnosticCode.DataMessageDecodeFailed; + + /// Gets the session state observed with the decode failure. + public HsmsSessionState State { get; } + + /// Gets how much payload data the record contains. + public SecsTraceFaultSampleDataClassification DataClassification { get; } + + /// Gets the original ten-byte HSMS data-message header. + public HsmsMessageHeader Header { get; } + + /// Gets the original SECS-II body length before classification. + public int OriginalBodyLength { get; } + + /// Gets the copied body bytes, empty for metadata-only records. + public ReadOnlySpan Body => _body; + + /// Gets the ordered ranges that were zeroed before capture. + public IReadOnlyList RedactionRanges { get; } + + /// + /// Creates a sample from a public data-message decode-failure event. + /// + public static SecsTraceFaultSampleRecord Create( + DateTimeOffset timestamp, + HsmsConnectionEvent connectionEvent, + SecsTraceFaultSampleCaptureOptions options) + { + if (connectionEvent is null) + throw new ArgumentNullException(nameof(connectionEvent)); + if (options is null) + throw new ArgumentNullException(nameof(options)); + if (connectionEvent.Kind != HsmsConnectionEventKind.DataMessageDecodeFailed || + connectionEvent.Frame is null || + connectionEvent.Diagnostic?.Code != + HsmsDiagnosticCode.DataMessageDecodeFailed) + { + throw new ArgumentException( + "The connection event must contain a data-message decode failure.", + nameof(connectionEvent)); + } + + var frame = connectionEvent.Frame; + var body = frame.Body.Span; + return options.DataClassification switch + { + SecsTraceFaultSampleDataClassification.MetadataOnly => + new SecsTraceFaultSampleRecord( + timestamp, + connectionEvent.State, + options.DataClassification, + frame.Header, + body.Length), + SecsTraceFaultSampleDataClassification.RedactedPayload => + CreateRedacted(timestamp, connectionEvent.State, frame, options), + SecsTraceFaultSampleDataClassification.RawPayload => + CreateRaw(timestamp, connectionEvent.State, frame, options), + _ => throw new ArgumentOutOfRangeException( + nameof(options), + options.DataClassification, + "Unknown fault-sample data classification."), + }; + } + + private static SecsTraceFaultSampleRecord CreateRedacted( + DateTimeOffset timestamp, + HsmsSessionState state, + HsmsFrame frame, + SecsTraceFaultSampleCaptureOptions options) + { + ValidateBodySize(frame.Body.Length, options.MaxBodyBytes); + var body = frame.Body.ToArray(); + foreach (var range in options.RedactionRanges) + { + if (range.Offset + range.Length > body.Length) + { + throw new ArgumentOutOfRangeException( + nameof(options), + range, + "A byte redaction range exceeds the HSMS data-message body."); + } + + Array.Clear(body, range.Offset, range.Length); + } + + return new SecsTraceFaultSampleRecord( + timestamp, + state, + options.DataClassification, + frame.Header, + body.Length, + body, + options.RedactionRanges); + } + + private static SecsTraceFaultSampleRecord CreateRaw( + DateTimeOffset timestamp, + HsmsSessionState state, + HsmsFrame frame, + SecsTraceFaultSampleCaptureOptions options) + { + ValidateBodySize(frame.Body.Length, options.MaxBodyBytes); + return new SecsTraceFaultSampleRecord( + timestamp, + state, + options.DataClassification, + frame.Header, + frame.Body.Length, + frame.Body.Span); + } + + private static void ValidateBodySize(int bodyLength, int maxBodyBytes) + { + if (bodyLength > maxBodyBytes) + { + throw new InvalidOperationException( + $"The fault-sample body length {bodyLength} exceeds the configured maximum {maxBodyBytes}."); + } + } + + private static IReadOnlyList PrepareRanges( + IEnumerable? ranges) + { + if (ranges is null) + return Array.Empty(); + + var prepared = ranges.ToArray(); + for (var index = 1; index < prepared.Length; index++) + { + var previous = prepared[index - 1]; + if (previous.Offset + previous.Length > prepared[index].Offset) + { + throw new ArgumentException( + "Byte redaction ranges must be ordered and cannot overlap.", + nameof(ranges)); + } + } + + return new ReadOnlyCollection(prepared); + } + + private static void ValidateDataBoundary( + SecsTraceFaultSampleDataClassification dataClassification, + int originalBodyLength, + ReadOnlySpan body, + IReadOnlyList ranges) + { + if (dataClassification == SecsTraceFaultSampleDataClassification.MetadataOnly) + { + if (!body.IsEmpty || ranges.Count != 0) + throw new ArgumentException("A metadata-only sample cannot contain body bytes or redaction ranges.", nameof(body)); + return; + } + + if (body.Length != originalBodyLength) + throw new ArgumentException("A payload sample must retain the original body length.", nameof(body)); + if (dataClassification == SecsTraceFaultSampleDataClassification.RawPayload) + { + if (ranges.Count != 0) + throw new ArgumentException("A raw payload sample cannot declare redaction ranges.", nameof(ranges)); + return; + } + + if (ranges.Count == 0) + throw new ArgumentException("A redacted payload sample requires at least one byte range.", nameof(ranges)); + foreach (var range in ranges) + { + if (range.Offset + range.Length > body.Length) + throw new ArgumentOutOfRangeException(nameof(ranges), range, "A byte redaction range exceeds the sample body."); + for (var index = range.Offset; index < range.Offset + range.Length; index++) + { + if (body[index] != 0) + throw new ArgumentException("Every declared redaction range must contain only zero bytes.", nameof(body)); + } + } + } +} diff --git a/test/SecsFrame.Tests/SecsTraceFaultSampleTests.cs b/test/SecsFrame.Tests/SecsTraceFaultSampleTests.cs new file mode 100644 index 0000000..eec0e2e --- /dev/null +++ b/test/SecsFrame.Tests/SecsTraceFaultSampleTests.cs @@ -0,0 +1,264 @@ +using System.Text; +using SecsFrame.Trace; + +namespace SecsFrame.Tests; + +public sealed class SecsTraceFaultSampleTests +{ + [Fact] + public void Redacted_capture_produces_a_deterministic_verifiable_vector() + { + var body = Encoding.ASCII.GetBytes("A-SECRET-Z"); + var connectionEvent = CreateDecodeEvent(body); + var options = SecsTraceFaultSampleCaptureOptions.RedactedPayload( + new[] { new SecsTraceByteRedactionRange(2, 6) }); + var timestamp = new DateTimeOffset( + 2026, + 8, + 29, + 13, + 0, + 0, + TimeSpan.FromHours(8)); + var record = SecsTraceFaultSampleRecord.Create( + timestamp, + connectionEvent, + options); + var codec = new SecsTraceFaultSampleCodec( + allowPayloadRecords: true); + + var text = codec.Encode(new[] { record }); + + Assert.Equal( + "SecsFrame-FaultSampleTrace/1\n" + + "Fault 2026-08-29T05:00:00.0000000Z DataMessageDecodeFailed Selected RedactedPayload 23 0x81 0x02 0x00 0x00 0xAABBCCDD 10 2:6 412D0000000000002D5A\n", + text); + Assert.DoesNotContain("534543524554", text, StringComparison.Ordinal); + var decoded = Assert.Single(codec.Decode(text)); + AssertRecordEqual(record, decoded); + Assert.All( + decoded.Body.Slice(2, 6).ToArray(), + static value => Assert.Equal((byte)0, value)); + } + + [Fact] + public void Default_codec_allows_metadata_only_and_rejects_payload_records() + { + var connectionEvent = CreateDecodeEvent( + Encoding.ASCII.GetBytes("SECRET")); + var metadata = SecsTraceFaultSampleRecord.Create( + Epoch, + connectionEvent, + SecsTraceFaultSampleCaptureOptions.MetadataOnly()); + var raw = SecsTraceFaultSampleRecord.Create( + Epoch, + connectionEvent, + SecsTraceFaultSampleCaptureOptions.RawPayload()); + var defaultCodec = new SecsTraceFaultSampleCodec(); + var payloadCodec = new SecsTraceFaultSampleCodec( + allowPayloadRecords: true); + + var metadataText = defaultCodec.Encode(new[] { metadata }); + var decoded = Assert.Single(defaultCodec.Decode(metadataText)); + var rawText = payloadCodec.Encode(new[] { raw }); + + Assert.Equal( + SecsTraceFaultSampleDataClassification.MetadataOnly, + decoded.DataClassification); + Assert.Equal(6, decoded.OriginalBodyLength); + Assert.True(decoded.Body.IsEmpty); + Assert.Throws( + () => defaultCodec.Encode(new[] { raw })); + Assert.Throws( + () => defaultCodec.Decode(rawText)); + } + + [Fact] + public void Raw_capture_is_defensively_copied_and_round_trips_when_enabled() + { + var body = new byte[] { 0x20, 0x01, 0xFF }; + var record = SecsTraceFaultSampleRecord.Create( + Epoch, + CreateDecodeEvent(body), + SecsTraceFaultSampleCaptureOptions.RawPayload()); + body[0] = 0; + var codec = new SecsTraceFaultSampleCodec( + allowPayloadRecords: true); + + var decoded = Assert.Single(codec.Decode(codec.Encode(new[] { record }))); + + Assert.Equal(new byte[] { 0x20, 0x01, 0xFF }, record.Body.ToArray()); + AssertRecordEqual(record, decoded); + } + + [Fact] + public void Capture_factory_rejects_wrong_events_oversize_and_outside_ranges() + { + var decodeEvent = CreateDecodeEvent(new byte[4]); + var controlEvent = HsmsConnectionEvent.ControlMessageReceived( + HsmsSessionState.Selected, + new HsmsFrame( + HsmsMessageHeader.CreateControl( + HsmsMessageType.LinktestRequest, + 1))); + + Assert.Throws(() => + SecsTraceFaultSampleRecord.Create( + Epoch, + null!, + SecsTraceFaultSampleCaptureOptions.MetadataOnly())); + Assert.Throws(() => + SecsTraceFaultSampleRecord.Create(Epoch, decodeEvent, null!)); + Assert.Throws(() => + SecsTraceFaultSampleRecord.Create( + Epoch, + controlEvent, + SecsTraceFaultSampleCaptureOptions.MetadataOnly())); + Assert.Throws(() => + SecsTraceFaultSampleRecord.Create( + Epoch, + decodeEvent, + SecsTraceFaultSampleCaptureOptions.RawPayload( + maxBodyBytes: 3))); + Assert.Throws(() => + SecsTraceFaultSampleRecord.Create( + Epoch, + decodeEvent, + SecsTraceFaultSampleCaptureOptions.RedactedPayload( + new[] { new SecsTraceByteRedactionRange(3, 2) }))); + } + + [Fact] + public void Codec_rejects_malformed_fields_and_unverifiable_redaction() + { + const string valid = + "SecsFrame-FaultSampleTrace/1\n" + + "Fault 1970-01-01T00:00:00.0000000Z DataMessageDecodeFailed Selected RedactedPayload 23 0x81 0x02 0x00 0x00 0xAABBCCDD 4 1:2 41000044\n"; + var codec = new SecsTraceFaultSampleCodec( + allowPayloadRecords: true); + + Assert.Throws(() => codec.Decode(string.Empty)); + Assert.Throws(() => codec.Decode(valid.Replace("FaultSampleTrace/1", "FaultSampleTrace/2"))); + Assert.Throws(() => codec.Decode(valid.Replace("DataMessageDecodeFailed", "CodecFailure"))); + Assert.Throws(() => codec.Decode(valid.Replace("Selected", "selected"))); + Assert.Throws(() => codec.Decode(valid.Replace("RedactedPayload", "Payload"))); + Assert.Throws(() => codec.Decode(valid.Replace("0x81", "0x8"))); + Assert.Throws(() => codec.Decode(valid.Replace("0x00 0xAABB", "0x07 0xAABB"))); + Assert.Throws(() => codec.Decode(valid.Replace("1:2", "01:2"))); + Assert.Throws(() => codec.Decode(valid.Replace("1:2 41000044", "3:1,0:1 00000000"))); + Assert.Throws(() => codec.Decode(valid.Replace("41000044", "41424344"))); + Assert.Throws(() => codec.Decode(valid.Replace("41000044", "4100044"))); + Assert.Throws(() => codec.Decode(valid.Replace("41000044", "4100004a"))); + Assert.Throws(() => codec.Decode(valid.Replace("Fault ", "Fault "))); + } + + [Fact] + public void Options_and_records_enforce_classification_boundaries() + { + var header = HsmsMessageHeader.CreateData( + 23, + 1, + 2, + true, + 0xAABBCCDD); + + Assert.Throws(() => + SecsTraceFaultSampleCaptureOptions.RedactedPayload(null!)); + Assert.Throws(() => + SecsTraceFaultSampleCaptureOptions.RedactedPayload( + Array.Empty())); + Assert.Throws(() => + SecsTraceFaultSampleCaptureOptions.RedactedPayload( + new[] + { + new SecsTraceByteRedactionRange(0, 2), + new SecsTraceByteRedactionRange(1, 2), + })); + Assert.Throws(() => + SecsTraceFaultSampleCaptureOptions.RawPayload(0)); + Assert.Throws(() => + new SecsTraceByteRedactionRange(-1, 1)); + Assert.Throws(() => + new SecsTraceByteRedactionRange(0, 0)); + Assert.Throws(() => + new SecsTraceFaultSampleRecord( + Epoch, + HsmsSessionState.Selected, + SecsTraceFaultSampleDataClassification.RedactedPayload, + header, + 2, + new byte[] { 1, 2 }, + new[] { new SecsTraceByteRedactionRange(0, 1) })); + } + + [Fact] + public void Codec_enforces_record_text_and_body_resource_limits() + { + var metadata = SecsTraceFaultSampleRecord.Create( + Epoch, + CreateDecodeEvent(new byte[4]), + SecsTraceFaultSampleCaptureOptions.MetadataOnly()); + var raw = SecsTraceFaultSampleRecord.Create( + Epoch, + CreateDecodeEvent(new byte[4]), + SecsTraceFaultSampleCaptureOptions.RawPayload()); + var records = new[] { metadata, metadata }; + var metadataText = new SecsTraceFaultSampleCodec().Encode(records); + var rawText = new SecsTraceFaultSampleCodec( + allowPayloadRecords: true).Encode(new[] { raw }); + + Assert.Throws(() => + new SecsTraceFaultSampleCodec().Encode(null!)); + Assert.Throws(() => + new SecsTraceFaultSampleCodec().Decode(null!)); + Assert.Throws(() => + new SecsTraceFaultSampleCodec().Encode( + new SecsTraceFaultSampleRecord[] { metadata, null! })); + Assert.Throws(() => + new SecsTraceFaultSampleCodec(maxRecordCount: 1).Encode(records)); + Assert.Throws(() => + new SecsTraceFaultSampleCodec(maxRecordCount: 1).Decode(metadataText)); + Assert.Throws(() => + new SecsTraceFaultSampleCodec(maxTextLength: 20) + .Encode(Array.Empty())); + Assert.Throws(() => + new SecsTraceFaultSampleCodec(maxTextLength: 20).Decode(metadataText)); + Assert.Throws(() => + new SecsTraceFaultSampleCodec( + allowPayloadRecords: true, + maxBodyBytes: 3).Encode(new[] { raw })); + Assert.Throws(() => + new SecsTraceFaultSampleCodec( + allowPayloadRecords: true, + maxBodyBytes: 3).Decode(rawText)); + } + + private static HsmsConnectionEvent CreateDecodeEvent(byte[] body) + => HsmsConnectionEvent.DataMessageDecodeFailed( + new HsmsFrame( + HsmsMessageHeader.CreateData( + 23, + 1, + 2, + true, + 0xAABBCCDD), + body), + new SecsProtocolException("Invalid SECS-II body.")); + + private static DateTimeOffset Epoch + => new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static void AssertRecordEqual( + SecsTraceFaultSampleRecord expected, + SecsTraceFaultSampleRecord actual) + { + Assert.Equal(expected.Timestamp, actual.Timestamp); + Assert.Equal(expected.Code, actual.Code); + Assert.Equal(expected.State, actual.State); + Assert.Equal(expected.DataClassification, actual.DataClassification); + Assert.Equal(expected.Header, actual.Header); + Assert.Equal(expected.OriginalBodyLength, actual.OriginalBodyLength); + Assert.Equal(expected.Body.ToArray(), actual.Body.ToArray()); + Assert.Equal(expected.RedactionRanges, actual.RedactionRanges); + } +}