diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d3e0f9b..f8753aa 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -483,14 +483,16 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs) **Body 规则类型**(`runner/http/body.ts`): - `contains`:文本包含匹配 -- `regex`:正则表达式匹配(注意:body 正则字段为 `regex`,不是 `match`) +- `regex`:正则表达式匹配(注意:body 正则字段为 `regex`,不是 `match`,启动期会拒绝嵌套量词等 ReDoS 风险模式) - `json`:JSONPath 提取 + 操作符比较(使用 `es-toolkit/isPlainObject` 区分纯值和操作符) - `css`:cheerio CSS 选择器 + 操作符比较 - `xpath`:XPath 节点提取 + 操作符比较 **文本规则**(`runner/command/text.ts`):stdout/stderr 文本匹配,支持 `contains`、`match`(正则)、操作符比较 -**操作符**(`expect/operator.ts`):`equals`(深度比较,`es-toolkit/isEqual`)、`contains`、`match`(正则)、`empty`(`isNil`+`isEmptyObject`)、`exists`、`gte`/`lte`/`gt`/`lt` +**操作符**(`expect/operator.ts`):`equals`(深度比较,`es-toolkit/isEqual`)、`contains`、`match`(正则,启动期通过 `expect/redos.ts` 拒绝 ReDoS 风险模式)、`empty`(`isNil`+`isEmptyObject`)、`exists`、`gte`/`lte`/`gt`/`lt` + +启动期语义校验会对 HTTP body `regex` 规则和所有 `match` operator 执行静态 ReDoS 检测,常见的嵌套量词模式如 `(a+)+`、`(\\d+)*x` 会被拒绝,避免运行期正则在大响应体上阻塞事件循环。 ### 1.11 错误模式 diff --git a/README.md b/README.md index 9eb3ada..89336e1 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ targets: - Command:覆盖命令执行耗时(含 stdout/stderr 读取) - `body`: HTTP 响应体校验(数组,可组合使用) - `contains`: 响应体包含的文本 - - `regex`: 响应体匹配的正则表达式 + - `regex`: 响应体匹配的正则表达式(启动期会拒绝嵌套量词等存在 ReDoS 风险的模式) - `json`: JSONPath 提取值比较 - `path`: JSONPath 表达式(必填,如 `$.slideshow.title`) - 比较操作符(可选,无操作符时仅检查路径对应值是否存在) @@ -146,7 +146,7 @@ targets: - `path`: XPath 表达式(必填,如 `/html/body/h1/text()`) - 比较操作符(可选,无操作符时仅检查节点是否存在) - `stdout` / `stderr`: Command 输出校验(数组,每项为一个操作符对象) - - 比较操作符:`equals`(默认)、`contains`、`match`(正则)、`empty`、`exists`、`gte`、`lte`、`gt`、`lt` + - 比较操作符:`equals`(默认)、`contains`、`match`(正则,启动期会拒绝存在 ReDoS 风险的模式)、`empty`、`exists`、`gte`、`lte`、`gt`、`lt` 大小说明:`maxBodyBytes` 和 `maxOutputBytes` 支持单位 `KB`、`MB`、`GB`,也可直接使用数字(非负安全整数字节数)。 diff --git a/openspec/changes/http-checker-quality-hardening/.openspec.yaml b/openspec/changes/http-checker-quality-hardening/.openspec.yaml deleted file mode 100644 index 93831bd..0000000 --- a/openspec/changes/http-checker-quality-hardening/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-13 diff --git a/openspec/changes/http-checker-quality-hardening/design.md b/openspec/changes/http-checker-quality-hardening/design.md deleted file mode 100644 index ecafdc5..0000000 --- a/openspec/changes/http-checker-quality-hardening/design.md +++ /dev/null @@ -1,93 +0,0 @@ -## Context - -HTTP checker 是 DiAL 拨测系统的核心 runner 之一,负责对 HTTP 目标执行请求并校验响应。经审查发现以下质量问题: - -1. **actual 值截断格式不符合 spec**:spec 要求 failure 中的 actual 摘要需截断并附带字符计数,但当前 `truncateActual` 函数只加省略号无计数,导致用户无法判断原始响应体规模。 -2. **ReDoS 风险**:用户配置的 regex body 规则和 match operator 直接对大响应体执行 `new RegExp().test()`,恶意或不当正则可能导致 CPU 阻塞。 -3. **JSON 重复解析**:多条 json body 规则各自独立调用 `JSON.parse(body)`,对大 JSON 响应体造成不必要的重复开销。 -4. **CSS 规则分支冗余**:`checkCssRule` 中"无 operator 时检查元素存在"和"exists: true"是重复逻辑。 -5. **重定向测试不足**:303、307/308、相对路径 Location 等分支缺少测试覆盖。 - -当前代码结构: -- `src/server/checker/expect/failure.ts` — failure 构造函数 -- `src/server/checker/runner/http/body.ts` — body 规则检查 -- `src/server/checker/runner/http/execute.ts` — HTTP 执行主流程 -- `src/server/checker/expect/operator.ts` — operator 匹配逻辑 - -## Goals / Non-Goals - -**Goals:** -- 实现 failure actual 值截断,满足 spec 要求 -- 消除 regex 相关的 ReDoS 风险 -- 优化多条 JSON 规则的解析性能 -- 精简冗余代码分支 -- 补全重定向和集成测试覆盖 - -**Non-Goals:** -- 不改变 CheckResult / CheckFailure 的类型结构(截断在构造时完成,对外接口不变) -- 不引入新依赖 -- 不改变 HTTP checker 的功能行为(纯内部质量改进) -- 不添加 response timing 分段记录(暂缓) -- 不添加重试机制(拨测场景下重试会掩盖网络问题信号) - -## Decisions - -### Decision 1: actual 截断在 mismatchFailure 构造点统一实施 - -**选择**:在 `expect/failure.ts` 的 `mismatchFailure` 函数内部对 actual 参数截断,阈值 200 字符。 - -**替代方案**: -- 在存储层(store.ts insertCheckResult)截断 — 但这样 API 实时返回的 failure 仍然很大 -- 在每个调用点手动截断 — 分散且容易遗漏 - -**理由**:构造点截断是最集中的拦截位置,所有 mismatch failure 都经过此函数,一处修改全局生效。expected 值不截断(来自用户配置,通常很短)。 - -**截断格式**:`<前 200 字符>…(共 N 字符)` — 保留前缀便于诊断,附带总长度便于判断规模(省略号为单字符 U+2026)。 - -### Decision 2: ReDoS 防护使用正则复杂度静态检测 - -**选择**:在启动期 validate 阶段对 regex body 规则和 match operator 进行静态复杂度检测,拒绝含有嵌套量词等危险模式的正则。运行期不做额外防护。 - -**替代方案**: -- 运行期用 AbortSignal + setTimeout 强制中断 — Bun 的 RegExp 执行不可中断,无法实现 -- 使用 safe-regex 库 — 引入新依赖,违反项目规范 -- 限制正则执行的输入长度 — 会影响正常大响应体的匹配 - -**理由**:自行实现轻量级检测函数,检查常见 ReDoS 模式(嵌套量词 `(a+)+`、重叠交替 `(a|a)*`)。在 validate 阶段拒绝危险正则,比运行期防护更可靠——配置错误应该在启动时暴露。 - -**检测规则**: -- 嵌套量词:量词内包含量词(如 `(a+)+`、`(a*)*`、`(a+)*`) -- 重叠字符类交替后跟量词:`(x|x)+` 模式 - -### Decision 3: JSON parse 结果缓存在 checkBodyExpect 层 - -**选择**:在 `checkBodyExpect` 函数中,首次遇到 json 规则时执行 `JSON.parse`,将结果缓存并传递给后续 json 规则复用。 - -**实现方式**:修改 `checkSingleBodyRule` 签名,接受可选的 `parsedJson` 参数;在 `checkBodyExpect` 循环中维护一个 `let parsedJson: { ok: boolean; value?: unknown; error?: string }` 状态。 - -**理由**:改动最小,不改变外部接口,只在内部传递缓存。对于非 json 规则(contains、regex、css、xpath)无影响。 - -### Decision 4: CSS 规则分支合并策略 - -**选择**:将 `checkCssRule` 重构为线性流程: -1. 解析 HTML -2. 处理 `exists: false`(元素不存在即通过) -3. 查找元素(不存在则失败) -4. 处理 `exists: true`(到这里已确认存在,直接通过) -5. 提取值(attr 或 text) -6. 无 operator 时检查值非 undefined 即通过 -7. 有 operator 时执行匹配 - -**理由**:消除当前三层嵌套判断中的重复逻辑,使控制流线性化,更易理解和维护。 - -### Decision 5: execute.ts 提前 duration 检查保留但加注释 - -**选择**:保留第 56-74 行的提前 duration 检查逻辑(它是有效的性能优化——避免读取注定超时的 body),但重构为独立的 helper 函数使意图更明确。 - -**理由**:删除它会导致超时场景下仍然读取完整 body 后才报错,浪费网络带宽和时间。提取为 `checkEarlyTimeout` 函数名即可自解释。 - -## Risks / Trade-offs - -- **ReDoS 静态检测的误报**:过于严格的检测可能拒绝合法但看起来复杂的正则。→ 缓解:只检测最常见的嵌套量词模式,不做过度分析;提供清晰的错误信息指导用户修改。 -- **actual 截断丢失诊断信息**:截断后用户无法看到完整 actual 值。→ 缓解:200 字符的前缀通常足够定位问题;如需完整响应体,用户应直接请求目标 URL 查看。 -- **JSON parse 缓存的内存占用**:对于大 JSON 响应体,缓存的 parsed 对象会在整个 body rules 检查期间驻留内存。→ 缓解:这是短暂的(单次检查周期内),且原本每条规则都会各自 parse 一份,缓存反而减少了峰值内存。 diff --git a/openspec/changes/http-checker-quality-hardening/proposal.md b/openspec/changes/http-checker-quality-hardening/proposal.md deleted file mode 100644 index fb66e9a..0000000 --- a/openspec/changes/http-checker-quality-hardening/proposal.md +++ /dev/null @@ -1,28 +0,0 @@ -## Why - -HTTP checker 经过审查发现若干质量问题:failure 中 actual 值截断格式不符合 spec 要求(缺少字符计数)导致诊断信息不完整、regex 规则缺少 ReDoS 防护存在 CPU 阻塞风险、多条 JSON body 规则重复 parse 造成不必要开销、CSS 规则分支冗余、重定向测试覆盖不足。需要统一修复以提升健壮性和代码质量。 - -## What Changes - -- 修正 `mismatchFailure` 中 actual 值截断格式,添加字符计数信息,格式为 `前 N 字符…(共 M 字符)` -- 为 regex body 规则和 match operator 添加 ReDoS 防护(执行超时或正则复杂度检测) -- 优化多条 JSON body 规则共享同一次 `JSON.parse` 结果,避免重复解析 -- 精简 `body.ts` 中 `checkCssRule` 的冗余分支逻辑 -- 精简 `execute.ts` 中提前 duration 检查的代码结构 -- 补充重定向相关测试:303 method 转换、307/308 保持 method、相对路径 Location、混合 body rules 集成测试 - -## Capabilities - -### New Capabilities - -### Modified Capabilities -- `expect-body-checkers`: 新增 actual 值截断的具体实现要求(spec 已声明但未细化截断阈值和格式) - -## Impact - -- `src/server/checker/expect/failure.ts` — 新增截断逻辑 -- `src/server/checker/runner/http/body.ts` — JSON parse 优化、CSS 分支精简 -- `src/server/checker/runner/http/execute.ts` — duration 检查精简 -- `src/server/checker/expect/operator.ts` — match operator ReDoS 防护 -- `tests/server/checker/runner/http/runner.test.ts` — 补充重定向和集成测试 -- `tests/server/checker/runner/shared/body.test.ts` — 补充截断相关测试 diff --git a/openspec/changes/http-checker-quality-hardening/specs/expect-body-checkers/spec.md b/openspec/changes/http-checker-quality-hardening/specs/expect-body-checkers/spec.md deleted file mode 100644 index f4530b7..0000000 --- a/openspec/changes/http-checker-quality-hardening/specs/expect-body-checkers/spec.md +++ /dev/null @@ -1,101 +0,0 @@ -## MODIFIED Requirements - -> 注:仅展示变更的 scenarios,其余 scenarios 保持不变 - -### Requirement: 结构化 expect 失败信息 -系统 SHALL 在任一 expect 规则失败时生成结构化 failure,用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。actual 值 SHALL 在构造时截断至不超过 200 字符,超出部分以省略标记和总字符数替代。expected 值不截断。 - -#### Scenario: body 规则失败信息 -- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败 -- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message - -#### Scenario: actual 值截断 -- **WHEN** 失败规则的实际值为字符串且长度超过 200 字符 -- **THEN** failure.actual SHALL 为前 200 字符加 `…(共 N 字符)` 后缀,其中 N 为原始总字符数 - -#### Scenario: actual 值未超限 -- **WHEN** 失败规则的实际值为字符串且长度不超过 200 字符 -- **THEN** failure.actual SHALL 保留完整原始值,不做截断 - -#### Scenario: actual 值为对象或数组 -- **WHEN** 失败规则的实际值为对象或数组,且 JSON 序列化后长度超过 200 字符 -- **THEN** failure.actual SHALL 为序列化后前 200 字符加 `…(共 N 字符)` 后缀 - -#### Scenario: actual 值为标量 -- **WHEN** 失败规则的实际值为 number、boolean、null 或 undefined -- **THEN** failure.actual SHALL 保留原始值,不做截断 - -### Requirement: HTTP expect 规则启动期校验 -系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operator;body 提取规则可以不配置 operator,并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value,包括数组和对象。系统 SHALL 在启动期对 regex body 规则和 match operator 的正则表达式进行 ReDoS 安全检测,含有嵌套量词等危险模式的正则 SHALL 导致启动期配置失败。 - -#### Scenario: body rule 使用 regex 字段 -- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险 -- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体 - -#### Scenario: body rule 不支持 match 字段 -- **WHEN** HTTP target 配置 `expect.body: [{match: "ok"}]` 且该规则没有 contains、regex、json、css、xpath 任一支持字段 -- **THEN** 系统 SHALL 在启动期配置校验失败 - -#### Scenario: body rule 未知字段启动失败 -- **WHEN** HTTP target 配置 `expect.body: [{contains: "ok", note: "ignored"}]` -- **THEN** 系统 SHALL 在启动期配置校验失败,提示 `note` 是未知字段 - -#### Scenario: body rule 多支持字段非法 -- **WHEN** HTTP target 的同一条 body rule 同时配置 contains 和 regex -- **THEN** 系统 SHALL 在启动期配置校验失败 - -#### Scenario: operator match 正则非法 -- **WHEN** HTTP target 的 expect.headers、json、css 或 xpath operator 配置了不可编译的 match 正则 -- **THEN** 系统 SHALL 在启动期配置校验失败 - -#### Scenario: operator 数值比较类型非法 -- **WHEN** HTTP target 的 expect operator 配置 gt、gte、lt 或 lte,且对应值不是有限数字 -- **THEN** 系统 SHALL 在启动期配置校验失败 - -#### Scenario: operator 布尔类型非法 -- **WHEN** HTTP target 的 expect operator 配置 empty 或 exists,且对应值不是布尔值 -- **THEN** 系统 SHALL 在启动期配置校验失败 - -#### Scenario: JSONPath 子集非法 -- **WHEN** HTTP target 的 json body rule path 不符合系统支持的 JSONPath 子集 -- **THEN** 系统 SHALL 在启动期配置校验失败 - -#### Scenario: operator 未知字段非法 -- **WHEN** HTTP target 的 expect operator 配置了 `foo: "bar"` 等未知 operator 字段 -- **THEN** 系统 SHALL 在启动期配置校验失败 - -#### Scenario: equals 支持对象 -- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.payload", equals: {status: "ok"}}}]` -- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和对象期望 - -#### Scenario: equals 支持数组 -- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.items", equals: ["a", "b"]}}]` -- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和数组期望 - -#### Scenario: 纯 operator 对象不能为空 -- **WHEN** HTTP target 的 `expect.headers` 中某个 header 期望配置为空对象 `{}` -- **THEN** 系统 SHALL 在启动期配置校验失败,要求显式配置至少一个 operator - -#### Scenario: json rule 允许存在性语义 -- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status"}}]` -- **THEN** 系统 SHALL 接受该配置,并在运行期以 JSONPath 值存在作为通过语义 - -#### Scenario: css rule 未知字段非法 -- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "h1", unknown: true}}]` -- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段 - -#### Scenario: xpath rule 未知字段非法 -- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]` -- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段 - -#### Scenario: regex body 规则含嵌套量词启动失败 -- **WHEN** HTTP target 配置 `expect.body: [{regex: "(a+)+$"}]` -- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险 - -#### Scenario: match operator 含嵌套量词启动失败 -- **WHEN** HTTP target 的 expect operator 配置 `{match: "(\\d+)*x"}` -- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险 - -#### Scenario: 安全正则通过校验 -- **WHEN** HTTP target 配置 `expect.body: [{regex: "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"}]` -- **THEN** 系统 SHALL 接受该配置(无嵌套量词,无 ReDoS 风险) diff --git a/openspec/changes/http-checker-quality-hardening/tasks.md b/openspec/changes/http-checker-quality-hardening/tasks.md deleted file mode 100644 index 9ae0125..0000000 --- a/openspec/changes/http-checker-quality-hardening/tasks.md +++ /dev/null @@ -1,38 +0,0 @@ -## 1. failure actual 截断 - -- [ ] 1.1 修改 `src/server/checker/expect/failure.ts` 中 `truncateActual` 函数,截断后缀从 `...` 改为 `…(共 N 字符)`,其中省略号为单字符 U+2026 -- [ ] 1.2 更新 `tests/server/checker/runner/shared/failure.test.ts` 中截断相关测试断言,匹配新格式(检查省略号为单字符且带字符计数) - -## 2. ReDoS 防护 - -- [ ] 2.1 在 `src/server/checker/expect/` 下新增 `redos.ts`,实现 `isUnsafeRegex(pattern: string): boolean` 函数,检测嵌套量词模式 -- [ ] 2.2 在 `src/server/checker/runner/http/validate.ts` 的 `validateRegexRule` 和 `src/server/checker/expect/validate-operator.ts` 的 match 校验中调用 `isUnsafeRegex`,不安全时返回 issue -- [ ] 2.3 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 ReDoS 正则启动校验失败的测试用例 -- [ ] 2.4 在 `tests/server/checker/runner/shared/` 下新增 `redos.test.ts`,覆盖常见 ReDoS 模式和安全正则的判定 - -## 3. JSON parse 优化 - -- [ ] 3.1 修改 `src/server/checker/runner/http/body.ts` 中 `checkBodyExpect` 函数,维护 parsedJson 缓存状态,首次 json 规则 parse 后复用结果 -- [ ] 3.2 修改 `checkJsonRule` 签名接受可选的预解析 JSON 对象,避免重复 `JSON.parse` -- [ ] 3.3 在 `tests/server/checker/runner/shared/body.test.ts` 中补充多条 json 规则共享 parse 结果的测试(验证行为正确性) - -## 4. CSS 规则精简 - -- [ ] 4.1 重构 `src/server/checker/runner/http/body.ts` 中 `checkCssRule` 为线性流程:解析 HTML → exists:false 短路 → 查找元素 → exists:true 短路 → 提取值 → operator 匹配 -- [ ] 4.2 确认 `tests/server/checker/runner/shared/body.test.ts` 中现有 CSS 测试全部通过 - -## 5. execute.ts 精简 - -- [ ] 5.1 将 `src/server/checker/runner/http/execute.ts` 第 56-74 行的提前 duration 检查提取为 `checkEarlyTimeout` 辅助函数,明确意图 - -## 6. 补充测试 - -- [ ] 6.1 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 303 重定向 method 转 GET 的测试 -- [ ] 6.2 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 307/308 保持原始 method 和 body 的测试 -- [ ] 6.3 在 `tests/server/checker/runner/http/runner.test.ts` 中补充相对路径 Location header 重定向的测试 -- [ ] 6.4 在 `tests/server/checker/runner/http/runner.test.ts` 中补充混合 body rules(contains + json + css)集成测试 - -## 7. 质量保障 - -- [ ] 7.1 执行完整测试套件 `bun test`、代码检查 `bun run lint`、格式检查 `bun run format:check` 确保无回归 -- [ ] 7.2 更新 DEVELOPMENT.md 中 ReDoS 校验相关说明(如有必要) diff --git a/openspec/specs/expect-body-checkers/spec.md b/openspec/specs/expect-body-checkers/spec.md index bc41ae8..a74f732 100644 --- a/openspec/specs/expect-body-checkers/spec.md +++ b/openspec/specs/expect-body-checkers/spec.md @@ -117,15 +117,27 @@ - **THEN** 系统 SHALL 判定 matched 为 false ### Requirement: 结构化 expect 失败信息 -系统 SHALL 在任一 expect 规则失败时生成结构化 failure,用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。 +系统 SHALL 在任一 expect 规则失败时生成结构化 failure,用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。actual 值 SHALL 在构造时截断至不超过 200 字符,超出部分以省略标记和总字符数替代。expected 值不截断。 #### Scenario: body 规则失败信息 - **WHEN** HTTP target 的 `expect.body[1].json` 规则失败 - **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message #### Scenario: actual 值截断 -- **WHEN** 失败规则的实际值超过系统允许记录的摘要长度 -- **THEN** 系统 MUST 截断 actual 摘要,而不是持久化完整响应体或命令输出 +- **WHEN** 失败规则的实际值为字符串且长度超过 200 字符 +- **THEN** failure.actual SHALL 为前 200 字符加 `…(共 N 字符)` 后缀,其中 N 为原始总字符数 + +#### Scenario: actual 值未超限 +- **WHEN** 失败规则的实际值为字符串且长度不超过 200 字符 +- **THEN** failure.actual SHALL 保留完整原始值,不做截断 + +#### Scenario: actual 值为对象或数组 +- **WHEN** 失败规则的实际值为对象或数组,且 JSON 序列化后长度超过 200 字符 +- **THEN** failure.actual SHALL 为序列化后前 200 字符加 `…(共 N 字符)` 后缀 + +#### Scenario: actual 值为标量 +- **WHEN** 失败规则的实际值为 number、boolean、null 或 undefined +- **THEN** failure.actual SHALL 保留原始值,不做截断 ### Requirement: 状态码范围匹配 系统 SHALL 支持在 `expect.status` 数组中使用范围模式字符串(`"1xx"`、`"2xx"`、`"3xx"`、`"4xx"`、`"5xx"`),与精确数字混合使用。范围模式 SHALL 匹配对应百位段内的所有状态码。其他范围模式 SHALL 在启动期配置校验失败。 @@ -163,10 +175,10 @@ - **THEN** 系统 SHALL 在启动期配置校验失败 ### Requirement: HTTP expect 规则启动期校验 -系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operator;body 提取规则可以不配置 operator,并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value,包括数组和对象。 +系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operator;body 提取规则可以不配置 operator,并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value,包括数组和对象。系统 SHALL 在启动期对 regex body 规则和 match operator 的正则表达式进行 ReDoS 安全检测,含有嵌套量词等危险模式的正则 SHALL 导致启动期配置失败。 #### Scenario: body rule 使用 regex 字段 -- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译 +- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险 - **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体 #### Scenario: body rule 不支持 match 字段 @@ -225,6 +237,18 @@ - **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]` - **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段 +#### Scenario: regex body 规则含嵌套量词启动失败 +- **WHEN** HTTP target 配置 `expect.body: [{regex: "(a+)+$"}]` +- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险 + +#### Scenario: match operator 含嵌套量词启动失败 +- **WHEN** HTTP target 的 expect operator 配置 `{match: "(\\d+)*x"}` +- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险 + +#### Scenario: 安全正则通过校验 +- **WHEN** HTTP target 配置 `expect.body: [{regex: "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"}]` +- **THEN** 系统 SHALL 接受该配置(无嵌套量词,无 ReDoS 风险) + ### Requirement: HTTP body 运行期失败结构化 系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure,并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch;响应内容无法按配置解析或解码 SHALL 记录为 error。 diff --git a/src/server/checker/expect/failure.ts b/src/server/checker/expect/failure.ts index 70aa3b0..2f93447 100644 --- a/src/server/checker/expect/failure.ts +++ b/src/server/checker/expect/failure.ts @@ -28,7 +28,9 @@ export function mismatchFailure( export function truncateActual(value: unknown, maxLen = 200): unknown { if (value === undefined || value === null) return value; - const str = typeof value === "string" ? value : JSON.stringify(value); + + const str = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : undefined; + if (str === undefined) return value; if (str.length <= maxLen) return value; - return str.slice(0, maxLen) + "..."; + return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`; } diff --git a/src/server/checker/expect/redos.ts b/src/server/checker/expect/redos.ts new file mode 100644 index 0000000..a83cd92 --- /dev/null +++ b/src/server/checker/expect/redos.ts @@ -0,0 +1,151 @@ +export function isUnsafeRegex(pattern: string): boolean { + const groups = findQuantifiedGroups(pattern); + return groups.some((group) => containsQuantifier(group) || containsOverlappingAlternation(group)); +} + +function containsOverlappingAlternation(pattern: string): boolean { + const branches = splitTopLevelAlternation(stripGroupPrefix(pattern)); + if (branches.length < 2) return false; + + for (let i = 0; i < branches.length; i++) { + const current = branches[i]!; + if (current === "") continue; + for (let j = i + 1; j < branches.length; j++) { + const next = branches[j]!; + if (next === "") continue; + if (current === next || current.startsWith(next) || next.startsWith(current)) return true; + } + } + return false; +} + +function containsQuantifier(pattern: string): boolean { + const input = stripGroupPrefix(pattern); + let inCharClass = false; + + for (let i = 0; i < input.length; i++) { + const char = input[i]!; + if (isEscaped(input, i)) continue; + if (char === "[") { + inCharClass = true; + continue; + } + if (char === "]") { + inCharClass = false; + continue; + } + if (inCharClass) continue; + if (char === "*" || char === "+" || char === "?") return true; + if (char === "{" && readQuantifierBody(input, i) !== null) return true; + } + + return false; +} + +function findQuantifiedGroups(pattern: string): string[] { + const groups: string[] = []; + const stack: number[] = []; + let inCharClass = false; + + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i]!; + if (isEscaped(pattern, i)) continue; + if (char === "[") { + inCharClass = true; + continue; + } + if (char === "]") { + inCharClass = false; + continue; + } + if (inCharClass) continue; + + if (char === "(") { + stack.push(i); + continue; + } + + if (char === ")") { + const start = stack.pop(); + if (start === undefined) continue; + if (hasRepeatingQuantifierAt(pattern, i + 1)) { + groups.push(pattern.slice(start + 1, i)); + } + } + } + + return groups; +} + +function hasRepeatingQuantifierAt(pattern: string, index: number): boolean { + const char = pattern[index]; + if (char === "*" || char === "+") return true; + if (char !== "{") return false; + + const body = readQuantifierBody(pattern, index); + if (body === null) return false; + const parts = body.split(","); + if (parts.length === 1) return Number(parts[0]) > 1; + if (parts[1] === "") return true; + return Number(parts[1]) > 1; +} + +function isEscaped(pattern: string, index: number): boolean { + let slashCount = 0; + for (let i = index - 1; i >= 0 && pattern[i] === "\\"; i--) { + slashCount++; + } + return slashCount % 2 === 1; +} + +function readQuantifierBody(pattern: string, index: number): null | string { + const end = pattern.indexOf("}", index + 1); + if (end === -1) return null; + + const body = pattern.slice(index + 1, end); + return /^\d+(?:,\d*)?$/.test(body) ? body : null; +} + +function splitTopLevelAlternation(pattern: string): string[] { + const branches: string[] = []; + let start = 0; + let depth = 0; + let inCharClass = false; + + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i]!; + if (isEscaped(pattern, i)) continue; + if (char === "[") { + inCharClass = true; + continue; + } + if (char === "]") { + inCharClass = false; + continue; + } + if (inCharClass) continue; + if (char === "(") { + depth++; + continue; + } + if (char === ")") { + depth = Math.max(0, depth - 1); + continue; + } + if (char === "|" && depth === 0) { + branches.push(pattern.slice(start, i)); + start = i + 1; + } + } + + branches.push(pattern.slice(start)); + return branches; +} + +function stripGroupPrefix(pattern: string): string { + if (pattern.startsWith("?:") || pattern.startsWith("?=") || pattern.startsWith("?!")) return pattern.slice(2); + if (pattern.startsWith("?<=") || pattern.startsWith("?]+>/.exec(pattern); + return namedCapture ? pattern.slice(namedCapture[0].length) : pattern; +} diff --git a/src/server/checker/expect/validate-operator.ts b/src/server/checker/expect/validate-operator.ts index 4ee332e..33ad8c7 100644 --- a/src/server/checker/expect/validate-operator.ts +++ b/src/server/checker/expect/validate-operator.ts @@ -3,6 +3,7 @@ import type { JsonValue } from "../types"; import { OperatorKeys } from "../schema/fragments"; import { issue, joinPath } from "../schema/issues"; +import { isUnsafeRegex } from "./redos"; const OPERATOR_KEY_SET = new Set(OperatorKeys); @@ -70,10 +71,10 @@ export function validateOperatorValue( if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)]; try { new RegExp(value); - return []; } catch { return [issue("invalid-regex", path, "正则不合法", targetName)]; } + return isUnsafeRegex(value) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : []; default: return [issue("unknown-operator", path, "是未知 operator", targetName)]; } diff --git a/src/server/checker/runner/http/body.ts b/src/server/checker/runner/http/body.ts index 5888856..f994420 100644 --- a/src/server/checker/runner/http/body.ts +++ b/src/server/checker/runner/http/body.ts @@ -8,11 +8,20 @@ import type { BodyRule, CssRule, JsonRule, XpathRule } from "./types"; import { errorFailure, mismatchFailure } from "../../expect/failure"; import { applyOperator, evaluateJsonPath } from "../../expect/operator"; +type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown }; + export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult { if (!rules || rules.length === 0) return { failure: null, matched: true }; + let parsedJson: ParsedJsonResult | undefined; + for (let i = 0; i < rules.length; i++) { - const result = checkSingleBodyRule(body, rules[i]!, i); + const rule = rules[i]!; + if ("json" in rule && parsedJson === undefined) { + parsedJson = parseJsonBody(body); + } + + const result = checkSingleBodyRule(body, rule, i, parsedJson); if (!result.matched) return result; } @@ -34,36 +43,7 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu } const el = $(selector); - const opKeys = Object.keys(operators); - if (opKeys.length === 0) { - if (attr !== undefined) { - if (el.attr(attr) === undefined) { - return { - failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`), - matched: false, - }; - } - return { failure: null, matched: true }; - } - if (el.length === 0) { - return { - failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`), - matched: false, - }; - } - return { failure: null, matched: true }; - } - - if (operators.exists === true) { - if (el.length === 0) { - return { - failure: mismatchFailure("body", fullPath, true, false, `selector ${selector} not found`), - matched: false, - }; - } - return { failure: null, matched: true }; - } if (operators.exists === false) { if (el.length > 0) { return { @@ -75,13 +55,28 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu } if (el.length === 0) { + const expected = operators.exists === true ? true : "element found"; + const actual = operators.exists === true ? false : "no match"; return { - failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`), + failure: mismatchFailure("body", fullPath, expected, actual, `selector ${selector} not found`), matched: false, }; } + if (operators.exists === true) return { failure: null, matched: true }; + const actual = attr ? el.attr(attr) : el.text(); + const opKeys = Object.keys(operators); + if (opKeys.length === 0) { + if (actual === undefined) { + return { + failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`), + matched: false, + }; + } + return { failure: null, matched: true }; + } + const matched = applyOperator(actual ?? "", operators); if (!matched) { return { @@ -92,21 +87,19 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu return { failure: null, matched: true }; } -function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectResult { +function checkJsonRule(body: string, rule: JsonRule, rulePath: string, parsedJson?: ParsedJsonResult): ExpectResult { const { path, ...operators } = rule; const fullPath = `${rulePath}.json(${path})`; - let json: unknown; - try { - json = JSON.parse(body); - } catch { + const jsonResult = parsedJson ?? parseJsonBody(body); + if (!jsonResult.ok) { return { - failure: errorFailure("body", fullPath, "body is not valid JSON"), + failure: errorFailure("body", fullPath, jsonResult.error), matched: false, }; } - const actual = evaluateJsonPath(json, path); + const actual = evaluateJsonPath(jsonResult.value, path); const opKeys = Object.keys(operators); if (opKeys.length === 0) { @@ -129,7 +122,7 @@ function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectRe return { failure: null, matched: true }; } -function checkSingleBodyRule(body: string, rule: BodyRule, index: number): ExpectResult { +function checkSingleBodyRule(body: string, rule: BodyRule, index: number, parsedJson?: ParsedJsonResult): ExpectResult { const rulePath = `body[${index}]`; if ("contains" in rule) { @@ -155,7 +148,7 @@ function checkSingleBodyRule(body: string, rule: BodyRule, index: number): Expec } if ("json" in rule) { - return checkJsonRule(body, rule.json, rulePath); + return checkJsonRule(body, rule.json, rulePath, parsedJson); } if ("css" in rule) { @@ -208,3 +201,11 @@ function checkXpathRule(body: string, rule: XpathRule, rulePath: string): Expect } return { failure: null, matched: true }; } + +function parseJsonBody(body: string): ParsedJsonResult { + try { + return { ok: true, value: JSON.parse(body) as unknown }; + } catch { + return { error: "body is not valid JSON", ok: false }; + } +} diff --git a/src/server/checker/runner/http/execute.ts b/src/server/checker/runner/http/execute.ts index c94e0de..7af183a 100644 --- a/src/server/checker/runner/http/execute.ts +++ b/src/server/checker/runner/http/execute.ts @@ -53,24 +53,9 @@ export class HttpChecker implements CheckerDefinition { const hasBodyRules = !!(expect?.body && expect.body.length > 0); - if (hasBodyRules && expect?.maxDurationMs !== undefined) { - const elapsed = performance.now() - start; - if (elapsed > expect.maxDurationMs) { - const durationMs = Math.round(elapsed); - return makeResult( - t, - timestamp, - elapsed, - mismatchFailure( - "duration", - "duration", - `<=${expect.maxDurationMs}ms`, - durationMs, - `duration ${durationMs}ms > ${expect.maxDurationMs}ms`, - ), - statusCode, - ); - } + const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.maxDurationMs) : null; + if (earlyTimeout) { + return makeResult(t, timestamp, earlyTimeout.elapsed, earlyTimeout.failure, statusCode); } if (hasBodyRules) { @@ -203,6 +188,28 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin return newInit; } +function checkEarlyTimeout( + start: number, + maxDurationMs: number | undefined, +): null | { elapsed: number; failure: CheckResult["failure"] } { + if (maxDurationMs === undefined) return null; + + const elapsed = performance.now() - start; + if (elapsed <= maxDurationMs) return null; + + const durationMs = Math.round(elapsed); + return { + elapsed, + failure: mismatchFailure( + "duration", + "duration", + `<=${maxDurationMs}ms`, + durationMs, + `duration ${durationMs}ms > ${maxDurationMs}ms`, + ), + }; +} + function decodeBody( data: Uint8Array, headers: Headers, diff --git a/src/server/checker/runner/http/validate.ts b/src/server/checker/runner/http/validate.ts index dac9662..051dcb5 100644 --- a/src/server/checker/runner/http/validate.ts +++ b/src/server/checker/runner/http/validate.ts @@ -4,6 +4,7 @@ import * as xpath from "xpath"; import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; +import { isUnsafeRegex } from "../../expect/redos"; import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator"; import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments"; import { issue, joinPath } from "../../schema/issues"; @@ -188,10 +189,10 @@ function validateRegexRule(rule: unknown, path: string, targetName?: string): Co if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)]; try { new RegExp(rule); - return []; } catch { return [issue("invalid-regex", path, "正则不合法", targetName)]; } + return isUnsafeRegex(rule) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : []; } function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] { diff --git a/tests/server/checker/runner/http/runner.test.ts b/tests/server/checker/runner/http/runner.test.ts index ab5aaf4..0b0445a 100644 --- a/tests/server/checker/runner/http/runner.test.ts +++ b/tests/server/checker/runner/http/runner.test.ts @@ -67,13 +67,17 @@ describe("HttpChecker", () => { beforeAll(() => { server = Bun.serve({ - fetch(req) { + async fetch(req) { const url = new URL(req.url); switch (url.pathname) { case "/echo": return new Response(JSON.stringify({ body: req.body ? "present" : "empty", method: req.method }), { headers: { "content-type": "application/json" }, }); + case "/echo-actual": + return new Response(JSON.stringify({ body: await req.text(), method: req.method }), { + headers: { "content-type": "application/json" }, + }); case "/gbk": { const gbkBytes = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]); return new Response(gbkBytes, { headers: { "content-type": "text/plain; charset=gbk" } }); @@ -88,6 +92,10 @@ describe("HttpChecker", () => { }); case "/large": return new Response("x".repeat(2000)); + case "/mixed": + return new Response(JSON.stringify({ html: "OK", status: "ok" }), { + headers: { "content-type": "application/json" }, + }); case "/notfound": return new Response("not found", { status: 404 }); case "/ok": @@ -96,6 +104,12 @@ describe("HttpChecker", () => { }); case "/redirect": return new Response(null, { headers: { location: `${baseUrl}/ok` }, status: 301 }); + case "/redirect-303": + return new Response(null, { headers: { location: `${baseUrl}/echo-actual` }, status: 303 }); + case "/redirect-307": + return new Response(null, { headers: { location: `${baseUrl}/echo-actual` }, status: 307 }); + case "/redirect-308": + return new Response(null, { headers: { location: `${baseUrl}/echo-actual` }, status: 308 }); case "/redirect-chain-1": return new Response(null, { headers: { location: `${baseUrl}/redirect-chain-2` }, status: 302 }); case "/redirect-chain-2": @@ -109,6 +123,8 @@ describe("HttpChecker", () => { } case "/redirect-post": return new Response(null, { headers: { location: `${baseUrl}/echo` }, status: 301 }); + case "/redirect-relative": + return new Response(null, { headers: { location: "/ok" }, status: 302 }); case "/slow-body": return new Response("x".repeat(2000)); case "/unknown-charset": { @@ -529,6 +545,74 @@ describe("HttpChecker", () => { expect(result.matched).toBe(true); }); + test("303 重定向将 method 转为 GET 且清空 body", async () => { + const result = await checker.execute( + makeTarget({ + body: "payload", + expect: { + body: [{ json: { equals: "GET", path: "$.method" } }, { json: { equals: "", path: "$.body" } }], + status: [200], + }, + headers: { "content-type": "text/plain" }, + maxRedirects: 1, + method: "POST", + url: `${baseUrl}/redirect-303`, + }), + makeCtx(), + ); + expect(result.matched).toBe(true); + }); + + test("307/308 重定向保持原始 method 和 body", async () => { + for (const statusCode of [307, 308]) { + const result = await checker.execute( + makeTarget({ + body: `payload-${statusCode}`, + expect: { + body: [ + { json: { equals: "POST", path: "$.method" } }, + { json: { equals: `payload-${statusCode}`, path: "$.body" } }, + ], + status: [200], + }, + headers: { "content-type": "text/plain" }, + maxRedirects: 1, + method: "POST", + url: `${baseUrl}/redirect-${statusCode}`, + }), + makeCtx(), + ); + expect(result.matched).toBe(true); + } + }); + + test("相对路径 Location header 重定向", async () => { + const result = await checker.execute( + makeTarget({ maxRedirects: 1, url: `${baseUrl}/redirect-relative` }), + makeCtx(), + ); + expect(result.matched).toBe(true); + expect(result.statusDetail).toBe("HTTP 200"); + }); + + test("混合 body rules 集成检查", async () => { + const result = await checker.execute( + makeTarget({ + expect: { + body: [ + { contains: '"status":"ok"' }, + { json: { equals: "ok", path: "$.status" } }, + { css: { equals: "OK", selector: "span" } }, + ], + status: [200], + }, + url: `${baseUrl}/mixed`, + }), + makeCtx(), + ); + expect(result.matched).toBe(true); + }); + test("跨 origin 重定向剥离敏感 headers", async () => { const targetServer = Bun.serve({ fetch(req) { @@ -616,6 +700,16 @@ describe("HttpChecker", () => { expect(errors).toContain("regex 正则不合法"); }); + test("ReDoS regex body rule 启动校验失败", () => { + const errors = validateHttpTarget({ + expect: { body: [{ regex: "(a+)+$" }] }, + http: { url: "https://example.com" }, + name: "test", + type: "http", + }); + expect(errors).toContain("正则存在 ReDoS 风险"); + }); + test("非法 JSONPath 启动校验失败", () => { const errors = validateHttpTarget({ expect: { body: [{ json: { equals: "ok", path: "status" } }] }, @@ -636,6 +730,16 @@ describe("HttpChecker", () => { expect(errors).toContain("match 正则不合法"); }); + test("ReDoS operator match 启动校验失败", () => { + const errors = validateHttpTarget({ + expect: { headers: { "x-test": { match: "(\\d+)*x" } } }, + http: { url: "https://example.com" }, + name: "test", + type: "http", + }); + expect(errors).toContain("正则存在 ReDoS 风险"); + }); + test("非法 operator gte 类型启动失败", () => { const errors = validateHttpTarget({ expect: { body: [{ json: { gte: "abc", path: "$.count" } }] }, diff --git a/tests/server/checker/runner/shared/body.test.ts b/tests/server/checker/runner/shared/body.test.ts index 34ae72b..a3a6c23 100644 --- a/tests/server/checker/runner/shared/body.test.ts +++ b/tests/server/checker/runner/shared/body.test.ts @@ -130,6 +130,28 @@ describe("checkBodyExpect (BodyRule[])", () => { expect(r.failure).toBeNull(); }); + test("多条 json 规则共享解析结果且全部通过", () => { + const body = JSON.stringify({ count: 5, status: "healthy" }); + const originalParse = JSON.parse; + let parseCount = 0; + JSON.parse = ((text, reviver) => { + parseCount++; + return originalParse(text, reviver) as unknown; + }) as typeof JSON.parse; + + try { + const r = checkBodyExpect(body, [ + { json: { equals: "healthy", path: "$.status" } }, + { json: { gte: 1, path: "$.count" } }, + ]); + expect(r.matched).toBe(true); + expect(r.failure).toBeNull(); + expect(parseCount).toBe(1); + } finally { + JSON.parse = originalParse; + } + }); + test("第二条规则失败返回正确索引", () => { const body = JSON.stringify({ status: "ok" }); const r = checkBodyExpect(body, [{ contains: "ok" }, { json: { equals: "error", path: "$.status" } }]); diff --git a/tests/server/checker/runner/shared/failure.test.ts b/tests/server/checker/runner/shared/failure.test.ts index aedaade..0c46edd 100644 --- a/tests/server/checker/runner/shared/failure.test.ts +++ b/tests/server/checker/runner/shared/failure.test.ts @@ -12,18 +12,18 @@ describe("truncateActual", () => { expect(truncateActual(str)).toBe(str); }); - test("超过限制长度截断并加省略号", () => { + test("超过限制长度截断并加省略号与字符计数", () => { const str = "a".repeat(300); const result = truncateActual(str) as string; - expect(result.length).toBe(203); - expect(result.endsWith("...")).toBe(true); + expect(result).toBe(`${"a".repeat(200)}…(共 300 字符)`); + expect(result.includes("...")).toBe(false); expect(result.startsWith("a".repeat(200))).toBe(true); }); test("自定义最大长度", () => { const str = "abcdefghij"; const result = truncateActual(str, 5) as string; - expect(result).toBe("abcde..."); + expect(result).toBe("abcde…(共 10 字符)"); }); test("null 不截断", () => { @@ -34,9 +34,16 @@ describe("truncateActual", () => { expect(truncateActual(undefined)).toBe(undefined); }); - test("数字转换为字符串后判断", () => { + test("标量不截断", () => { expect(truncateActual(42)).toBe(42); - expect(truncateActual(123456789, 3) as string).toBe("123..."); + expect(truncateActual(123456789, 3)).toBe(123456789); + expect(truncateActual(true, 3)).toBe(true); + }); + + test("对象序列化后超限时截断", () => { + const result = truncateActual({ value: "x".repeat(20) }, 10) as string; + expect(result.startsWith('{"value":"')).toBe(true); + expect(result.endsWith("…(共 32 字符)")).toBe(true); }); }); @@ -56,8 +63,7 @@ describe("mismatchFailure", () => { test("自动截断过长的 actual", () => { const longStr = "x".repeat(300); const f = mismatchFailure("body", "body[0]", "short", longStr, "too long"); - expect((f.actual as string).endsWith("...")).toBe(true); - expect((f.actual as string).length).toBe(203); + expect(f.actual).toBe(`${"x".repeat(200)}…(共 300 字符)`); }); }); diff --git a/tests/server/checker/runner/shared/redos.test.ts b/tests/server/checker/runner/shared/redos.test.ts new file mode 100644 index 0000000..84260b1 --- /dev/null +++ b/tests/server/checker/runner/shared/redos.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; + +import { isUnsafeRegex } from "../../../../../src/server/checker/expect/redos"; + +describe("isUnsafeRegex", () => { + test("识别嵌套量词", () => { + expect(isUnsafeRegex("(a+)+$")).toBe(true); + expect(isUnsafeRegex("(a*)*")).toBe(true); + expect(isUnsafeRegex("(a?)+")).toBe(true); + expect(isUnsafeRegex("(\\d+)*x")).toBe(true); + expect(isUnsafeRegex("(?:a+)+")).toBe(true); + }); + + test("识别重叠交替分支", () => { + expect(isUnsafeRegex("(a|a)+")).toBe(true); + expect(isUnsafeRegex("(a|aa)*")).toBe(true); + }); + + test("安全正则不误判", () => { + expect(isUnsafeRegex("\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}")).toBe(false); + expect(isUnsafeRegex("^(ok|healthy)$")).toBe(false); + expect(isUnsafeRegex("^[a-z0-9_-]+$")).toBe(false); + expect(isUnsafeRegex("([a+])+")).toBe(false); + }); +});