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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@

### 新增

- 增加独立 <code>SecsFrame-FaultSampleTrace/1</code> 信封,为公共
<code>DataMessageDecodeFailed</code> 事件提供已成帧 HSMS data body 样本。
- 捕获必须显式选择 MetadataOnly、RedactedPayload 或 RawPayload;默认 codec
拒绝 payload 导入导出,Redacted 范围固定清零并在严格读取时重新验证。
- 增加 64 KiB 默认 Body 上限、无截断失败、黄金向量、敏感字节移除、不可变
复制、畸形范围/十六进制和资源限制测试;该信封不进入重放路径。
- 增加默认关闭的完整 HSMS 控制消息元数据观测流,覆盖内部 Select、
Linktest、Deselect、Reject 与 Separate 的收发,而不改变现有业务事件流。
- 观测只保留方向、actor 观察状态与原十字节头;入站在协议处理前发布,
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 设备接口数据字典 |

Expand All @@ -45,6 +45,8 @@ Equipment 应用发送策略的 Collection Event、报警目录查询、单报
严格的 Item 路径脱敏、显式 allowlist 重放,以及默认关闭的缩放/封顶时序
重放;独立诊断信封只保存稳定标量,不保存异常或原始帧;控制信封可保存
显式启用的完整控制消息观测流或既有未认领控制事件的十字节头元数据。
解码失败样本另用独立信封,并以元数据为默认导出边界;Body 捕获与 codec
导入导出都必须显式开启,声明的脱敏字节范围会固定清零并严格复核。
控制观测默认关闭,并与业务事件流独立;该包不会自行接管任何连接消费者,
也不会复用原事务标识。
Item 用法与边界见
Expand Down
11 changes: 8 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,19 @@ StreamFrame TCP、Pipe、帧边界、连接生命周期
E172 SEDD 与 E173 SMN 是可选元数据/表示层,不进入核心通讯路径。
<code>SecsFrame.Sml</code> 同样是只依赖核心动态消息模型的可选调试表示层;
核心包不反向依赖它。
<code>SecsFrame.Trace</code> 依赖 SML 表示层和核心公共发送 API,只处理已经
解码的数据消息;它不订阅内部 transport 或创建第二个公共事件消费者。
<code>SecsFrame.Trace</code> 依赖 SML 表示层和核心公共 API,处理已经解码
的数据消息,以及调用方显式提供的公共解码失败事件;它不订阅内部 transport
或创建第二个公共事件消费者。
Trace 的时序重放必须显式启用,只在允许发送记录之间等待,并继续通过公共
发送 API 创建新事务;默认重放不引入时间等待。
结构化诊断使用独立只读快照信封:只复制公共诊断的稳定标量,明确排除原
异常和未解码帧,也不能进入重放路径。
控制消息使用另一个只读元数据信封。核心连接可显式启用独立控制观测流,
从会话 actor 复制全部收发控制消息的十字节头字段;Trace 仍由调用方消费
该流并创建记录,不自行订阅连接。既有公共未认领控制事件工厂继续可用。
已成帧但 SECS-II Body 解码失败的样本使用独立信封:捕获和 codec 分别要求
显式数据边界,默认只允许头与原 Body 长度;payload-bearing 记录不能进入
重放。T8 和成帧失败片段尚未进入该模型。

## 核心原则

