fix(highlight): ReDoS 检测降级为编辑器提示(实测漏判,不可作拒绝闸门)

上一提交把 isReDoSProne 当作 compileRules 的拒绝闸门,声称"9/9 危险全抓"。
独立复查证伪了这个结论:

## 实测漏判 —— 危险规则仍会进热路径

| pattern | 静态检测 | 实测 |
|---|---|---|
| [(](a+)+$      | 放行 | 30 字符 4065ms |
| (?:a\|a)+$     | 放行 | 26 字符 7547ms |
| ((a\|a))+$     | 放行 | 24 字符 16212ms |
| (\w\|\d)+$     | 放行 | 26 字符 14451ms |

根因三条:外层扫描不跟踪字符类状态([(] 里的 ( 被当组起始)、遇到不闭合
组时 return false 让整个函数交白卷、分支比较只做字面相等抓不到语义重叠。

## 实测误判 —— 合法规则被拒

(?:a{2,3})+ 等把 {n,m} 范围重复当成 {n} 固定重复。

尝试修复后每修一处引入新问题(补字符类状态后又误杀 (?:\d{2})+ 与 ipv4
预设),证明手写正则语法扫描有本质缺陷。

## 三个替代方案均否决(有实测依据)

1. 运行时熔断(循环内查时钟):**原理上无效**。单次 exec 阻塞 28426ms,
   时钟检查根本执行不到
2. re2:不支持 lookbehind,perm/okstate/http 三条预设依赖它
3. Worker + 超时:**可靠**(6/6 危险全抓、806ms 内 tick 51 次主线程未阻塞)
   但往返 0.11ms/块 vs 直接 0.035ms,且终端高亮必须同步(WriteBatcher 消费
   同步染色结果)——只适合规则的异步校验,不适合热路径

## 本次改动

- compileRules 不再因 isReDoSProne 跳过规则:拒绝闸门会静默禁用合法规则
  (已证实会误杀)却仍放进危险规则(已证实会漏判),两头都不对
- isReDoSProne 保留给编辑器提示:漏判不比改动前差,误判只是多一个警告而非
  禁用功能
- 注释与 AGENTS.md 如实标注"只有提示没有根治",并记录三个方案的否决理由,
  避免后来者重复踩坑

热路径的实际边界仍是 MAX_LINE_LEN(>4KB 行不进用户正则)+ MAX_PER_RULE。
真正的解法(Worker 异步校验 + 存盘前把关)列为未完成项。
This commit is contained in:
Bill committed 2026-10-17 23:19:19 +08:00
1 parent c6aca15134
commit 68fa46b2e3
4 files changed
+66 -29

No files matched your search

+7 -4
View File
@@ -1238,12 +1238,15 @@ console.log('[ReDoS shapes]')
const flaggedPresets = DEFAULT_HIGHLIGHT_RULES.filter((r) => isReDoSProne(r.pattern)).map((r) => r.id)
ok(flaggedPresets.length === 0, `no shipped preset is rejected (flagged: ${flaggedPresets.join(', ') || 'none'})`)
// A rejected rule must not reach the compiled set at all.
// The heuristic is ADVISORY: it must not gate the hot path. Rejecting on it
// would silently disable legitimate rules (measured false positives) while
// still admitting hostile ones (measured misses: `[(](a+)+`, `(?:a|a)+`), so
// compileRules keeps both and only the editor surfaces the warning.
const compiled = compileRules([
{ id: 'bad', pattern: '(a+)+$', priority: 1, color: { fg: '#ff0000' } },
{ id: 'good', pattern: 'ERROR', priority: 2, color: { fg: '#00ff00' } }
{ id: 'flagged', pattern: '(a+)+$', priority: 1, color: { fg: '#ff0000' } },
{ id: 'plain', pattern: 'ERROR', priority: 2, color: { fg: '#00ff00' } }
])
ok(compiled.length === 1 && compiled[0].id === 'good', 'compileRules drops the unsafe rule and keeps the safe one')
ok(compiled.length === 2, 'compileRules keeps flagged rules (the check is advisory, not a gate)')
}
console.log(failed === 0 ? '\n[hl-rules] ALL CHECKS PASSED' : `\n[hl-rules] ${failed} CHECK(S) FAILED`)