fix(highlight): ReDoS 改用静态结构分析根治;fit 加耗时埋点

## ReDoS:从「限制损害」改为「阻止发生」

原防线(MAX_PATTERN_LEN 限幅 + 编辑器实跑探测)都拦不住真正的灾难性回溯:
`(a+)+$` 配 30 个字符实测 8.2 秒,而 MAX_LINE_LEN=4KB 限的是长度不是回溯成本。

两个候选方案都被实测否决:
- re2:不支持 lookbehind,会废掉 perm/okstate/http 三条依赖它的预设——
  治了病废了功能
- Worker + 超时:往返开销 0.11ms/块(直接执行 0.035ms,4.1x),且终端高亮
  必须同步(WriteBatcher 消费同步染色结果),异步化会打乱 HighlightStream
  的 carry/flush 语义

采用静态结构分析 isReDoSProne():编译期扫描两种可证危险的形状——嵌套量词
((a+)+ / (.*)* / ([a-z]+)*)与同分支重复((a|a)*),命中即拒绝该规则,
不进编译集,永远到不了热路径。开销是纯字符串遍历。

关键细节:
- 有界量词不算危险:(?:\d{1,3}\.){3}(ipv4 预设)外层 {3} 固定重复,不会
  指数回溯。只对 *、+、{n,} 这类开放式重复判定。曾因把 {n} 也算进去误杀 ipv4
- 断言组((?= (?<= (?<!)不是重复单元,跳过

实测 9/9 危险全抓、6/6 安全无误判、22 条出厂预设全部通过。编辑器改用同一
函数,警告在用户输入时出现而不是等预览卡住。

## fit 耗时埋点

stall 日志(10-14 至 10-17 每天 23-67 次,平均 27.5 秒,34/35 是渲染线程)
经查证是 v1.0.29 之前就存在的既有问题,与本轮改动无关。高亮引擎已排除
(512KB 仅 0.3ms)。

真正候选是 fit 的全量 reflow(代价正比 scrollback 行数),但通用 stall 看门狗
只会说「渲染层卡了」,不可行动。故给 scheduleFit 加耗时记录:超 200ms 写诊断
日志,含 cols/rows/scrollback。正常路径只多一次 performance.now()。

不盲改 reflow 逻辑——先有数据再优化。
This commit is contained in:
Bill committed 2026-10-17 22:43:49 +08:00
1 parent cee616a23a
commit c6aca15134
5 files changed
+208 -41

No files matched your search

+38 -1
View File
@@ -12,7 +12,7 @@
* --outfile=tests/.hl-rules.cjs --alias:@shared=./src/shared
* node tests/hl-rules.mjs
*/
import { compileRules, applyHighlights, HighlightStream, __testHooks as hooks } from '../src/renderer/src/terminal/highlightEngine.ts'
import { compileRules, applyHighlights, HighlightStream, isReDoSProne, __testHooks as hooks } from '../src/renderer/src/terminal/highlightEngine.ts'
import { previewSpans } from '../src/renderer/src/terminal/highlightEngine.ts'
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
import {
@@ -1209,5 +1209,42 @@ console.log('[reset spellings and CSI parameters]')
}
}
console.log('[ReDoS shapes]')
{
// The hot path runs a user pattern on every output line, so exponential
// backtracking must be rejected BEFORE it can run. `(a+)+$` compiles in
// microseconds and then takes ~8s on 30 characters — no length or time guard
// downstream can fix that. These shapes are caught by a string scan at compile
// time, which is why the check has to be exact in both directions.
const dangerous = ['(a+)+$', '(a+)+', '(.*)*', '(.+)+', '([a-z]+)*', '(a*)*b', '(a|a)*', '(a|a)+', '(?:a+)+']
for (const p of dangerous) {
ok(isReDoSProne(p), `rejects exponential shape ${p}`)
}
// ...and it must not touch legitimate patterns. The most important case is the
// shipped presets: rejecting one of those would silently disable a built-in
// rule for every user. `ipv4` (`(?:\\d{1,3}\\.){3}`) is the tricky one — a
// BOUNDED outer quantifier cannot backtrack exponentially.
const safe = [
'\\b(?:ERROR|FAILED)\\b',
'\\d+%',
'(?<=^|\\s)[-dlbcps][rwxstST-]{9}(?![rwxstST-])',
'(?<!not\\s)\\b(?:SUCCESS|PASS|OK)\\b',
'\\b[a-z]+\\b',
'\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b'
]
for (const p of safe) {
ok(!isReDoSProne(p), `accepts safe pattern ${p}`)
}
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.
const compiled = compileRules([
{ id: 'bad', pattern: '(a+)+$', priority: 1, color: { fg: '#ff0000' } },
{ id: 'good', 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')
}
console.log(failed === 0 ? '\n[hl-rules] ALL CHECKS PASSED' : `\n[hl-rules] ${failed} CHECK(S) FAILED`)
process.exit(failed === 0 ? 0 : 1)