Expand Down Expand Up @@ -179,6 +183,7 @@ transport Session generation、StreamFrame
公共发送 API,不得直接构造或写出保留旧事务标识的线上帧;可选时序只
控制相邻发送前的等待,不得绕过连接状态、T3 或显式 allowlist。诊断
导出不得隐式包含 <code>Exception</code> 或 <code>HsmsFrame</code>;控制
元数据导出不得包含 Body 或隐式订阅任何连接流。
元数据导出不得包含 Body 或隐式订阅任何连接流。故障样本只能从调用方
已取得的解码失败事件显式创建,默认不得导入或导出 Body,也不得进入重放。
- 核心包不得依赖 GEM、SML/SMN、依赖注入容器或具体日志实现。
- 状态机计时使用可替换的时间抽象,测试不得依赖真实长时间等待。
4 changes: 4 additions & 0 deletions docs/HSMS-CONNECTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ var secondary = await connection.SendAsync(
- <code>ControlMessageReceived</code>:未被当前控制或数据事务认领的帧;
- <code>DataMessageDecodeFailed</code>:原始帧和严格解码错误。

<code>DataMessageDecodeFailed</code> 的帧可能包含设备或工艺数据。默认应只
使用结构化诊断;需要保留已成帧互操作样本时,可使用 Trace 包的显式分级
故障样本信封,见 [TRACE.md](TRACE.md)。它不会自动订阅本事件流。

调用方在自己的事件循环中可以使用
<code>SecsTraceControlRecord.CreateReceived</code> 对
<code>ControlMessageReceived</code> 创建不含 Body 的头元数据快照。该事件
Expand Down
7 changes: 7 additions & 0 deletions docs/HSMS-DIAGNOSTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ Session ID 与 System Bytes;拒绝诊断保留远端状态或原因字节。
System Bytes 和远端状态字节仍属于运维元数据,导出文件仍应使用适当的
访问控制与保留策略。具体格式和示例见 [TRACE.md](TRACE.md)。

需要保留互操作样本时,<code>SecsFrame.Trace</code> 另提供
<code>SecsFrame-FaultSampleTrace/1</code>。它只接受公共
<code>DataMessageDecodeFailed</code> 事件,并要求调用方显式选择仅元数据、
声明范围清零后的 Body 或原始 Body。默认 codec 只允许元数据记录;任何
带 Body 的导入或导出都要再次显式开启。该信封不包含异常、TCP 长度前缀、
分片、transport Session generation 或 T8 未完成片段。

## 标准边界

诊断名称描述 SecsFrame 已实现并测试的工程行为,不新增 SEMI 默认值,也
Expand Down
9 changes: 7 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,19 @@ E30-0526 材料。下一可执行优先级已转入阶段 3;SML 调试读写
- [x] 不包含异常与原始帧的受限结构化诊断 Trace 导出和严格读取。
- [x] 公共未认领控制消息的受限头元数据 Trace 导出和严格读取。
- [x] 默认关闭、不含 Body/原始字节的完整控制面头元数据观测。
- 故障原始样本等互操作诊断扩展。
- [x] 显式分级、默认不导出 Body 的数据消息解码失败样本。
- T8/成帧失败原始片段等运输层互操作诊断扩展。

当前 Trace 基线不接管连接事件流,也不把原始标识写回线上。时序重放默认
关闭,并继续经过显式 allowlist 与公共发送 API。诊断快照始终排除
<code>Error</code> 与 <code>Frame</code>。完整控制元数据观测必须在连接
选项中显式启用,覆盖内部握手但只复制方向、actor 状态和十字节头,不含
Body、原始线上字节、transport generation 或时间戳。后续故障原始样本
扩展必须先定义原始字节的数据分级、默认不导出策略和可验证脱敏边界。
已先覆盖公共事件中忠实保留的完整 data frame:捕获分为 MetadataOnly、
RedactedPayload 与 RawPayload,payload codec 默认关闭,声明脱敏范围必须
全为零且可严格复核。它不包含 TCP 长度前缀或分片,也不能进入重放。
T8/成帧失败片段仍需先把 StreamFrame 原始错误数据与 transport session
安全关联,并保持默认不捕获、容量限制和跨会话隔离。
E173 SMN 与 E172 SEDD 仍需先核对授权标准及允许分发的 Schema/表示形式。

## 发布门槛
Expand Down
57 changes: 56 additions & 1 deletion docs/TRACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- 不包含异常和原始帧的结构化 HSMS 诊断快照导出与严格读取;
- 显式启用的完整控制观测或公共未认领控制事件的十字节头元数据导出与
严格读取。
- 已成帧但 SECS-II Body 解码失败的显式分级故障样本。

当前切片不是原始网络抓包器,不记录 TCP 字节、transport Session generation、
控制帧的线上原始字节或异常对象,也不会接管 <code>HsmsConnection</code> 的
Expand Down Expand Up @@ -131,6 +132,58 @@ PCAP 级线上时序;需要线速证据时不能从本元数据流推断字节
数据事务认领的控制帧,主要用于未匹配 Reject。两条创建路径生成同一信封;
控制信封不能进入消息重放器,头字段仍应按运维元数据执行访问控制与保留策略。

## 解码失败样本

<code>SecsFrame-FaultSampleTrace/1</code> 只接受应用从现有事件循环取得的
<code>DataMessageDecodeFailed</code>。捕获时必须显式选择三种数据分类之一:

- <code>MetadataOnly</code>:只保存十字节头字段和原 Body 长度;
- <code>RedactedPayload</code>:复制 Body,并把声明的非重叠字节范围清零;
- <code>RawPayload</code>:显式复制未脱敏 Body。

默认 codec 只允许 MetadataOnly。即使已经显式捕获 payload,导入或导出仍
必须再次设置 <code>allowPayloadRecords: true</code>:

~~~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 长度、逗号分隔的 <code>offset:length</code> 脱敏范围,以及大写
十六进制 Body。范围相对 SECS-II Body,不包含 HSMS 四字节长度前缀或十字节
头。Redacted 记录必须至少声明一个范围,范围必须有序、非重叠、不越界,
且严格读取时会验证范围内每个字节都为零。

payload 捕获默认限制为 64 KiB;超限立即失败,不截断样本。调用方可显式
收紧或扩大限制,codec 另行执行自己的 Body、记录数和文本长度限制。记录
防御性复制 Body,后续修改事件原数组不会改变样本。

该信封只重建已经完成 HSMS 成帧和头解码的 payload,不是 TCP/PCAP 抓包,
不包含异常、长度前缀、分片时序、transport Session generation 或 T8
未完成片段,也没有重放 API。清零范围可验证,但哪些范围包含敏感数据仍是
调用方的数据分类责任;未经独立审查不得把 Redacted 等同于已安全公开。

## 结构化脱敏

脱敏发生在不可变 Item 树上,而不是对 SML 文本做字符串替换:
Expand Down Expand Up @@ -223,4 +276,6 @@ var results = await new SecsTraceReplayer().ReplayWithTimingAsync(
当前工具组合边界,不代表设备命令安全性或 SEMI 一致性认证。诊断信封另有
黄金向量、可选字段往返、严格枚举/十六进制、资源限制及异常/帧内容不泄漏
测试。控制信封另有完整 Select/Linktest 双向观测、未匹配 Reject 黄金向量、
未知 SType 往返、Data Message 拒绝、严格字段与资源限制测试。
未知 SType 往返、Data Message 拒绝、严格字段与资源限制测试。故障样本
信封另有三种分类、payload 默认拒绝、范围清零复核、防御性复制、畸形字段
和 Body/记录/文本资源限制测试。
28 changes: 28 additions & 0 deletions src/SecsFrame.Trace/SecsTraceByteRedactionRange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Runtime.InteropServices;

namespace SecsFrame.Trace;

/// <summary>Identifies a zero-based range within an HSMS data-message body.</summary>
[StructLayout(LayoutKind.Auto)]
public readonly record struct SecsTraceByteRedactionRange
{
/// <summary>Creates a nonempty byte range.</summary>
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;
}

/// <summary>Gets the zero-based body offset.</summary>
public int Offset { get; }

/// <summary>Gets the number of bytes in the range.</summary>
public int Length { get; }
}
95 changes: 95 additions & 0 deletions src/SecsFrame.Trace/SecsTraceFaultSampleCaptureOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System.Collections.ObjectModel;

namespace SecsFrame.Trace;

/// <summary>
/// Defines the explicit data boundary for one decode-fault sample capture.
/// </summary>
public sealed class SecsTraceFaultSampleCaptureOptions
{
/// <summary>The default maximum body copied into one sample: 64 KiB.</summary>
public const int DefaultMaxBodyBytes = 64 * 1024;

private SecsTraceFaultSampleCaptureOptions(
SecsTraceFaultSampleDataClassification dataClassification,
IEnumerable<SecsTraceByteRedactionRange>? 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;
}

/// <summary>Gets how much payload data can be copied.</summary>
public SecsTraceFaultSampleDataClassification DataClassification { get; }

/// <summary>Gets the ordered, nonoverlapping ranges zeroed before capture.</summary>
public IReadOnlyList<SecsTraceByteRedactionRange> RedactionRanges { get; }

/// <summary>Gets the maximum body size copied by payload-bearing modes.</summary>
public int MaxBodyBytes { get; }

/// <summary>Creates an option that captures no body bytes.</summary>
public static SecsTraceFaultSampleCaptureOptions MetadataOnly()
=> new(
SecsTraceFaultSampleDataClassification.MetadataOnly,
null,
DefaultMaxBodyBytes);

/// <summary>Creates an option that copies and zeroes declared body ranges.</summary>
public static SecsTraceFaultSampleCaptureOptions RedactedPayload(
IEnumerable<SecsTraceByteRedactionRange> redactionRanges,
int maxBodyBytes = DefaultMaxBodyBytes)
{
if (redactionRanges is null)
throw new ArgumentNullException(nameof(redactionRanges));

return new(
SecsTraceFaultSampleDataClassification.RedactedPayload,
redactionRanges,
maxBodyBytes);
}

/// <summary>Creates an option that explicitly copies the unredacted body.</summary>
public static SecsTraceFaultSampleCaptureOptions RawPayload(
int maxBodyBytes = DefaultMaxBodyBytes)
=> new(
SecsTraceFaultSampleDataClassification.RawPayload,
null,
maxBodyBytes);

private static IReadOnlyList<SecsTraceByteRedactionRange> PrepareRanges(
IEnumerable<SecsTraceByteRedactionRange>? ranges)
{
if (ranges is null)
return Array.Empty<SecsTraceByteRedactionRange>();

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<SecsTraceByteRedactionRange>(prepared);
}
}
Loading
Loading