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:
1 parent
cee616a23a
commit
c6aca15134
5 files changed
+208
-41
No files matched your search
+38
-1
@@ -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)
|
||||
Reference in new issue
Block a user