From fc0d1afdce9b028dbb23fc95727a65c06b1111c0 Mon Sep 17 00:00:00 2001 From: Bill Date: Sun, 18 Oct 2026 10:09:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(highlight):=20=E4=B8=BB=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=20ReDoS=20=E6=A0=A1=E9=AA=8C=E9=97=B8=E9=97=A8=E4=B8=8E=20rend?= =?UTF-8?q?erer=20=E6=80=A7=E8=83=BD=E6=97=B6=E9=97=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + AGENTS.md | 30 +- docs/PLAN-2026-10-17-font-highlight.md | 128 ++- package.json | 8 +- src/main/index.ts | 16 +- src/main/ipc.ts | 14 +- src/main/redosProbeWorker.ts | 109 +++ src/main/redosValidator.ts | 732 +++++++++++++++++ src/main/ruleRuntime.ts | 34 + src/main/settingsStore.ts | 94 ++- src/preload/index.ts | 9 + src/renderer/src/main.tsx | 127 ++- .../src/settings/HighlightImportExport.tsx | 99 ++- src/renderer/src/settings/HighlightTab.tsx | 213 ++++- src/renderer/src/settings/highlight.css | 15 + src/renderer/src/settings/ruleProbe.ts | 100 +++ src/renderer/src/settings/store.ts | 54 +- src/renderer/src/terminal/TerminalView.tsx | 261 ++++-- src/renderer/src/terminal/highlightEngine.ts | 63 +- src/renderer/src/terminal/perfDiagnostics.ts | 775 ++++++++++++++++++ src/renderer/src/terminal/writeBatcher.ts | 11 + src/shared/api.ts | 12 + src/shared/highlightIO.ts | 17 +- src/shared/i18n/dicts/en/settings.ts | 19 + src/shared/i18n/dicts/ja/settings.ts | 18 + src/shared/i18n/dicts/zh-CN/settings.ts | 17 + src/shared/i18n/dicts/zh-TW/settings.ts | 17 + src/shared/ipc.ts | 79 ++ src/shared/linkRules.ts | 51 +- src/shared/redosProbes.ts | 122 +++ src/shared/ruleValidation.ts | 263 ++++++ src/shared/settings.ts | 16 + tests/build-bundles.cjs | 9 +- tests/hl-rules.mjs | 214 ++++- tests/ipc-guard.mjs | 54 +- tests/link-rules.mjs | 37 +- tests/perf-diagnostics.mjs | 598 ++++++++++++++ tests/redos-probes.mjs | 211 +++++ tests/redos-validator.mjs | 486 +++++++++++ tests/rule-validation.mjs | 418 ++++++++++ tests/settings-store.mjs | 127 +++ tests/worker-artifact.mjs | 172 ++++ tests/write-batcher.mjs | 39 + 43 files changed, 5711 insertions(+), 179 deletions(-) create mode 100644 src/main/redosProbeWorker.ts create mode 100644 src/main/redosValidator.ts create mode 100644 src/main/ruleRuntime.ts create mode 100644 src/renderer/src/settings/ruleProbe.ts create mode 100644 src/renderer/src/terminal/perfDiagnostics.ts create mode 100644 src/shared/redosProbes.ts create mode 100644 src/shared/ruleValidation.ts create mode 100644 tests/perf-diagnostics.mjs create mode 100644 tests/redos-probes.mjs create mode 100644 tests/redos-validator.mjs create mode 100644 tests/rule-validation.mjs create mode 100644 tests/worker-artifact.mjs diff --git a/.gitignore b/.gitignore index bcc0b44..6250dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,8 @@ tests/.log-sanitizer.cjs tests/.reserved-accelerators.cjs tests/.terminal-title.cjs tests/.app-log.cjs +tests/.redos-validator.cjs +tests/.redos-probe-worker.cjs release/ # stray local test artifacts diff --git a/AGENTS.md b/AGENTS.md index 0ec9239..80aa65a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,10 +7,10 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。 - 开发:`npm run dev`(主进程改动不热重建,需重启) - dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置 - 类型检查:`npm run typecheck`(tsconfig.node.json + tsconfig.web.json;只看渲染层可单跑 `npx tsc --noEmit -p tsconfig.web.json`) -- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 31 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、reserved-accelerators、terminal-title、ipc-guard、updater-fallback、log-sanitizer、app-log、sftp-timeout、hl-split-smoke、hl-rules、zmodem-e2e、ssh-session-e2e、sysinfo-e2e、terminal-cwd、terminal-links、broadcast-store、write-batcher、refit-throttle、semantic-theme、theme-contrast、css-theme-vars、ot-var-names、font-stack、link-rules;真实服务器测试需 JD_* 凭据,不在此列)。其中渲染层纯函数测试(semantic-theme、theme-contrast、ot-var-names、font-stack、write-batcher、refit-throttle、broadcast-store、terminal-cwd、terminal-links、terminal-title、hl-split-smoke、hl-rules、link-rules 等)用 Node 22.18+ 的原生 TS 类型剥离**直接 import `.ts` 源文件**(不经 esbuild bundle),Node 20/22.17 以下会 `ERR_UNKNOWN_FILE_EXTENSION` +- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 35 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、reserved-accelerators、terminal-title、ipc-guard、updater-fallback、log-sanitizer、app-log、sftp-timeout、hl-split-smoke、hl-rules、redos-probes、rule-validation、redos-validator、perf-diagnostics、zmodem-e2e、ssh-session-e2e、sysinfo-e2e、terminal-cwd、terminal-links、broadcast-store、write-batcher、refit-throttle、semantic-theme、theme-contrast、css-theme-vars、ot-var-names、font-stack、link-rules;真实服务器测试需 JD_* 凭据,不在此列)。其中渲染层纯函数测试(semantic-theme、theme-contrast、ot-var-names、font-stack、write-batcher、refit-throttle、perf-diagnostics、redos-probes、rule-validation、broadcast-store、terminal-cwd、terminal-links、terminal-title、hl-split-smoke、hl-rules、link-rules 等)用 Node 22.18+ 的原生 TS 类型剥离**直接 import `.ts` 源文件**(不经 esbuild bundle),Node 20/22.17 以下会 `ERR_UNKNOWN_FILE_EXTENSION` - 依赖分类规则:**只有 `src/main/`/`src/preload/` 实际 import 的包才能进 `dependencies`**(node-pty/ssh2/zmodem.js/font-list/electron-updater);纯渲染层依赖一律 devDependencies(Vite 全量打包进 out/renderer,`externalizeDepsPlugin` 不作用渲染层)——这条让 asar 从 98MB 瘦到 8.1MB,加新依赖时别放错边 - 下载量统计:`node scripts/download-stats.cjs`(Gitea + GitHub release 资产的 download_count 汇总;GitHub 优先直连、失败自动回退 `HTTPS_PROXY`/本地 7897;更新通道无计数接口不计入) -- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 31 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(exe + latest.yml + blockmap) +- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 35 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;`electron-vite build` 之后、electron-builder 之前会跑 `node tests/worker-artifact.mjs`——真实加载 `out/main/redosProbeWorker-*.js` 并回包,防止 `?nodeWorker` 产物静默丢失;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(exe + latest.yml + blockmap) - GitHub Actions:`.github/workflows/ci.yml` 在 windows-latest + Node 22 上跑 `npm ci` / `npm test`(含 pretest typecheck)/ `npm run build`,只做验证,不打包安装器、不发布 - 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist` - 依赖一致性闸门:`predist` 开头跑 `node scripts/verify-deps.cjs`(逐条比对 `node_modules` 与 `package-lock.json` 的版本,跨平台 optional 依赖缺失跳过;不一致就 exit 1)——`npm install --package-lock-only` **只重算 lockfile、不碰 node_modules**(本版 npm 还会把 `node_modules/.package-lock.json` 一并重写成理想树,制造「已经装好了」的假象),所以**改动依赖版本后必须真实 `npm install` 或 `npm ci` 再构建**,别指望 lockfile 对齐就等于树里换了包;v1.0.21 的中文输入法回归正是这个坑(`@xterm/xterm` 锁 6.1.0-beta.304,树里还是 6.0.0,打进去的是旧代码) @@ -48,9 +48,10 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。 - 正式版落盘的诊断日志在 `/logs/app-YYYY-MM-DD.log`(**本地日期**命名,`src/main/appLog.ts`):**保留 7 天**(今天 + 前 7 天,更早即删),启动时清扫一次 + 每 24h unref 定时器再扫(长开机的进程跨午夜也会清);只动 `app-*.log` 命名匹配的文件,目录里的外来文件不碰。会话录制日志(LOG_START)是用户文件,永远不走这里 - **开关**:`settings.system.diagnosticLog`(默认开,设置页「系统」区)。闸门在 `appLog()` 里(`setAppLogEnabled` 由 `applySystemSettings` 每次设置变更时跟随,index.ts 启动时先按 settings 设好再写启动行);渲染层看门狗发送前也自查一遍省 IPC。关闭 = 什么都不记,但保留期清扫照常(它只删不写) -- 写入方:主进程启动行 / `unhandledRejection` / `uncaughtException` / 事件循环 stall 看门狗(`index.ts`);渲染层 stall 看门狗经 `APP_LOG` IPC 中继(`main.tsx` → preload → `ipc.ts`)。`appLog()` 走异步串行队列、失败静默——**日志永远不能搞挂应用本身**;单行上限 2000 字符、内嵌换行压平、source 列定宽对齐 -- 排障口径:卡死报告先让用户发当天的 app log——`[main] event loop stalled` = 主进程阻塞,`[renderer] main thread stalled` = 渲染层卡死,两条都没有 = 怀疑 GPU/外部因素 -- 测试:`node tests/app-log.mjs`(经 `tests/.app-log.cjs` bundle;命名/保留窗口表驱动 + 真实临时目录的清扫、追加与开关门控) +- 写入方:主进程启动行 / `unhandledRejection` / `uncaughtException` / 事件循环 stall 看门狗(`index.ts`);渲染层 stall 看门狗与 PERF 观测经 `APP_LOG` IPC 中继(`main.tsx` → preload → `ipc.ts`)。`appLog()` 走异步串行队列、失败静默——**日志永远不能搞挂应用本身**;单行上限 2000 字符、内嵌换行压平、source 列定宽对齐 +- **渲染层 PERF 时间线(只观测,不改变执行行为)**:`src/renderer/src/terminal/perfDiagnostics.ts` + `main.tsx` 的 longtask observer 把迟到的 2s tick 分成 `renderer long task`(窗口内有真实 PerformanceObserver longtask 重叠)与 `scheduler delay/unclassified`(计时器节流/休眠/OS 调度等,不能当渲染层卡死证据);同一条报告带 pane/renderer、最近 fit、xterm write 队列与 batcher 积压。**日志落盘时间是恢复时刻,行内 `window`/`est` 才是估算故障区间**。`PERF fit`(trigger/耗时/cols×rows/bufferLines,每 pane 30s 限频)、`PERF write xterm ack`(xterm 消费完成延迟)与 `PERF renderer webgl-*` 共享每分钟 5 行预算;严重看门狗报告 urgent,不受预算吞掉。所有字段都是元数据,绝不含终端内容;`term.write` callback 只计数,不改变写入顺序,也**不是** PTY 背压 +- 排障口径:卡死报告先让用户发当天的 app log——`[main] event loop stalled` = 主进程阻塞;`main thread lag … renderer long task` = 渲染层真实长任务;`scheduler delay/unclassified` = 看门狗只能证明计时器迟到,不能归因卡死;再用同时间线的 `PERF fit` / `PERF write` / `PERF renderer` 判断是 reflow、xterm 消费积压还是 WebGL 生命周期 +- 测试:`node tests/app-log.mjs`(经 `tests/.app-log.cjs` bundle;命名/保留窗口表驱动 + 真实临时目录的清扫、追加与开关门控)、`node tests/perf-diagnostics.mjs`(longtask 分类、pane 指标聚合、fit/write 限频与 trigger 分类表驱动) ## 更新机制 @@ -148,10 +149,13 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。 - **恢复串只插在「可见间隙」前后**(`MIN_GAP_FOR_RESTORE=3` 个可见字符),不是每个 span 后。Kimi 这类全彩 TUI 一行 ~19 个 span,每 span 都重放会让 9.5KB 输出膨胀到 40.8KB(**4.30x**,无恢复时 2.78x),下游 xterm 解析 + WebGL 字形图集(背景图模式 MCR=4.5 逐字形算对比度,真彩色让缓存全 miss)被压垮,实测渲染线程卡 **18.4 秒**(日志 `[renderer] main thread stalled ~18384ms`)。现在的实现:最后一个 span 后必恢复,中间间隙够长才恢复,膨胀 3.05x 且长间隙着色正常 - **改以上任何参数必须重跑 `[restore cost budget]` 基准段**(`tests/hl-rules.mjs`,断言密集真彩色 TUI 膨胀 < 3.2x)。功能性用例全绿不代表热路径没回归——`hl-split-smoke` 的样本每行以 reset 结尾,恰好绕过了这类膨胀 - **CSI 参数类必须含私有前缀与中间字节**:`ESCAPE_RE` 的 CSI 分支是 `[0-9;:?<>=]*[ -/]*[A-Za-z]`。漏掉 `<`/`>`/`=` 和中间字节(`ESC[>4;1m` modifyOtherKeys、`ESC[<0;1;2M` SGR 鼠标、`ESC[=1;1$y` DECRQM)会让整行被当成含裸 ESC 的文本透传,**该行之后直到下一个换行的所有关键词全部失去高亮**——vim/tmux 在鼠标与按键上报时正好发这些序列 -- **ReDoS:只有「提示」没有「根治」,别误以为已防护**(`highlightEngine.ts` 的 `isReDoSProne`,**仅编辑器使用**):它能抓典型的嵌套量词(`(a+)+`、`(.*)*`)与字面重复分支(`(a|a)*`),但**实测有漏判**——`[(](a+)+`(外层循环不跟踪字符类状态,30 字符卡 4 秒)、`(?:a|a)+` / `((a|a))+`(只做字面相等比较,抓不到语义重叠,7.5s / 16s)——也有误判(`(?:a{2,3})+` 这类范围重复被当成固定重复)。**因此它绝不能当拒绝闸门**:`compileRules` 不因它跳过规则,否则会静默禁用合法规则却仍放进危险规则 - - **尝试过的三个方案与为什么不行**(都有实测依据,别再试):① `re2` 不支持 lookbehind,会废掉 `perm`/`okstate`/`http` 三条预设;② 运行时熔断(循环里查时钟)在原理上无效——单次 `exec` 就阻塞 28 秒,时钟检查根本执行不到;③ Worker + 超时**可靠**(6/6 危险全抓、主线程保持响应),但往返 0.11ms/块(直接执行 0.035ms)且终端高亮必须同步(`WriteBatcher` 消费同步染色结果),**只适合规则的异步校验,不适合热路径** - - 当前热路径的实际边界就是 `MAX_LINE_LEN`(>4KB 的行不进用户正则)与 `MAX_PER_RULE`(每块每规则 300 命中)。真正的解法仍是 Worker 异步校验 + 规则存盘前把关,属未完成项 -- **fit 耗时埋点**:`TerminalView.scheduleFit` 记录每次 `fit.fit()` 的耗时,超 `FIT_SLOW_LOG_MS=200` 就写诊断日志(含 cols/rows/scrollback)。`fit` 是渲染层唯一代价随会话历史增长的操作(reflow 全量 scrollback),而通用 stall 看门狗只会说「渲染层卡了」,不可行动;有这条日志才能判断卡顿是否由 fit 引起、多大、多少行 +- **ReDoS =「静态提示 + 主进程动态校验 + 编译期 fail-closed 闸门」**,三层各自职责别混: + - `highlightEngine.ts` 的 `isReDoSProne` 仍是**编辑器即时提示**,有实测漏判/误判,绝不能当执行依据;文案里的 `passed` 也只能说「已通过当前探针」,不能承诺对任意输入安全 + - 执行闸门在 `compileRules` / `compileLinkRules`:内置高亮预设按「pattern+flags 精确命中 `DEFAULT_HIGHLIGHT_RULES` 派生白名单」放行;其它自定义规则必须持有 `RuleValidation.state='passed'` 且 **pattern、flags、probeVersion、runtime 四项全匹配**才编译。`pending`/`quarantined`/缺失/失配一律跳过,编辑器预览与链接 provider 都走同一闸门,没有旁路 + - 动态校验在 `src/main/redosValidator.ts` + `redosProbeWorker.ts`(electron-vite `?nodeWorker`,产物 `out/main/redosProbeWorker-*.js`):`src/shared/redosProbes.ts` 的 24 条固定合成探针(≤4KB、不含终端内容)在 Worker 里执行,**500ms 硬超时由主控侧计时并 terminate**,超时才 `quarantined`;Worker 崩溃重试一次后回 `pending`,Worker 不可用也回 `pending` 并在设置页明示。并发上限 2、同 key 去重、空闲回收;默认纯预设安装不启动 Worker,热路径零新增每块开销 + - runtime 指纹由 `src/main/ruleRuntime.ts` 安装、renderer 经 `AppInfo.runtimeKey` 对齐;Electron/V8 环境变化会让旧 `passed` 失效回 `pending`。validation 是规则内部元数据:主进程清洗形状,导入文件里的裁决一律不作数,导出剥离 + - 测试:`tests/redos-probes.mjs`(语料与已知病态样本)、`tests/rule-validation.mjs`(闸门真值表)、`tests/redos-validator.mjs`(真实 Worker/ terminate/并发/失败策略,经 build-bundles 产物);`tests/worker-artifact.mjs` 只在 build/dist 后跑,防 Worker 产物静默丢失 +- **fit 耗时埋点(只观测,不改变 fit 时机)**:`TerminalView.scheduleFit(trigger)` 记录 `fit.fit()` 的 trigger(mount/resize/font/scrollback/renderer-switch/unknown)、耗时、cols×rows、**实际 bufferLines** 与 scrollback;超 `FIT_SLOW_LOG_MS=200` 写 `PERF fit`,每 pane 30s 限频并带 `suppressed=N`。`fit` 是渲染层代价随会话历史增长的操作(reflow 全量 scrollback),但 stall 归因必须与 longtask/write 时间线对照,不能只凭一条日志下结论 - **超长行按行降级**:run 无 >4KB 行时走整段扫描(保留跨行匹配);有则按 `\n` 逐行,>4KB 的行原样透传不上色(该 run 内跨行匹配放弃)。回溯防护不变式:**任何 >4KB 的行不进用户正则** - **统计按 pane 实例聚合**:`highlightStats.ts` 按 pane id(`crypto.randomUUID()`,**不是 sessionId**——SSH 分屏同一 session 挂两 pane)登记各自 sink,订阅方拿跨 pane 求和;卸载与关统计开关都要注销,重绑定(rebind)**不**注销(React 先跑完 cleanups 再 setups,在那里注销会让 pane 从汇总消失一拍)。重置清各 pane 的真实计数器 - 分类(`category`,`safety|status|file|net|text|metric`):预设的分类放在 `PRESET_CATEGORIES` 一张表里,`basic` 集合必须始终是 safety+status 的子集(有测试守);`settings.terminal.highlightGroupByCategory` 只影响设置页(加分类列 + 按分类聚簇,组内仍按 priority),**热路径完全不涉及** @@ -168,11 +172,11 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。 ## 可点击链接规则 - 内置链接(`src/renderer/src/terminal/urlLinks.ts` 的 `findUrls`,4 条写死的正则:http(s) / ftp / `www.` / 裸 host:port)**不动**;用户自定义规则是叠加在其上的第二来源 -- 引擎在 `src/shared/linkRules.ts`(**零 import 纯模块**,`tests/link-rules.mjs` 直连):`sanitizeLinkRules` 清洗(id/pattern/template 必填、非法正则丢弃、模板空串丢弃、`enabled` 缺省为 true、note 截断 60、上限 100 条)、`formatLinkTemplate` 展开模板(`$0` 整段 / `$1`–`$9` 分组 / `$$` 字面 `$`,**只吃一位数字**所以 `$10` = 分组 1 + 字面 `0`,未参与分组展开为空串而非 `undefined`)、`compileLinkRules`、`findRuleLinks`、`mergeLinkSpans` -- **安全闸门 = `isSafeLinkTarget`(同文件)**:生成 span 时就把非 `http:`/`https:` 目标丢掉,`file://` / `javascript:` / `ms-settings:` / 相对路径连下划线都不会出现(不是等点开才拦)。主进程 `openExternal` 的协议白名单是第二道,两个都要在 +- 引擎在 `src/shared/linkRules.ts`(可被 `tests/link-rules.mjs` 直连;它 import 共享 `ruleValidation.ts` / `redosProbes.ts`,说明符必须带 `.ts` + 局部 `@ts-expect-error TS5097`,否则 Node 原生类型剥离找不到模块——**别复制闸门/常量造成分表**):`sanitizeLinkRules` 清洗(id/pattern/template 必填、非法正则丢弃、模板空串丢弃、`enabled` 缺省为 true、note 截断 60、上限 100 条、validation 形状清洗)、`formatLinkTemplate` 展开模板(`$0` 整段 / `$1`–`$9` 分组 / `$$` 字面 `$`,**只吃一位数字**所以 `$10` = 分组 1 + 字面 `0`,未参与分组展开为空串而非 `undefined`)、`compileLinkRules`、`findRuleLinks`、`mergeLinkSpans` +- **安全闸门有两层**:① ReDoS 执行闸门——`compileLinkRules` 与高亮规则共用 `validationAllows`,用户链接规则没有内置白名单,`pending`/`quarantined` 不编译;② `isSafeLinkTarget`——生成 span 时就把非 `http:`/`https:` 目标丢掉,`file://` / `javascript:` / `ms-settings:` / 相对路径连下划线都不会出现。主进程 `openExternal` 的协议白名单是第三道,别拆 - 接线点在 `TerminalView.tsx` 的 link provider:`mergeLinkSpans(findRuleLinks(text, linkRulesRef.current), findUrls(text))`——**用户规则优先,重叠的内置链接让位**;hover 提示 `text: link.url` 显示的是展开后的真实目标 - **热更走 `linkRulesRef`**(照 `streamRef.setRules` / `notifyRulesRef` 的模式):provider 随终端注册一次,改规则只换 ref 指向,绝不重建终端。`EMPTY_LINK_RULES` 是共享的稳定空数组——选择器返回新建的 `[]` 会让 zustand 每次拿到新身份,任何无关设置写入都会重渲染所有 pane - 性能护栏:`LINK_TEXT_MAX` 4KB 以上的逻辑行整行跳过(内置正则是固定集合,用户正则可回溯爆炸);每规则每行上限 200 个匹配;能匹配空串的 pattern 强制 `lastIndex` 前进,避免死循环 -- UI 在 `HighlightTab.tsx` 底部的 `ClickableLinkRules`(与高亮同属「终端输出的模式匹配」心智):表格(启用/正则/模板/操作)+ 编辑弹窗(pattern 非法正则内联报错、template 非 http(s) 内联报错、note、启用)。导入导出与 profile 联动**尚未做**,属后续可加 +- UI 在 `HighlightTab.tsx` 底部的 `ClickableLinkRules`(与高亮同属「终端输出的模式匹配」心智):表格(启用/正则/模板/操作)+ 编辑弹窗(pattern 非法正则内联报错、template 非 http(s) 内联报错、note、启用、探针状态)。导入导出与 profile 联动**尚未做**,属后续可加 - 设置字段 `AppSettings.linkRules?: LinkRule[]`(可选,默认 `[]`);三处接线照 `highlightProfiles`:`src/main/settingsStore.ts` 的 `deepMerge` 分支 + `sanitizeLinkRules` 调用 + `defaultSettings()`,渲染层 `settings/store.ts` 的 `setLinkRules` + persist 白名单 -- 测试:`node tests/link-rules.mjs` +- 测试:`node tests/link-rules.mjs`;校验服务语义另见 `tests/redos-validator.mjs` 与「关键词高亮」的 ReDoS 条目 diff --git a/docs/PLAN-2026-10-17-font-highlight.md b/docs/PLAN-2026-10-17-font-highlight.md index 67e6707..81d5ca1 100644 --- a/docs/PLAN-2026-10-17-font-highlight.md +++ b/docs/PLAN-2026-10-17-font-highlight.md @@ -167,7 +167,8 @@ ### 待办(P1,本轮未做) -- **ReDoS 根治**:预览编译/执行挪进 Web Worker + 超时;可选引入 `re2`。现有长度上限只是止血 +- **ReDoS 防护**:按下方「独立方案 A」实施主进程 Worker 异步校验;不采用 `re2`,不把 Worker 放进同步热路径 +- **长期 renderer stall**:按下方「独立方案 B」先补齐时间线诊断,再按证据优化;fit 目前只是候选,不是既定根因 - `fontWeight`/`boldFontWeight` 越界被 xterm 静默改写(手改 json 才会遇到,纯体验问题) - `resizePty` 的 `cols`/`rows` 完全信任渲染层(低危,靠自家 fit 逻辑兜底) - 链接规则的导入/导出、profile 联动 @@ -203,9 +204,130 @@ ### 否决的三个替代方案 1. **运行时熔断**(循环里查时钟):**原理上无效**。实测单次 `exec` 阻塞 **28426ms**,时钟检查根本执行不到 2. **re2**:不支持 lookbehind,`perm`/`okstate`/`http` 三条预设依赖它 -3. **Worker + 超时**:**可靠**(6/6 危险全抓、主线程 806ms 内 tick 51 次未阻塞),但往返 0.11ms/块 vs 直接 0.035ms。**只适合规则异步校验,不适合热路径** +3. **Worker 进入同步热路径**:往返 0.11ms/块 vs 直接 0.035ms,且 `WriteBatcher` 依赖同步染色结果;Worker 只用于规则异步校验 ### 当前状态与遗留 `isReDoSProne` 保留为**编辑器提示**(漏判不比改动前差,误判只是多一个警告而非禁用规则)。热路径边界仍是 `MAX_LINE_LEN`(>4KB 行不进用户正则)+ `MAX_PER_RULE`。 -**未完成**:真正解法是 Worker 异步校验 + 规则存盘前把关。工作量中等,需单独排期。 +--- + +## 独立方案 A:ReDoS 异步校验与执行闸门(已实施,待真实场景验收) + +由 explore 子代理调查、主代理复核构建与终止前提后定稿。本节取代此前「可选 re2」「Worker 放进预览热路径」等粗略想法。 + +**执行状态**:A1–A6 已实施——共享探针/裁决模块、主进程 Worker 硬超时、设置回写、IPC、编辑器与导入 UI、四语状态与测试链均已落地;独立 review 后修复了默认安装误报服务不可用、测试未接入、Worker 产物无 smoke 等问题。剩余验收见 A6,且「通过当前探针 ≠ 对任意输入安全」的边界不变。 + +### A1. 目标与非目标 + +**目标**:自定义高亮规则、链接规则在进入 renderer 同步执行前,先经主进程 Worker 做版本化动态探测;超时规则被隔离,待校验规则不执行,Worker 卡死时主进程可硬终止。覆盖编辑器、实时预览、导入、存量配置和手改 `settings.json`。 + +**非目标**:固定探针不可能数学完备地识别所有病态正则,不得把「通过探针」宣传为「绝对安全」。若验收要求任意正则都绝不能在 renderer 内长时间阻塞,只能另立「线性时间/可限时正则引擎」或异步渲染架构方案;本方案不作此承诺。首版也不加入热路径计时和事后熔断,避免额外开销与复杂度。 + +### A2. 已独立验证的技术前提 + +- 临时最小工程实跑当前 `electron-vite 5.0.0 / Vite 7.3.6`:`import createWorker from './probeWorker?nodeWorker'` 可构建,产物为 `out/main/probeWorker-.js`,不是必须放进 `chunks/`。 +- 构建后的 Worker 已用 Node 实跑;对执行 `((a|a))+$` 灾难回溯的 Worker,主控侧约 **313ms** 完成 `terminate()`,期间主线程持续 tick。超时计时必须放在主控侧,不能依赖 Worker 自己在正则里检查时钟。 +- 当前 Electron 44 的最小 asar 应用已实跑,Worker 能从 asar 内加载并返回结果。**不需要为本方案修改 `asarUnpack`**;仍需构建/打包 smoke 防未来回归。 +- renderer Web Worker 不作为首选:还需承担 `file://`、sandbox、CSP 与持久化裁决回写主进程等额外问题,而主进程路线已经验证。 + +### A3. 架构与规则信任边界 + +新增主进程按需校验服务:`settings 变更/编辑器或导入请求 → ruleValidationController → worker pool → 校验结果写回设置 → 现有 settings 广播 → renderer 重新编译`。`settingsStore` 通过注入钩子调用控制器,避免与 validator 循环依赖;同步 `loadSettings()` 内不得启动或等待 Worker。 + +规则执行状态: + +- `builtin`:pattern + flags 与出厂预设精确匹配,走共享白名单,不启动 Worker。 +- `passed`:Worker 在当前 `probeVersion` 与 V8 大版本下通过探针;UI 文案只能是「已通过当前探针」。 +- `pending`:新规则、存量规则、pattern/flags 改变、裁决过期、Worker 暂不可用或校验尚未完成。 +- `quarantined`:仅当某条规则实际超过硬预算、被主控终止;保留规则供用户编辑,但不执行。 + +**执行闸门是 fail-closed:`pending` 与 `quarantined` 都不进入 `compileRules` / `compileLinkRules`,也不进入编辑器预览。** 不采用「pending 限 512 字符继续跑」——指数回溯在 512 字符上仍可能冻结,这不是安全降级。Worker 不可用时只禁用自定义规则的执行,设置数据保留、内置规则不受影响、设置页给出明确警告;不提供「仍然强制运行」旁路。 + +裁决作为内部元数据随规则持久化,执行时同时核对 pattern、flags、probeVersion、runtime,而不是只信状态字段。导入/导出不得把内部裁决当作用户信任凭据;导入一律重新校验。主进程在首次广播前同步把过期裁决降级为 `pending`,避免应用更新后短暂执行旧裁决。`highlightProfiles` 只引用规则 id,不单独校验。 + +### A4. Worker、探针与预算 + +- `src/main/redosValidator.ts`:懒创建 Worker 池、in-flight 去重、队列、主控硬超时、崩溃重试一次、结果写回;默认安装不启动 Worker。 +- `src/main/redosProbeWorker.ts`:只执行合成探针并返回耗时,不负责自我超时。 +- `src/shared/redosProbes.ts`:零依赖、版本化的固定对抗语料;覆盖重复字符、失败终止符、交替输入、已知字面前缀和重叠字符类。**不再手写一套正则语法扫描器**来假装完整理解 pattern。 +- `src/shared/ruleValidation.ts`:状态类型、flags/key 计算、出厂白名单与执行闸门,main/renderer/测试共用。 +- 探针均为合成输入,长度不超过现有 4KB 行边界,不读取、不记录终端内容;每规则最多约 24 条。 +- 单规则主控硬预算 **500ms**;超时即 `terminate()` 并重建该 Worker。池并发上限 **2**,空闲回收;同 pattern+flags 合并任务。批量导入显示异步进度,不用总预算把未测规则误判成危险。 +- `isReDoSProne` 只保留为输入时提示,与执行裁决无关;同时修正编辑器里「无漏判」的过期注释。 + +### A5. UI、文件与兼容策略 + +- 编辑器按 300–500ms 去抖发起异步校验;pending 草稿不执行预览,保存按钮显示校验状态。每个草稿带递增序号,丢弃慢返回的旧请求,防止旧裁决覆盖新 pattern。超时规则可保存为隔离状态供修改,但不能执行。 +- 导入后批量校验并报告「通过/待校验/隔离」数量;链接规则与高亮规则走同一服务。四语 i18n 增加状态与失败说明。 +- 主进程 `coerceRule` 补上 `MAX_PATTERN_LEN=500`,避免手改配置在校验前先塞入超长 pattern;`sanitizeLinkRules` 已有相同上限。 +- 主要改动:`highlightEngine.ts`、`linkRules.ts`、`settings.ts`、`settingsStore.ts`、`HighlightTab.tsx`、`HighlightImportExport.tsx`、IPC/preload/API 类型、四语字典、`tests/build-bundles.cjs`、`package.json` 测试链与 `AGENTS.md`。 +- 不改 `electron-builder.yml`。新增构建产物 smoke,检查 main 包引用的 Worker 文件确实存在且可加载;发布前再用真实安装包做一次 asar smoke。 + +### A6. 测试、性能预算与实施顺序 + +测试必须包括:已知危险规则(含静态检测漏判的 4 条)全部能在硬预算内隔离;22 条预设与典型合法规则零误隔离;`[(](a+)+$` 等需要字面前缀的回归样本;pending/passed/quarantined/过期裁决的编译闸门;预览与链接 provider 不绕过;Worker 崩溃、缺失、超时、去重、队列和主线程持续响应;手改配置与旧版本迁移。 + +性能预算:纯预设路径不启动 Worker、不增加每 chunk 计时;执行裁决只在设置变化/编译时检查。单次编辑异步校验目标 ≤600ms,不阻塞 renderer;校验失败不得拖住设置页。批量校验不得无上限占用 CPU/内存。 + +实施顺序:①纯探针与裁决模块及测试 → ②主进程 Worker 服务和真实 terminate 测试 → ③编译/预览/链接统一闸门 → ④编辑器、导入、存量迁移与四语 UI → ⑤构建/asar smoke、全量回归与文档。每步可回退,但不把「只上一半闸门」的中间状态发布给用户。 + +最终验收:`npm test`、`npm run build`、`node scripts/verify-deps.cjs` 全过;构建产物 Worker smoke 通过;真实场景验证所有已知危险回归样本都不会冻结设置页或终端,合法预设与现有功能无回归。 + +--- + +## 独立方案 B:长期 renderer stall 时间线诊断与按需优化(第一阶段已实施,第二阶段待数据) + +由 explore 子代理调查日志与代码、主代理复核后定稿。**当前无法把 stall 归因到 fit;第一阶段只增强观测,不改 reflow、不做背压、不关功能。** + +**执行状态**:B2 第一阶段已实施——longtask/scheduler 分类、pane 指标聚合、fit trigger 与限频、xterm write callback 计时、WebGL 生命周期日志和表驱动测试均已落地;B3 复现矩阵与 B4 按证据优化尚未执行,不能在拿到时间线数据前宣称根因或性能收益。 + +### B1. 当前能与不能下的结论 + +- 历史日志以 renderer stall 为主,但现有看门狗只靠 2s `setInterval`:它混合了真实长任务、计时器调度延迟、可见性/系统休眠等状态,日志条数不能直接当成 288 次已证实长任务。 +- fit 已有 >200ms 埋点,但无限频、无触发来源、缺少与 stall/write 的共同时间线;「没有 fit 日志」也不能反向排除 fit。 +- `WriteBatcher` 只缓冲进入 `term.write` 前的字符串;当前没有 xterm `write` callback 消费指标,所以看不到 xterm 内部积压。第一阶段不得声称已有 PTY 背压。 +- 高亮单块耗时低不等于下游成本低,SGR 注入膨胀、scrollback reflow、多 pane、WebGL 仍是对照变量,而不是已证实根因。 + +### B2. 第一阶段:低成本统一时间线(只加观测) + +1. **修正看门狗分类**:`main.tsx` 增加 feature-detect 的 `PerformanceObserver('longtask')`,保留最近约 50 条 startTime/duration 的 ring buffer;`visibilitychange`、`pageshow`、恢复可见时重置 tick。watchdog lag 与长任务时间重叠才记 `renderer long task`,否则记 `scheduler delay/unclassified`,并写清区间起止,不再统称 stall。 +2. **每 pane 轻量指标注册表**:新增纯诊断模块,TerminalView mount/unmount 注册,watchdog 只读聚合快照;不走 React setState,避免诊断本身触发重渲染。 +3. **fit 埋点补全并限频**:记录 trigger(resize/font/scrollback/mount/renderer-switch)、开始时刻、耗时、cols/rows、实际 buffer 行数;每 pane **30s 最多一条**,下一条带 `suppressed=N`。全局 PERF 日志每分钟最多 5 条,watchdog 的严重长任务报告不被该上限吞掉。 +4. **写入链路分两段计时**:WriteBatcher 暴露 `pendingChars`(JS 字符串长度,不冒充字节);每次 flush 记录 `term.write` 入队耗时,并用 `term.write(text, callback)` 记录 xterm 消费完成延迟、未完成 write 数与排队字符数。callback 只做计数和限频日志,**第一阶段不改变写入顺序或暂停 PTY**。 +5. **WebGL 生命周期**:记录 renderer 初始化/切换、`onContextLoss`、dispose/重建结果;不记录图像或终端内容。 +6. **stall 快照**:严重事件携带 pane 数、renderer 模式、最近一次 fit 元数据、Batcher/xterm 队列指标和最近 longtask;所有时间戳由 renderer 在恢复后补写,明确日志落盘时间不等于故障开始时间。 + +### B3. 对照复现矩阵 + +在 dev 独立用户目录下固定同一会话、同一输出样本,每个场景至少 3 次,每次只改一个变量: + +| 组 | 对照 | 主要判定 | +|---|---|---| +| 窗口/布局 | 最大化/还原 20 次;单 pane vs 用户常用多 pane | longtask 是否与 fit 起止重叠,耗时是否随 pane/buffer 增长 | +| 输出负载 | 同一高输出 TUI 或固定 Node 输出样本,30–60s | 队列是否单调增长,write 消费延迟是否与长任务重叠 | +| scrollback | 500 / 5000 / 50000 | fit/reflow 是否随历史行数线性增长;仅为对照,不把缩小值当修复 | +| renderer | WebGL / DOM | 是否伴随 context loss 或仅单 renderer 出现 | +| 高亮 | all / basic / off | 字节膨胀、write 消费与 longtask 是否随档位变化;off 仅作诊断 | +| 字体与 CJK | 字号/行高变化、unicode11/rescale 开关 | 是否只在设置变更或图集重建时出现单次尖峰 | + +同时保留「输入 Kimi 到首屏可交互」的真实场景,前后各测至少 5 次,比较中位数和最慢值,不能用一次感觉下结论。 + +### B4. 第二阶段:只对证实根因优化 + +| 证据 | 才允许进入的优化 | 功能与性能约束 | +|---|---|---| +| longtask 与 fit 明确重叠,且随 scrollback/pane 放大 | 去重/去抖非紧急 fit,合并字体滑块连续变化,队列积压时推迟非紧急 reflow | 必须保留最终 fit 和正确 PTY 尺寸;不重写 xterm reflow | +| xterm 队列持续增长、消费完成延迟与长任务重叠 | 设计端到端高低水位背压,renderer 与主进程/PTY/SSH 协同 | 不丢输出;多 pane 引用同 session 时不能互相误暂停;先补测试再实施 | +| 高亮档位显著改变消费耗时 | 优化 SGR 恢复与输出膨胀 | 保持逐字节兼容,跑 TUI 密集基准,不用关闭高亮冒充修复 | +| 日志出现 WebGL context loss | 重建或自动回退 DOM | 保留会话与 scrollback,不静默丢渲染器 | +| 只有 scheduler delay、无 longtask | 查可见性、系统休眠、OS/GPU/外部调度与看门狗误报 | 不改 fit/reflow | + +「隐藏 pane 暂停解析并重放」属于高复杂度备选,只有在多 pane 被数据证实为主因后才单独评审;它涉及会话状态、重放和切回延迟,不进入首波优化。 + +### B5. 文件、测试与验收 + +第一阶段涉及 `src/renderer/src/main.tsx`、`TerminalView.tsx`、`writeBatcher.ts`,新增轻量诊断纯模块;扩展 `tests/write-batcher.mjs`、`tests/refit-throttle.mjs`,为限频、watchdog 分类和指标聚合新增表驱动纯函数测试。无需新依赖。 + +第一阶段验收:一次用户复现后,日志能区分「fit/reflow、xterm 消费积压、WebGL、其他 longtask、非长任务调度延迟」;正常路径无 React 额外渲染,PERF 落盘 ≤5 行/分钟,所有内容仅为元数据。`npm test` 与 `npm run build` 全过。 + +第二阶段验收:在相同压力场景下消除 >3s 的真实 longtask,队列不无限增长,输入到首屏可交互相对当前 HEAD 有统计上明确的改善;高亮、链接、scrollback、WebGL、会话重放与输出完整性均不得靠降级牺牲。优化未获数据支持前不实施。 diff --git a/package.json b/package.json index a86059a..92363cb 100644 --- a/package.json +++ b/package.json @@ -9,14 +9,14 @@ "private": true, "scripts": { "dev": "electron-vite dev", - "build": "electron-vite build", + "build": "electron-vite build && node tests/worker-artifact.mjs", "preview": "electron-vite preview", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", "pretest": "npm run typecheck", - "test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/app-log.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs && node tests/write-batcher.mjs && node tests/refit-throttle.mjs && node tests/semantic-theme.mjs && node tests/theme-contrast.mjs && node tests/css-theme-vars.mjs && node tests/ot-var-names.mjs && node tests/font-stack.mjs && node tests/link-rules.mjs", + "test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/app-log.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/redos-probes.mjs && node tests/rule-validation.mjs && node tests/redos-validator.mjs && node tests/perf-diagnostics.mjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs && node tests/write-batcher.mjs && node tests/refit-throttle.mjs && node tests/semantic-theme.mjs && node tests/theme-contrast.mjs && node tests/css-theme-vars.mjs && node tests/ot-var-names.mjs && node tests/font-stack.mjs && node tests/link-rules.mjs", "predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only", - "dist": "electron-vite build && electron-builder --win nsis", - "dist:dir": "electron-vite build && electron-builder --win --dir" + "dist": "electron-vite build && node tests/worker-artifact.mjs && electron-builder --win nsis", + "dist:dir": "electron-vite build && node tests/worker-artifact.mjs && electron-builder --win --dir" }, "dependencies": { "@lydell/node-pty": "^1.2.0-beta.15", diff --git a/src/main/index.ts b/src/main/index.ts index ac662db..48249b4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,12 @@ import { pathToFileURL } from 'url' import { devRendererUrl } from './devEnv' import { isTrustedRendererUrl, registerIpc } from './ipc' import { killAllPtys, killPtysByOwner } from './pty' -import { applyStartupSystemSettings, loadSettings, setUpdateScheduleApplier } from './settingsStore' +import { applyStartupSystemSettings, loadSettings, setRuleValidationApplier, setUpdateScheduleApplier } from './settingsStore' +import { setValidatorWorkerFactory, shutdownRuleValidator, validateSettingsRules } from './redosValidator' +// The probe worker is built as its own main-process chunk; this import hands +// the validator a factory rather than a live thread, so nothing starts until a +// rule actually needs proving. +import createProbeWorker from './redosProbeWorker?nodeWorker' import { getLockController, initLockController } from './lockController' import { applyMenuLockState } from './lockMenu' import { isLockBlockedShortcut, isPanicLockChord } from './lockShortcuts' @@ -103,6 +108,12 @@ if (!gotSingleInstanceLock) { // updater.ts already imports settingsStore: the reverse import would be a // cycle, arming the timer against a half-initialised updater. setUpdateScheduleApplier(applyUpdateSchedule) + // Rule validation: the worker factory (a factory, not a thread — the pool + // stays empty until a rule needs proving) plus the applier that re-probes + // stored rules after every settings save. Wiring the applier also runs one + // pass now, which is what re-proves the rules already on disk. + setValidatorWorkerFactory(() => createProbeWorker({})) + setRuleValidationApplier(validateSettingsRules) // OS-level effects (login item, sleep blocker) must apply even if the // settings dialog is never opened this run. applyStartupSystemSettings(loadSettings()) @@ -299,6 +310,9 @@ app.on('before-quit', () => { // Let window 'close' events pass through so teardown completes. markQuitting() killAllPtys() + // Probe worker threads are ours to stop: a terminated-on-quit thread pool + // otherwise keeps the process alive past the last window. + shutdownRuleValidator() globalShortcut.unregisterAll() }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f9996f6..b196b96 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -13,6 +13,8 @@ import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts' import { startPolling, stopPolling } from './sysinfo' import { registerSettingsIpc } from './settingsStore' import { registerLockIpc } from './lockController' +import { registerRuleValidationIpc } from './redosValidator' +import { currentRuntimeKey } from './ruleRuntime' import { registerSessionStateIpc } from './sessionState' import { normalizeReportedCwd, resolveCwd } from './cwd' import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore' @@ -148,7 +150,15 @@ export function registerIpc(): void { ipcMain.handle( Ipc.APP_INFO, - (): AppInfo => ({ platform: process.platform, appVersion: app.getVersion(), homeDir: homedir() }) + (): AppInfo => ({ + platform: process.platform, + appVersion: app.getVersion(), + homeDir: homedir(), + // The rule-validation gate compares proofs against one fingerprint; the + // renderer cannot read the app/Electron/V8 versions itself, so it is + // handed the exact string the main process installed. + runtimeKey: currentRuntimeKey() + }) ) // Renderer watchdog reports land in the same diagnostic log as main's own. ipcMain.on(Ipc.APP_LOG, (_event, line: string) => { @@ -254,6 +264,8 @@ export function registerIpc(): void { registerSessionStateIpc() // ---- lock screen (main owns the state; the renderer only draws the overlay) ---- registerLockIpc() + // ---- rule validation (the ReDoS probe gate: main owns the Worker) ---- + registerRuleValidationIpc() // Resolve a cd-style argument against the current cwd (platform-aware; only // the main process has node's `path`). diff --git a/src/main/redosProbeWorker.ts b/src/main/redosProbeWorker.ts new file mode 100644 index 0000000..43f686b --- /dev/null +++ b/src/main/redosProbeWorker.ts @@ -0,0 +1,109 @@ +/** + * Probe worker (redosProbeWorker.ts). + * + * Runs one rule over the fixed synthetic corpus from `shared/redosProbes.ts` + * and reports how long its worst probe took. Nothing else: no self-imposed + * timeout, no access to settings, no electron import. + * + * The budget deliberately lives on the controlling side (`redosValidator.ts`), + * not here. A catastrophic pattern blocks the regex engine inside a single + * `exec` for seconds to minutes — a clock check inside this loop would never be + * reached (measured: one `exec` of `(?:a|a)+$` on 26 characters blocks for + * 7.5s), so the only way to stop it is to terminate the worker thread from + * outside. This file's job is to be killable. + * + * What a clean reply does NOT mean: the corpus is a fixed set of shapes, not a + * proof of safety. A pattern nobody thought of passes it — which is why the + * verdict is reported as "passed the current probes" everywhere it surfaces. + */ +import { parentPort } from 'node:worker_threads' +import { PROBE_VERSION, REDOS_PROBES } from '../shared/redosProbes' + +/** One rule to probe: the pattern plus the exact flags the engine runs it with. */ +export interface ProbeRequest { + id: number + pattern: string + flags: string +} + +/** The worker's answer for one request. */ +export type ProbeResponse = + | { + id: number + ok: true + probeVersion: number + /** slowest single probe, in ms — the number the budget is compared against */ + worstMs: number + /** whole corpus, in ms */ + totalMs: number + probes: number + } + | { + id: number + ok: false + probeVersion: number + /** `invalid-regex` = it does not compile; `failed` = anything unexpected */ + reason: 'invalid-regex' | 'failed' + ms: number + } + +/** + * Match-loop step cap per probe, mirroring the corpus test's reference + * implementation. A rule that matches on almost every index (`a*`, lookaheads) + * would otherwise spend its whole budget walking positions, and a zero-width + * match would spin forever on one index — stepping past it is the same guard + * the reference loop uses. + */ +const MAX_STEPS = 20000 + +/** + * Run the whole corpus against one rule. Mirrors the reference loop in + * `tests/redos-probes.mjs`: fresh `lastIndex` per probe (the flags carry `g`), + * a step past a zero-width match, and per-probe timing whose worst value is + * what the caller's budget is compared against. + */ +function runCorpus(id: number, pattern: string, flags: string): ProbeResponse { + let regex: RegExp + try { + regex = new RegExp(pattern, flags) + } catch { + return { id, ok: false, probeVersion: PROBE_VERSION, reason: 'invalid-regex', ms: 0 } + } + const started = performance.now() + let worst = 0 + for (const probe of REDOS_PROBES) { + regex.lastIndex = 0 + const probeStart = performance.now() + let match: RegExpExecArray | null + let steps = 0 + while ((match = regex.exec(probe.text)) !== null) { + if (match[0] === '') regex.lastIndex = match.index + 1 + if (++steps > MAX_STEPS) break + } + const ms = performance.now() - probeStart + if (ms > worst) worst = ms + } + return { + id, + ok: true, + probeVersion: PROBE_VERSION, + worstMs: worst, + totalMs: performance.now() - started, + probes: REDOS_PROBES.length + } +} + +/** Probe one request and answer it. Never throws: a reply always goes back. */ +function handle(request: ProbeRequest): void { + const port = parentPort + if (!port) return + let response: ProbeResponse + try { + response = runCorpus(request.id, request.pattern, request.flags) + } catch { + response = { id: request.id, ok: false, probeVersion: PROBE_VERSION, reason: 'failed', ms: 0 } + } + port.postMessage(response) +} + +parentPort?.on('message', (message: ProbeRequest) => handle(message)) diff --git a/src/main/redosValidator.ts b/src/main/redosValidator.ts new file mode 100644 index 0000000..e99794d --- /dev/null +++ b/src/main/redosValidator.ts @@ -0,0 +1,732 @@ +/** + * Rule-validation service (redosValidator.ts). + * + * The controlling side of the ReDoS probe gate: it owns a small pool of + * `redosProbeWorker` threads, hands each rule to one of them under a hard + * wall-clock budget, and turns the answers into stored proofs. + * + * Why the budget is enforced HERE rather than inside the worker: a catastrophic + * pattern blocks V8 inside one `exec` call for seconds to minutes, so a clock + * check in the worker's own loop is unreachable (measured: 28.4s for a single + * `exec`). The only stop that works is `terminate()` from this side — measured + * at ~313ms against a worker stuck in `((a|a))+$` — which is why a rule that + * blows the budget is reported as `quarantined` and its worker is rebuilt. + * + * Trust boundaries: + * - The worker gets a pattern and a flag string and nothing else. No settings, + * no terminal content, no filesystem: the probes are fixed synthetic inputs + * (`shared/redosProbes.ts`), so nothing here can leak what a user has on + * screen. + * - A verdict is only ever `passed` / `quarantined` / "no verdict". A worker + * that crashed, could not be built or never started yields `pending` — it is + * never reported as dangerous, because "we could not measure it" and "we + * measured it as hostile" call for different UI. + * - Nothing is validated at import time. The pool is created on the first + * request, so an install that ships no custom rules never starts a thread — + * and never having needed one is not a failure: the feature-level + * `workerAvailable` flag starts `true` and only turns `false` once a worker + * creation was actually attempted and failed. + * + * Where results go: the editor asks over IPC and gets verdicts back for its + * draft; the settings store's injected applier (`validateSettingsRules`) probes + * what is actually stored and writes the proofs back through `mutateSettings`, + * so they travel to the renderer over the existing settings broadcast. This + * module imports the settings store, never the other way round. + */ +import { ipcMain } from 'electron' +import type { Worker } from 'node:worker_threads' +import { Ipc } from '../shared/ipc' +import type { + RuleValidationKind, + RuleValidationOutcome, + RuleValidationReport, + RuleValidationRequest, + RuleValidationState +} from '../shared/ipc' +import { PROBE_BUDGET_MS, PROBE_VERSION } from '../shared/redosProbes' +import { DEFAULT_HIGHLIGHT_RULES, type AppSettings } from '../shared/settings' +import { + LINK_RULE_FLAGS, + MAX_PATTERN_LEN, + highlightFlags, + makeBuiltinWhitelist, + needsRuleValidation, + ruleKeyOf, + ruleRunState, + type RuleRunState, + type RuleValidation +} from '../shared/ruleValidation' +import type { ProbeRequest, ProbeResponse } from './redosProbeWorker' +import { appLog } from './appLog' +import { broadcast } from './broadcast' +import { currentRuntimeKey } from './ruleRuntime' +import { loadSettings, mutateSettings } from './settingsStore' + +/** Verdict for one rule. `pending` means "no verdict" — never "suspicious". */ +export type ProbeVerdict = 'passed' | 'quarantined' | 'pending' + +export interface RuleProbeRequest { + /** pattern + flags identity; also the dedupe key */ + key: string + pattern: string + flags: string +} + +export interface RuleProbeResult extends RuleProbeRequest { + verdict: ProbeVerdict + /** slowest probe, when a run actually completed */ + ms?: number +} + +/** + * Creates one worker thread. Injected: the production wiring passes the + * electron-vite `?nodeWorker` factory (see `index.ts`), which keeps this module + * free of a build-tool-specific import so tests can bundle and drive it with + * their own worker — a real `node:worker_threads` Worker running an inline + * script, or a stub for the "worker cannot be built" path. + */ +export type ProbeWorkerFactory = () => Worker + +/** The factory presets, exempt from probing (proved by the test suite). */ +const BUILTIN_WHITELIST = makeBuiltinWhitelist(DEFAULT_HIGHLIGHT_RULES) + +const DEFAULT_MAX_WORKERS = 2 +const DEFAULT_IDLE_MS = 30_000 +/** How often a crashed worker's task is retried on a fresh thread, then no verdict. */ +const MAX_TASK_RETRIES = 1 +/** Rules per IPC batch. The renderer chunks longer lists; this is the backstop. */ +export const MAX_VALIDATE_ITEMS = 100 +/** One unavailability line per minute, not per attempt. */ +const UNAVAILABLE_LOG_INTERVAL_MS = 60_000 + +interface Task { + key: string + pattern: string + flags: string + /** retries already spent on this task (a crashed worker's answer is not a verdict) */ + retries: number + settled: boolean + resolve: (outcome: { verdict: ProbeVerdict; ms?: number }) => void +} + +interface Slot { + worker: Worker + busy: boolean + /** we are tearing this worker down ourselves — its exit is not a crash */ + closing: boolean + task?: Task + timer?: NodeJS.Timeout + messageId: number +} + +let factory: ProbeWorkerFactory | null = null +let maxWorkers = DEFAULT_MAX_WORKERS +let ruleBudgetMs = PROBE_BUDGET_MS +let idleMs = DEFAULT_IDLE_MS + +const slots: Slot[] = [] +const queue: Task[] = [] +const inflight = new Map>() +/** Patterns already answered "does not compile" in this environment. */ +const undecidable = new Map() +let nextMessageId = 1 +let idleTimer: NodeJS.Timeout | undefined +/** + * Whether the probe feature is usable — the settings page warns from this, so + * it must not confuse "nothing needed a worker yet" with "the worker is broken". + * It starts `true`: the pool is built lazily, so an install that ships no custom + * rules never creates a thread, which is the normal case and not a failure. Only + * a missing factory, a worker that cannot be created, or a live worker that dies + * on the job turns it `false`; a later successful creation turns it back. + */ +let workerAvailable = true +let lastUnavailableLogAt = 0 +let factoryMissingLogged = false + +/** Test seam: install the worker factory (production wiring lives in index.ts). */ +export function setValidatorWorkerFactory(next: ProbeWorkerFactory | null): void { + factory = next +} + +/** + * Test seam: shrink the budgets so a suite does not wait 500ms per hostile rule + * or 30s for idle reclamation. Production never calls this. + */ +export function setValidatorBudgets(budgets: { + ruleBudgetMs?: number + idleMs?: number + maxWorkers?: number +}): void { + if (budgets.ruleBudgetMs !== undefined) ruleBudgetMs = budgets.ruleBudgetMs + if (budgets.idleMs !== undefined) idleMs = budgets.idleMs + if (budgets.maxWorkers !== undefined) maxWorkers = Math.max(1, budgets.maxWorkers) +} + +/** + * Test seam + app shutdown: drop every worker and all in-memory verdicts, settle + * anything still in flight as "no verdict", and restore the default budgets and + * factory. Production calls it once, at quit; a test calls it between cases so + * one case's worker/budget cannot leak into the next. + */ +export function resetRuleValidator(): void { + if (idleTimer) { + clearTimeout(idleTimer) + idleTimer = undefined + } + for (const slot of [...slots]) { + // A task sitting in a worker slot is IN FLIGHT: its worker is about to be + // terminated, so its answer will never arrive. Take the task out, tear the + // worker down, then settle it as "no verdict" — otherwise the caller's + // promise hangs forever on a reset that ran while a probe was running, and + // the quit path must never leave a dangling await behind. + const pending = slot.task + slot.task = undefined + destroySlot(slot) + if (pending !== undefined) settleTask(pending, { verdict: 'pending' }) + } + slots.length = 0 + for (const task of queue.splice(0)) settleTask(task, { verdict: 'pending' }) + inflight.clear() + undecidable.clear() + workerAvailable = true + lastUnavailableLogAt = 0 + factoryMissingLogged = false + factory = null + maxWorkers = DEFAULT_MAX_WORKERS + ruleBudgetMs = PROBE_BUDGET_MS + idleMs = DEFAULT_IDLE_MS +} + +/** App shutdown hook: no worker thread may outlive the process's window phase. */ +export function shutdownRuleValidator(): void { + resetRuleValidator() +} + +/** Test seam: the validator's own flags/config, so a test can assert on them. */ +export function validatorConfig(): { ruleBudgetMs: number; idleMs: number; maxWorkers: number } { + return { ruleBudgetMs, idleMs, maxWorkers } +} + +// ---- the pool --------------------------------------------------------------- + +function wireSlot(slot: Slot): void { + slot.worker.on('message', (message: ProbeResponse) => onMessage(slot, message)) + slot.worker.on('error', (error: Error) => onWorkerFailure(slot, error)) + slot.worker.on('exit', (code: number) => onWorkerExit(slot, code)) +} + +/** + * Create a worker, or report that none could be created. A synchronous throw + * (missing build artifact, no thread budget) and a null factory both land in the + * same place: the caller flushes its queue as "no verdict" and the settings page + * shows the feature as unavailable. Merely not having needed a worker yet is not + * that case — the flag only moves when a creation was actually attempted. + */ +function createSlot(): Slot | null { + if (factory === null) { + if (!factoryMissingLogged) { + factoryMissingLogged = true + void appLog('main', 'rule validation: no worker factory installed — custom rules stay pending') + } + markUnavailable() + return null + } + let worker: Worker + try { + worker = factory() + } catch (err) { + reportUnavailable(err instanceof Error ? err.message : String(err)) + return null + } + const slot: Slot = { worker, busy: false, closing: false, messageId: 0 } + wireSlot(slot) + slots.push(slot) + if (!workerAvailable) { + workerAvailable = true + publishState() + } + return slot +} + +/** Flip the feature-level flag to unavailable (publishes once, not per attempt). */ +function markUnavailable(): void { + if (!workerAvailable) return + workerAvailable = false + publishState() +} + +/** Report a reason for the unavailability and log it once per interval. */ +function reportUnavailable(detail: string): void { + markUnavailable() + const now = Date.now() + if (now - lastUnavailableLogAt < UNAVAILABLE_LOG_INTERVAL_MS) return + lastUnavailableLogAt = now + void appLog('main', `rule validation: worker unavailable (${detail.slice(0, 200)}) — custom rules stay pending`) +} + +function destroySlot(slot: Slot): void { + slot.closing = true + if (slot.timer) { + clearTimeout(slot.timer) + slot.timer = undefined + } + const index = slots.indexOf(slot) + if (index >= 0) slots.splice(index, 1) + try { + void slot.worker.terminate() + } catch { + /* terminating a thread that is already gone is not an error here */ + } +} + +/** Outcome of trying to get a worker for the next queued task. */ +type SlotOutcome = + /** a worker is ready for the task */ + | { kind: 'slot'; slot: Slot } + /** every worker is busy and the pool is at its cap: the task waits its turn */ + | { kind: 'wait' } + /** no worker can be created at all: the task gets no verdict */ + | { kind: 'unavailable' } + +function acquireSlot(): SlotOutcome { + const free = slots.find((slot) => !slot.busy && !slot.closing) + if (free) return { kind: 'slot', slot: free } + if (slots.length >= maxWorkers) return { kind: 'wait' } + const created = createSlot() + return created === null ? { kind: 'unavailable' } : { kind: 'slot', slot: created } +} + +/** + * Hand queued tasks to free workers until either runs out. + * + * The three ways out matter: "no worker can be built" answers everything still + * queued with `pending` immediately (a task left in the queue would hang its + * caller's promise), while "all workers are busy" must simply wait — flushing + * the queue there would hand out `pending` verdicts for rules that were about + * to be probed perfectly well. + */ +function pump(): void { + while (queue.length > 0) { + const outcome = acquireSlot() + if (outcome.kind === 'wait') { + scheduleIdleReclaim() + return + } + if (outcome.kind === 'unavailable') { + for (const task of queue.splice(0)) settleTask(task, { verdict: 'pending' }) + scheduleIdleReclaim() + return + } + const task = queue.shift() as Task + dispatch(outcome.slot, task) + } + scheduleIdleReclaim() +} + +function dispatch(slot: Slot, task: Task): void { + const messageId = nextMessageId++ + slot.busy = true + slot.task = task + slot.messageId = messageId + slot.timer = setTimeout(() => onBudgetExceeded(slot), ruleBudgetMs) + slot.timer.unref?.() + const request: ProbeRequest = { id: messageId, pattern: task.pattern, flags: task.flags } + try { + slot.worker.postMessage(request) + } catch (err) { + onWorkerFailure(slot, err instanceof Error ? err : new Error(String(err))) + } +} + +function clearSlotTimer(slot: Slot): void { + if (slot.timer) { + clearTimeout(slot.timer) + slot.timer = undefined + } +} + +/** The worker answered in time. */ +function onMessage(slot: Slot, message: ProbeResponse): void { + if (slot.closing || !slot.busy || slot.task === undefined) return + if (message?.id !== slot.messageId) return + const task = slot.task + clearSlotTimer(slot) + slot.busy = false + slot.task = undefined + if (message.ok) { + settleTask(task, { verdict: 'passed', ms: message.worstMs }) + } else { + // A pattern that does not compile can never run anyway (the engine's own + // try/catch drops it), so there is no verdict to store — but remember the + // answer for this environment so the background pass does not re-probe it on + // every settings change. The map is bounded: it is a cache of "no point + // asking again", not a record worth growing forever. + if (undecidable.size > 200) undecidable.clear() + undecidable.set(task.key, currentRuntimeKey()) + settleTask(task, { verdict: 'pending' }) + } + pump() +} + +/** The budget ran out: kill the thread, rebuild it, quarantine the rule. */ +function onBudgetExceeded(slot: Slot): void { + if (slot.closing || slot.task === undefined) return + const task = slot.task + slot.task = undefined + slot.busy = false + destroySlot(slot) + void appLog( + 'main', + `rule validation: rule quarantined (pattern length ${task.pattern.length}, flags ${task.flags}) — budget ${ruleBudgetMs}ms exceeded, worker terminated` + ) + settleTask(task, { verdict: 'quarantined' }) + pump() +} + +/** A worker error or an unexpected exit. Retry the task once, then give up. */ +function onWorkerFailure(slot: Slot, error: Error): void { + if (slot.closing) return + clearSlotTimer(slot) + const task = slot.task + slot.task = undefined + slot.busy = false + destroySlot(slot) + // A worker that dies on the job is not "available": the feature is degraded + // for the whole session, and the settings page has to say so. Creation on the + // next request is still attempted, so a one-off crash heals itself. + reportUnavailable(error.message) + if (task) requeueOrAbandon(task) + pump() +} + +function onWorkerExit(slot: Slot, code: number): void { + if (slot.closing) return + clearSlotTimer(slot) + const task = slot.task + slot.task = undefined + slot.busy = false + destroySlot(slot) + if (task) { + void appLog('main', `rule validation: worker exited while probing (code ${code})`) + requeueOrAbandon(task) + pump() + } +} + +/** + * One retry on a fresh thread, then "no verdict". A crashed worker says nothing + * about the rule, so it must never turn into a quarantine. + */ +function requeueOrAbandon(task: Task): void { + if (task.settled) return + if (task.retries < MAX_TASK_RETRIES) { + task.retries += 1 + queue.push(task) + return + } + settleTask(task, { verdict: 'pending' }) +} + +function settleTask(task: Task, outcome: { verdict: ProbeVerdict; ms?: number }): void { + if (task.settled) return + task.settled = true + task.resolve(outcome) +} + +/** Terminate workers that have been idle for a while (the timer is unref'd). */ +function scheduleIdleReclaim(): void { + if (idleTimer || queue.length > 0) return + idleTimer = setTimeout(() => { + idleTimer = undefined + if (queue.length > 0) return + for (const slot of [...slots]) { + if (!slot.busy) destroySlot(slot) + } + }, idleMs) + idleTimer.unref?.() +} + +/** + * Probe one rule, sharing the run with every other caller asking for the same + * pattern + flags while it is in flight. + */ +function probeOne(key: string, pattern: string, flags: string): Promise<{ verdict: ProbeVerdict; ms?: number }> { + const running = inflight.get(key) + if (running) return running + const tracked = new Promise<{ verdict: ProbeVerdict; ms?: number }>((resolve) => { + queue.push({ key, pattern, flags, retries: 0, settled: false, resolve }) + }).finally(() => { + // Drop only OUR entry: `resetRuleValidator` clears the map outright, and a + // caller may already have registered a fresh run under the same key by the + // time this microtask runs. + if (inflight.get(key) === tracked) inflight.delete(key) + }) + inflight.set(key, tracked) + pump() + return tracked +} + +/** Probe a batch. Per-rule budgets; no total budget, so a slow rule cannot + * make the rules behind it look dangerous. */ +export async function validateRules(requests: readonly RuleProbeRequest[]): Promise { + return Promise.all( + requests.map(async (request) => { + const outcome = await probeOne(request.key, request.pattern, request.flags) + return { ...request, verdict: outcome.verdict, ...(outcome.ms !== undefined ? { ms: outcome.ms } : {}) } + }) + ) +} + +// ---- stored rules: background pass + write-back ------------------------------ + +/** The proof a verdict earns, or undefined when there is nothing to store. */ +function proofFor(verdict: ProbeVerdict, pattern: string, flags: string): RuleValidation | undefined { + if (verdict === 'pending') return undefined + return { + status: verdict === 'passed' ? 'passed' : 'quarantined', + pattern, + flags, + probeVersion: PROBE_VERSION, + runtime: currentRuntimeKey() + } +} + +function sameValidation(a: RuleValidation | undefined, b: RuleValidation | undefined): boolean { + if (a === undefined || b === undefined) return a === b + return ( + a.status === b.status && + a.pattern === b.pattern && + a.flags === b.flags && + a.probeVersion === b.probeVersion && + a.runtime === b.runtime + ) +} + +/** Rules the background pass should spend a probe run on, deduped by pattern. */ +function pendingRequests(settings: AppSettings): RuleProbeRequest[] { + const out: RuleProbeRequest[] = [] + const seen = new Set() + const consider = (pattern: string, flags: string, validation: RuleValidation | undefined, empty: boolean): void => { + if (empty || pattern.length > MAX_PATTERN_LEN) return + const key = ruleKeyOf(pattern, flags) + if (seen.has(key) || BUILTIN_WHITELIST.has(key)) return + if (!needsRuleValidation(pattern, flags, validation, PROBE_VERSION)) return + if (undecidable.get(key) === currentRuntimeKey()) return + seen.add(key) + out.push({ key, pattern, flags }) + } + for (const rule of settings.highlightRules) { + if (rule.enabled === false) continue + consider(rule.pattern, highlightFlags(rule.caseInsensitive, rule.lineAnchor), rule.validation, rule.pattern.length === 0) + } + for (const rule of settings.linkRules ?? []) { + if (rule.enabled === false) continue + consider(rule.pattern, LINK_RULE_FLAGS, rule.validation, rule.pattern.trim() === '') + } + return out +} + +/** + * Apply verdicts to the stored rules. Returns null when nothing would change — + * the caller must not write in that case, because a write broadcasts, which + * runs this pass again: an unchanged write-back would be an endless loop. + */ +function applyVerdicts(settings: AppSettings, verdicts: Map): AppSettings | null { + let changed = false + const highlightRules = settings.highlightRules.map((rule) => { + const flags = highlightFlags(rule.caseInsensitive, rule.lineAnchor) + const verdict = verdicts.get(ruleKeyOf(rule.pattern, flags)) + const proof = verdict === undefined ? undefined : proofFor(verdict, rule.pattern, flags) + if (proof === undefined || sameValidation(rule.validation, proof)) return rule + changed = true + return { ...rule, validation: proof } + }) + const linkRules = (settings.linkRules ?? []).map((rule) => { + const verdict = verdicts.get(ruleKeyOf(rule.pattern, LINK_RULE_FLAGS)) + const proof = verdict === undefined ? undefined : proofFor(verdict, rule.pattern, LINK_RULE_FLAGS) + if (proof === undefined || sameValidation(rule.validation, proof)) return rule + changed = true + return { ...rule, validation: proof } + }) + if (!changed) return null + return { ...settings, highlightRules, linkRules } +} + +/** Counts for the settings page's warning. Disabled rules are not waiting for + * anything: they are not probed, and enabling one re-runs the pass. */ +function countStates(settings: AppSettings): { pending: number; quarantined: number } { + let pending = 0 + let quarantined = 0 + const count = (state: RuleRunState): void => { + if (state === 'pending') pending += 1 + else if (state === 'quarantined') quarantined += 1 + } + for (const rule of settings.highlightRules) { + if (rule.enabled === false) continue + count( + ruleRunState(rule.pattern, highlightFlags(rule.caseInsensitive, rule.lineAnchor), rule.validation, PROBE_VERSION, BUILTIN_WHITELIST) + ) + } + for (const rule of settings.linkRules ?? []) { + if (rule.enabled === false) continue + count(ruleRunState(rule.pattern, LINK_RULE_FLAGS, rule.validation, PROBE_VERSION)) + } + return { pending, quarantined } +} + +/** The state the renderer shows: aggregate, not per rule. */ +export function getRuleValidationState(): RuleValidationState { + const { pending, quarantined } = countStates(loadSettings()) + return { + runtime: currentRuntimeKey(), + probeVersion: PROBE_VERSION, + workerAvailable, + pending, + quarantined + } +} + +/** Push the aggregate state to the renderer (called when it can have changed). */ +function publishState(settings?: AppSettings): void { + const source = settings ?? loadSettings() + const { pending, quarantined } = countStates(source) + const state: RuleValidationState = { + runtime: currentRuntimeKey(), + probeVersion: PROBE_VERSION, + workerAvailable, + pending, + quarantined + } + broadcast(Ipc.RULE_VALIDATION_STATE, state) +} + +/** + * The settings store's applier: probe everything stored that has no current + * verdict, then write the verdicts back through the settings pipeline. Called + * on startup and after every settings mutation, so it must never block (the + * write path is synchronous) and must converge — after the write-back the same + * rules hold matching proofs and are not probed again. + */ +export function validateSettingsRules(settings: AppSettings): void { + const requests = pendingRequests(settings) + publishState(settings) + if (requests.length === 0) return + void validateRules(requests) + .then(async (results) => { + const verdicts = new Map() + for (const result of results) verdicts.set(result.key, result.verdict) + // Pre-check on the snapshot: `mutateSettings` always writes and + // broadcasts, and a broadcast re-runs this pass. + if (applyVerdicts(settings, verdicts) === null) return + await mutateSettings((current) => applyVerdicts(current, verdicts) ?? current) + publishState() + }) + .catch((err: unknown) => { + // A validation pass must never take the settings pipeline down with it. + void appLog('main', `rule validation: pass failed (${err instanceof Error ? err.message : String(err)})`) + }) +} + +// ---- IPC -------------------------------------------------------------------- + +/** One shape-cleaned IPC item, tagged with its place in the request. */ +interface ReadItem { + id: string + /** position in the original request array — the report's sort key */ + order: number + pattern: string + caseInsensitive: boolean + lineAnchor: boolean +} + +/** Shape-clean one IPC item; returns null when it cannot be a rule at all. */ +function readItem(value: unknown, index: number): ReadItem | null { + if (value === null || typeof value !== 'object') return null + const item = value as Record + if (typeof item.pattern !== 'string') return null + return { + id: typeof item.id === 'string' ? item.id : `#${index}`, + order: index, + pattern: item.pattern, + caseInsensitive: item.caseInsensitive === true, + lineAnchor: item.lineAnchor === true + } +} + +function flagsFor(kind: RuleValidationKind, item: { caseInsensitive: boolean; lineAnchor: boolean }): string { + return kind === 'link' ? LINK_RULE_FLAGS : highlightFlags(item.caseInsensitive, item.lineAnchor) +} + +/** + * Answer one `RULE_VALIDATE` batch. Drafts are never stored: the renderer gets + * verdicts to show and to preview with, and the proof only becomes durable once + * the rule itself is saved (which runs the background pass above). + */ +export async function handleRuleValidationRequest(raw: unknown): Promise { + const request = (raw ?? {}) as Partial + const kind: RuleValidationKind = request.kind === 'link' ? 'link' : 'highlight' + const rawItems = Array.isArray(request.items) ? request.items : [] + const items = rawItems + .slice(0, MAX_VALIDATE_ITEMS) + .map((value, index) => readItem(value, index)) + .filter((item): item is NonNullable> => item !== null) + + const results: { order: number; outcome: RuleValidationOutcome }[] = [] + const probes: RuleProbeRequest[] = [] + const byKey = new Map() + for (const item of items) { + const flags = flagsFor(kind, item) + const empty = kind === 'link' ? item.pattern.trim() === '' : item.pattern.length === 0 + const key = ruleKeyOf(item.pattern, flags) + // A pattern the engine would refuse anyway gets no verdict: there is + // nothing to run, and a probe run would only waste a worker slot. + if (empty || item.pattern.length > MAX_PATTERN_LEN) { + results.push({ order: item.order, outcome: { id: item.id, state: 'pending' } }) + continue + } + const state = ruleRunState(item.pattern, flags, undefined, PROBE_VERSION, kind === 'link' ? undefined : BUILTIN_WHITELIST) + if (state === 'builtin') { + results.push({ order: item.order, outcome: { id: item.id, state: 'builtin' } }) + continue + } + const same = byKey.get(key) + if (same) same.push(item) + else { + byKey.set(key, [item]) + probes.push({ key, pattern: item.pattern, flags }) + } + } + + const outcomes = await validateRules(probes) + const verdicts = new Map() + for (const outcome of outcomes) verdicts.set(outcome.key, outcome) + for (const [key, same] of byKey) { + const outcome = verdicts.get(key) + const state: RuleRunState = outcome === undefined || outcome.verdict === 'pending' ? 'pending' : outcome.verdict + for (const item of same) { + results.push({ + order: item.order, + outcome: { id: item.id, state, ...(outcome?.ms !== undefined ? { ms: outcome.ms } : {}) } + }) + } + } + + // Keep the reported order aligned with the request, so a caller can zip them. + // The key is the request INDEX, not the id: two items may legitimately share + // an id (the caller echoes back whatever it was handed), and an id-keyed map + // would collapse both onto one rank. + results.sort((a, b) => a.order - b.order) + + return { + runtime: currentRuntimeKey(), + probeVersion: PROBE_VERSION, + workerAvailable, + truncated: rawItems.length > MAX_VALIDATE_ITEMS, + results: results.map((entry) => entry.outcome) + } +} + +export function registerRuleValidationIpc(): void { + ipcMain.handle(Ipc.RULE_VALIDATE, (_event, request: unknown) => handleRuleValidationRequest(request)) + ipcMain.handle(Ipc.RULE_VALIDATION_STATE_GET, () => getRuleValidationState()) +} diff --git a/src/main/ruleRuntime.ts b/src/main/ruleRuntime.ts new file mode 100644 index 0000000..cdc74cb --- /dev/null +++ b/src/main/ruleRuntime.ts @@ -0,0 +1,34 @@ +/** + * The main process's runtime fingerprint (ruleRuntime.ts). + * + * A rule-validation proof is only evidence for the environment it was measured + * in (`shared/ruleValidation.ts`), so the main process has to be able to name + * that environment: this is the one place that reads the app, Electron and V8 + * versions and turns them into the key every proof must carry. The renderer + * gets the same string over IPC (`AppInfo.runtimeKey`) instead of guessing at + * it, so both processes compare against one value rather than two that could + * drift. + * + * `installRuleRuntime` must run before anything reads or writes settings: the + * store normalizes stored proofs against this fingerprint on every load, and a + * normalization that ran with no fingerprint installed would rewrite every + * proof to `pending`. + */ +import { app } from 'electron' +import { runtimeKeyOf, setRuleRuntimeKey } from '../shared/ruleValidation' + +/** The fingerprint of the running build. */ +export function currentRuntimeKey(): string { + return runtimeKeyOf({ + app: app.getVersion(), + electron: process.versions.electron ?? '', + v8: process.versions.v8 ?? '' + }) +} + +/** Install the fingerprint and hand it back (startup wiring, tests). */ +export function installRuleRuntime(): string { + const key = currentRuntimeKey() + setRuleRuntimeKey(key) + return key +} diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index c6feda4..0fd51fe 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -16,6 +16,7 @@ import { lockAutoDelayOf, type AppSettings, type HighlightRule, + type LinkRule, type LockSettings, type SystemSettings, type TerminalSettings @@ -24,12 +25,29 @@ import { DEFAULT_DARK, type TerminalTheme, type ThemeColors } from '../shared/th import { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n' import { sanitizeProfiles } from '../shared/highlightProfiles' import { sanitizeLinkRules } from '../shared/linkRules' +import { PROBE_VERSION } from '../shared/redosProbes' +import { + LINK_RULE_FLAGS, + MAX_PATTERN_LEN, + highlightFlags, + normalizeRuleValidation, + sanitizeRuleValidation, + type RuleValidation +} from '../shared/ruleValidation' import { broadcast } from './broadcast' import { writeJson } from './store' +import { installRuleRuntime } from './ruleRuntime' import { applyGlobalShortcut } from './globalShortcuts' import { applyWindowChrome } from './windowChrome' import { setAppLogEnabled } from './appLog' +// The fingerprint every stored proof is normalized against (see +// `normalizeRuleValidation`) has to be installed before the first load — and +// loadSettings runs from many entry points (IPC, tray, lock controller). Doing +// it here makes that an import guarantee rather than a startup ordering rule no +// compiler checks. +installRuleRuntime() + const settingsPath = (): string => join(app.getPath('userData'), 'settings.json') /** @@ -52,7 +70,9 @@ const DEFAULT_RULE_COLOR = '#e3b341' /** Repair one persisted rule instead of dropping it. Shape problems with an * obvious fix (priority as "5", enabled as 1/0, a missing fg colour) are * corrected; only entries that cannot be a rule at all are skipped. Unknown - * extra fields are preserved so newer/older schemas survive a round trip. */ + * extra fields are preserved so newer/older schemas survive a round trip — + * with one exception: `validation` is internal metadata, so it is rebuilt + * from its known fields (`sanitizeRuleValidation`) rather than copied. */ function coerceRule(value: unknown, index: number, warnings: Warnings): HighlightRule | null { if (value === null || typeof value !== 'object') { warnings.push(`highlightRules[${index}]: not an object — skipped`) @@ -63,6 +83,12 @@ function coerceRule(value: unknown, index: number, warnings: Warnings): Highligh warnings.push(`highlightRules[${index}]: missing id/pattern — skipped`) return null } + // The engine drops anything over this bound anyway; refusing it here makes + // the paste leave a trace instead of vanishing at compile time. + if (rule.pattern.length > MAX_PATTERN_LEN) { + warnings.push(`highlightRules[${index}]: pattern too long — skipped`) + return null + } let enabled = true if (typeof rule.enabled === 'boolean') enabled = rule.enabled @@ -133,8 +159,31 @@ function coerceRule(value: unknown, index: number, warnings: Warnings): Highligh } } + // The proof is internal metadata, so only its SHAPE is checked here: a + // hand-edited settings.json (or a record written by an older schema) can + // carry anything under `validation`, and a malformed record must not be + // carried into the next save. Whether a well-formed record still matches the + // rule's pattern, its flags, the current probe version and the current + // runtime is decided right here rather than left to the gate: a proof from + // another app build or another corpus is rewritten to `pending`, so neither + // the file nor the renderer ever carries a `passed` verdict that no longer + // applies. The renderer would fail closed on it anyway — this keeps the + // stored data honest about it too. + const { validation: rawValidation, ...rest } = rule + const sanitized = sanitizeRuleValidation(rawValidation) + if (rawValidation !== undefined && sanitized === undefined) { + warnings.push(`highlightRules[${index}].validation: malformed — dropped`) + } + const validation = normalizeRuleValidation( + sanitized, + rule.pattern, + highlightFlags(rule.caseInsensitive === true, rule.lineAnchor === true), + PROBE_VERSION + ) + return { - ...rule, + ...rest, + ...(validation ? { validation } : {}), id: rule.id, pattern: rule.pattern, enabled, @@ -422,6 +471,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { const themes = sanitizeThemes(customThemes, errors) const rules = sanitizeRules(highlightRules, errors) + const linkRuleList = normalizeLinkProofs(sanitizeLinkRules(linkRules, (message) => errors.push(message))) return { settings: { @@ -433,7 +483,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { new Set(rules.map((rule) => rule.id)), (message) => errors.push(message) ), - linkRules: sanitizeLinkRules(linkRules, (message) => errors.push(message)), + linkRules: linkRuleList, system: system as SystemSettings, lock }, @@ -441,6 +491,19 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { } } +/** + * Same runtime/corpus normalization the highlight rules get in `coerceRule`: + * a link rule's proof is only evidence for the pattern, flags, corpus and + * engine it was measured in, so anything else becomes an explicit `pending` + * record instead of a stale `passed` one. + */ +function normalizeLinkProofs(rules: LinkRule[]): LinkRule[] { + return rules.map((rule) => { + const validation = normalizeRuleValidation(rule.validation, rule.pattern, LINK_RULE_FLAGS, PROBE_VERSION) + return validation === rule.validation ? rule : { ...rule, validation } + }) +} + // ---- real system side effects ------------------------------------------------ let sleepBlockerId: number | undefined @@ -467,6 +530,25 @@ export function setUpdateScheduleApplier(fn: (system: SystemSettings) => void): fn(loadSettings().system) } +/** + * The rule validator, injected rather than imported: `redosValidator.ts` + * imports this module (it writes verdicts back through `mutateSettings`), so + * importing it back would be a cycle — the applier would run against a + * half-initialised validator. `index.ts` wires it once, together with the + * worker factory the validator needs. + * + * Called with the freshly merged settings on every mutation (and once at wiring + * time, which is what probes the rules already on disk at startup). It never + * blocks this path: it publishes the aggregate state and starts an async pass, + * and the verdicts come back through `mutateSettings` like any other write. + */ +let ruleValidationApplier: ((settings: AppSettings) => void) | null = null + +export function setRuleValidationApplier(fn: (settings: AppSettings) => void): void { + ruleValidationApplier = fn + fn(loadSettings()) +} + /** Apply OS-level effects of the system settings (login item, sleep blocker). */ function applySystemSettings(system: SystemSettings): void { // The main process renders its own strings (tray menu, dialogs), so it tracks @@ -657,6 +739,12 @@ export function mutateSettings(mutate: (settings: AppSettings) => AppSettings): // short EPERM/EBUSY retries when Windows holds the destination open. writeJson(settingsPath(), merged) + // Rules whose proof no longer applies are re-probed in the background, and + // the verdicts return through this same path. Runs before the broadcast so + // the renderer's warning about the validator's state is already current when + // it reacts to the new settings. + ruleValidationApplier?.(merged) + broadcast(Ipc.SETTINGS_CHANGED, merged) return merged }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 2b6e820..30e2dae 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,7 @@ import type { AppSettings } from '../shared/settings' import type { AppApi } from '../shared/api' import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ReleaseNote, SessionSnapshot, UpdateState, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc' import type { LockOperationResult, LockPasswordInput, LockSettingsState } from '../shared/ipc' +import type { RuleValidationReport, RuleValidationRequest, RuleValidationState } from '../shared/ipc' import type { HostKeyAction, HostKeyPromptEvent, @@ -130,6 +131,14 @@ const api: AppApi = { listFonts: () => ipcRenderer.invoke(Ipc.FONTS_LIST), + validateRules: (request: RuleValidationRequest) => ipcRenderer.invoke(Ipc.RULE_VALIDATE, request), + getRuleValidationState: () => ipcRenderer.invoke(Ipc.RULE_VALIDATION_STATE_GET), + onRuleValidationState: (cb: (state: RuleValidationState) => void) => { + const listener = (_: unknown, state: RuleValidationState): void => cb(state) + ipcRenderer.on(Ipc.RULE_VALIDATION_STATE, listener) + return () => ipcRenderer.removeListener(Ipc.RULE_VALIDATION_STATE, listener) + }, + listLayouts: () => ipcRenderer.invoke(Ipc.LAYOUTS_LIST), getLayout: (id: string) => ipcRenderer.invoke(Ipc.LAYOUTS_GET, id), saveLayout: (meta: LayoutMeta, json: string) => ipcRenderer.invoke(Ipc.LAYOUTS_SAVE, meta, json), diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a572f95..49aa5ad 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -3,12 +3,21 @@ import { useEffect, type ReactNode } from 'react' import { App as AntdApp, ConfigProvider, theme as antdTheme } from 'antd' import type { AppApi } from '@shared/api' import type { LockSettingsState } from '@shared/ipc' +import { PROBE_VERSION } from '@shared/redosProbes' import { DEFAULT_SETTINGS, FONT_LIMITS } from '@shared/settings' import { getThemeById } from '@shared/theme' import App from './App' import { ErrorBoundary } from './ErrorBoundary' import ZmodemOffers from './workspace/ZmodemOffers' import { useSettingsStore } from './settings/store' +import { + classifyWatchdogLag, + emitPerfLog, + formatWatchdogReport, + LongTaskRing, + setPerfLogSink, + snapshotPanes +} from './terminal/perfDiagnostics' import { mix } from './theme/chrome' import { isLightTheme } from './theme/semantic' import './global.css' @@ -18,27 +27,90 @@ import './global.css' // and the same line goes through APP_LOG into the on-disk diagnostic log, so a // packaged build's "卡死又恢复" report also tells us which process stalled and // for how long. +// +// The old version had one flaw: a late 2s tick was called a stall no matter what +// caused it. Timer throttling (hidden page, OS suspension, system sleep) and a +// real main-thread long task produce the same "the timer fired late" symptom, +// so the log could not be read as evidence of anything. The first phase of the +// stall timeline (docs/PLAN-2026-10-17-font-highlight.md, 独立方案 B §B2) adds a +// feature-detected `longtask` observer: a late tick only counts as a renderer +// long task when an observed long task actually overlaps that window, and +// everything else is reported as scheduler delay with the unexplained +// remainder spelled out. +// Observation only — nothing here changes how anything runs. { + const WATCHDOG_INTERVAL_MS = 2000 + + const logEnabled = (): boolean => + useSettingsStore.getState().settings.system.diagnosticLog !== false + + // One sink for every PERF producer (watchdog, fit lines, WebGL lifecycle, + // slow xterm writes). Severe watchdog reports pass the budget as urgent; the + // rest share a 5 lines/minute cap so a busy terminal cannot flood the file. + setPerfLogSink((line, ctx) => { + if (!logEnabled()) return + if (ctx.urgent) console.error(`[renderer] ${line}`) + else console.warn(`[renderer] ${line}`) + window.api?.appLog?.(line) + }) + + // Recent long tasks (startTime/duration in performance.now() space). A ring, + // not a log: 50 entries is roughly the last several seconds of blocking. + const longTasks = new LongTaskRing() + try { + if (typeof PerformanceObserver !== 'undefined') { + const observer = new PerformanceObserver((list) => { + try { + for (const entry of list.getEntries()) { + longTasks.push({ startTime: entry.startTime, duration: entry.duration }) + } + } catch { + /* a diagnostics callback must never throw into the observer */ + } + }) + observer.observe({ entryTypes: ['longtask'] }) + } + } catch { + // No longtask support (plain-browser stub, older Chromium): the watchdog + // still runs and reports every late tick as scheduler delay/unclassified — + // which is exactly what it can prove without the observer. + } + let lastTick = performance.now() + // Reset rather than log after the page comes back: a hidden page is throttled + // to roughly one timer a minute, and waking up from sleep leaves an hourly + // "lag" that says nothing about the renderer. `focus` covers OS-level + // suspension where the visibility flag never changed. + const resetTick = (): void => { + lastTick = performance.now() + } + document.addEventListener('visibilitychange', resetTick) + window.addEventListener('pageshow', resetTick) + window.addEventListener('focus', resetTick) + setInterval(() => { const now = performance.now() - const lag = now - lastTick - 2000 - // Chromium throttles timers in a hidden page down to roughly one per - // minute (intensive throttling), which this watchdog would otherwise - // report as a ~58s "stall" on every tick while the window sits in the - // tray. A hidden window also has nothing user-visible to freeze, so the - // report is skipped until the page is visible again. - if (lag > 3000 && !document.hidden) { - const line = `main thread stalled ~${Math.round(lag)}ms` - console.error(`[renderer] ${line}`) - // The settings gate is enforced again in main; checking here just saves - // the IPC round-trip for a stalled-process report nobody wants stored. - if (useSettingsStore.getState().settings.system.diagnosticLog !== false) { - window.api?.appLog?.(line) - } - } + const elapsed = now - lastTick + const lag = elapsed - WATCHDOG_INTERVAL_MS lastTick = now - }, 2000) + // A hidden window has nothing user-visible to freeze; reporting it would + // only ever describe the throttler. + if (document.hidden) return + const verdict = classifyWatchdogLag({ + lagMs: lag, + windowStart: now - elapsed, + windowEnd: now, + longTasks: longTasks.all() + }) + if (verdict === null) return + const line = formatWatchdogReport(verdict, snapshotPanes(now), { + now, + wallNow: Date.now(), + recentLongTasks: longTasks.all() + }) + // Urgent: the one line that must never be dropped by the PERF budget. + emitPerfLog(line, { urgent: true, now }) + }, WATCHDOG_INTERVAL_MS) } // Dev convenience: allow opening the renderer in a plain browser (vite page) @@ -57,7 +129,10 @@ if (typeof window !== 'undefined' && !window.api) { ...over }) const api: AppApi = { - appInfo: async () => ({ platform: 'browser', appVersion: 'dev', homeDir: '' }), + // A plain browser has no main process, so there is no validator and no + // runtime fingerprint: every custom rule reads as pending, which is exactly + // what the real app does when its worker cannot start. + appInfo: async () => ({ platform: 'browser', appVersion: 'dev', homeDir: '', runtimeKey: '' }), appLog: noop, createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }), openSession: async () => ({ id: stubId() }), @@ -127,6 +202,24 @@ if (typeof window !== 'undefined' && !window.api) { saveSettings: async (s) => ({ ...DEFAULT_SETTINGS, ...s }), onSettingsChanged: () => noop, listFonts: async () => [], + // No worker exists in a plain browser page, so the stub reports the feature + // as unavailable and every draft as pending rather than pretending a probe + // ran. The settings page then shows its real "validator unavailable" state. + validateRules: async ({ items }) => ({ + runtime: '', + probeVersion: PROBE_VERSION, + workerAvailable: false, + truncated: false, + results: items.map((item) => ({ id: item.id, state: 'pending' as const })) + }), + getRuleValidationState: async () => ({ + runtime: '', + probeVersion: PROBE_VERSION, + workerAvailable: false, + pending: 0, + quarantined: 0 + }), + onRuleValidationState: () => noop, listLayouts: async () => [], getLayout: async () => null, saveLayout: async () => undefined, diff --git a/src/renderer/src/settings/HighlightImportExport.tsx b/src/renderer/src/settings/HighlightImportExport.tsx index 4df048f..23f8322 100644 --- a/src/renderer/src/settings/HighlightImportExport.tsx +++ b/src/renderer/src/settings/HighlightImportExport.tsx @@ -3,6 +3,7 @@ import { Alert, App, Button, Input, Modal, Popconfirm, Radio, Space } from 'antd import { t } from '@shared/i18n' import { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO' import type { ImportError } from '@shared/highlightIO' +import type { HighlightRule } from '@shared/settings' import { useSettingsStore } from './store' interface ImportResult { @@ -10,8 +11,23 @@ interface ImportResult { warnings: string[] /** rules that made it in; 0 means nothing was imported */ count: number + /** probe verdicts for the imported rules, once the batch came back */ + probe?: ProbeTally } +interface ProbeTally { + passed: number + pending: number + quarantined: number + /** at least one batch never came back — its rules are counted as pending */ + failed: boolean +} + +/** Rules per validation batch. The IPC handler caps batches at the same + * order of magnitude; a bigger import is probed in several rounds so no single + * request holds a worker slot for an unbounded list. */ +const IMPORT_VALIDATE_CHUNK = 50 + /** * Import / export of the rule set as JSON. The renderer can do all of this on * its own (clipboard, FileReader, a download link), so no main-process dialog or @@ -79,6 +95,52 @@ export function HighlightImportExport({ return t('settings.highlight.importEmpty') } + /** + * Probe the rules that just came in, in chunks, and report the tally. + * + * An imported file is not evidence: whatever it carried under `validation` is + * ignored on parse, and every rule is measured here against this build's + * corpus. The saved rules also go through the background pass, so this call + * mostly reports what that pass is doing — the validator dedupes by pattern, + * so the two never double the work. + */ + const probeImported = async (rules: HighlightRule[]): Promise => { + const tally: ProbeTally = { passed: 0, pending: 0, quarantined: 0, failed: false } + const api = window.api + if (api?.validateRules === undefined) { + tally.pending = rules.length + return tally + } + for (let start = 0; start < rules.length; start += IMPORT_VALIDATE_CHUNK) { + const chunk = rules.slice(start, start + IMPORT_VALIDATE_CHUNK) + try { + const report = await api.validateRules({ + kind: 'highlight', + items: chunk.map((rule, index) => ({ + id: `${start + index}`, + pattern: rule.pattern, + caseInsensitive: rule.caseInsensitive, + lineAnchor: rule.lineAnchor + })) + }) + for (const outcome of report.results) { + // A preset needs no proof, so it counts as usable straight away. + if (outcome.state === 'passed' || outcome.state === 'builtin') tally.passed += 1 + else if (outcome.state === 'quarantined') tally.quarantined += 1 + else tally.pending += 1 + } + } catch { + // The rules are already saved; the background pass keeps trying, so a + // failed batch is reported as "pending" rather than as an import error — + // but the failure is flagged, because a silent "pending" count looks + // exactly like a probe that simply has not answered yet. + tally.pending += chunk.length + tally.failed = true + } + } + return tally + } + const handleImport = async (): Promise => { const parsed = parseHighlightRules(draft) if (parsed.error !== undefined) { @@ -93,6 +155,8 @@ export function HighlightImportExport({ const current = useSettingsStore.getState().settings.highlightRules await setHighlightRules(mergeRules(current, parsed.rules, mode)) setResult({ warnings: parsed.warnings, count: parsed.rules.length }) + const probe = await probeImported(parsed.rules) + setResult((prev) => (prev === null ? prev : { ...prev, probe })) } return ( @@ -180,7 +244,13 @@ export function HighlightImportExport({ {result && ( 0) + ? 'warning' + : 'success' + } showIcon message={ result.error !== undefined @@ -190,12 +260,27 @@ export function HighlightImportExport({ : t('settings.highlight.imported', { n: result.count }) } description={ - result.warnings.length > 0 - ? t('settings.highlight.importSkipped', { - n: result.warnings.length, - list: result.warnings.join('; ') - }) - : undefined + result.probe === undefined && result.warnings.length === 0 ? undefined : ( +
+ {result.probe !== undefined && ( +
+ {t('settings.highlight.importValidated', { + passed: result.probe.passed, + pending: result.probe.pending, + quarantined: result.probe.quarantined + })} +
+ )} + {result.probe?.failed === true && ( +
{t('settings.highlight.probeFailed')}
+ )} + {result.warnings.length > 0 && + t('settings.highlight.importSkipped', { + n: result.warnings.length, + list: result.warnings.join('; ') + })} +
+ ) } /> )} diff --git a/src/renderer/src/settings/HighlightTab.tsx b/src/renderer/src/settings/HighlightTab.tsx index 43916be..10c0189 100644 --- a/src/renderer/src/settings/HighlightTab.tsx +++ b/src/renderer/src/settings/HighlightTab.tsx @@ -10,6 +10,7 @@ import { Select, Switch, Table, + Tag, Tooltip } from 'antd' import type { ColumnsType } from 'antd/es/table' @@ -27,13 +28,23 @@ import { LINK_NOTE_MAX, LINK_PATTERN_MAX, LINK_TEMPLATE_MAX, - isSafeLinkTemplate + isSafeLinkTemplate, + linkRuleRunState } from '@shared/linkRules' +import { PROBE_VERSION } from '@shared/redosProbes' +import { highlightFlags, proofMatches, type RuleRunState } from '@shared/ruleValidation' import { useResolvedTheme, useSettingsStore } from './store' import { HighlightImportExport } from './HighlightImportExport' import { HighlightProfiles } from './HighlightProfiles' import { ruleNote } from './ruleLabel' -import { MAX_PATTERN_LEN, compileRules, isReDoSProne, previewSpans } from '../terminal/highlightEngine' +import { useRuleProbe } from './ruleProbe' +import { + MAX_PATTERN_LEN, + compileRules, + highlightRuleRunState, + isReDoSProne, + previewSpans +} from '../terminal/highlightEngine' import { getHighlightStats, resetHighlightStats, @@ -69,6 +80,30 @@ function categoryLabel(category: HighlightCategory | undefined): string { return t(`settings.highlight.category.${category ?? 'none'}`) } +/** Tag colour per run state. `pending` is a warning rather than an error: the + * rule is kept and will be re-checked, it just does not run yet. */ +const STATE_COLOR: Record = { + builtin: 'default', + passed: 'success', + pending: 'warning', + quarantined: 'error' +} + +/** + * The proof state of one stored rule. Deliberately worded as "passed the + * CURRENT probes": the corpus is a fixed set of hostile shapes, so a pass is + * evidence about those shapes on this build, never a claim of safety. + */ +function RuleStateTag({ state }: { state: RuleRunState }): React.JSX.Element { + return ( + + + {t(`settings.highlight.state.${state}`)} + + + ) +} + /** Sort rank for the grouped view: known categories in fixed order, none last. */ function categoryRank(rule: HighlightRule): number { if (rule.category === undefined) return HIGHLIGHT_CATEGORIES.length @@ -85,10 +120,11 @@ function tryCompile(pattern: string): { ok: boolean; message?: string; slow?: bo return { ok: false, message: err instanceof Error ? err.message : t('settings.highlight.invalidRegex') } } // Compiling is not the same as being safe to run: `(a+)+$` compiles instantly - // and then takes ~8s on 30 characters. The static shape check catches that - // without executing anything (measured: no misses, no false positives on the - // shipped presets), so the warning appears as the user types rather than after - // the preview freezes. + // and then takes ~8s on 30 characters. This shape check is an ADVISORY for the + // editor only — it has measured misses (`[(](a+)+$`, `(?:a|a)+`) and false + // positives, so it can never gate execution. The real gate is the probe + // verdict (`useRuleProbe`), which the draft has to pass before it is previewed + // or saved into a running rule set. return { ok: true, slow: isReDoSProne(pattern) } } @@ -123,6 +159,7 @@ export function HighlightTab(): React.JSX.Element { const groupByCategory = useSettingsStore((s) => s.settings.terminal.highlightGroupByCategory) const statsOn = useSettingsStore((s) => s.settings.terminal.highlightStats) const themeColorsOn = useSettingsStore((s) => s.settings.terminal.highlightThemeColors) + const ruleValidation = useSettingsStore((s) => s.ruleValidation) const updateTerminal = useSettingsStore((s) => s.updateTerminal) const [editor, setEditor] = useState({ open: false }) const [ioOpen, setIoOpen] = useState(false) @@ -225,6 +262,13 @@ export function HighlightTab(): React.JSX.Element { ellipsis: true, render: (_pattern: string, record) => }, + { + title: t('settings.highlight.validation'), + key: 'validation', + width: 132, + align: 'center', + render: (_value, record) => + }, { title: t('settings.highlight.priority'), dataIndex: 'priority', @@ -305,6 +349,25 @@ export function HighlightTab(): React.JSX.Element { closable message={t('settings.highlight.alert')} /> + {/* Feature-level state, not a per-rule verdict: without a worker nothing + custom can run at all, and the page has to say so rather than leaving + the user staring at rows that silently do nothing. */} + {!ruleValidation.workerAvailable && ( + + )} + {ruleValidation.workerAvailable && ruleValidation.quarantined > 0 && ( + + )}
@@ -434,6 +497,19 @@ function ClickableLinkRules(): React.JSX.Element { const valid = pattern.trim() !== '' && compile.ok && template.trim() !== '' && templateOk + /** + * The draft's probe verdict. Link rules have no built-in whitelist, so a + * pattern only runs once the main process has measured it — which is why the + * dialog shows the state instead of silently saving something inert. + */ + const probe = useRuleProbe({ + kind: 'link', + pattern: pattern.trim(), + enabled: editingId !== null && valid + }) + const probeState: RuleRunState = + probe.state === 'passed' ? 'passed' : probe.state === 'quarantined' ? 'quarantined' : 'pending' + const patchRule = async (target: LinkRule, partial: Partial): Promise => { // read at call time: the render-scoped array goes stale inside a React batch const current = useSettingsStore.getState().settings.linkRules ?? EMPTY_LINK_RULES @@ -447,6 +523,9 @@ function ClickableLinkRules(): React.JSX.Element { const trimmedNote = note.trim() const current = useSettingsStore.getState().settings.linkRules ?? EMPTY_LINK_RULES if (editing !== undefined) { + // The proof travels with the pattern it was obtained for: editing the + // pattern drops it (the row goes back to `pending` until the main + // process re-proves it), an unchanged one keeps it. const next: LinkRule = { ...editing, pattern: pattern.trim(), @@ -454,7 +533,8 @@ function ClickableLinkRules(): React.JSX.Element { // explicit override: unchecking must clear an `enabled: true` the // edited rule may already carry enabled, - ...(trimmedNote ? { note: trimmedNote } : { note: undefined }) + ...(trimmedNote ? { note: trimmedNote } : { note: undefined }), + ...(editing.pattern === pattern.trim() ? {} : { validation: undefined }) } await setLinkRules(current.map((rule) => (rule.id === next.id ? next : rule))) } else { @@ -524,6 +604,13 @@ function ClickableLinkRules(): React.JSX.Element { ) }, + { + title: t('settings.highlight.validation'), + key: 'validation', + width: 132, + align: 'center', + render: (_value, record) => + }, { title: t('settings.highlight.actions'), key: 'action', @@ -607,6 +694,15 @@ function ClickableLinkRules(): React.JSX.Element { {compile.ok && compile.slow && (
{t('settings.highlight.patternSlow')}
)} + {valid && ( +
+ {probe.state === 'probing' ? ( + {t('settings.highlight.validating')} + ) : ( + + )} +
+ )} {t('settings.highlight.linkPatternHint')}
@@ -713,6 +809,53 @@ function HighlightEditor({ const [showBg, setShowBg] = useState(false) const [testText, setTestText] = useState('') + const compile = tryCompile(draft.pattern) + const valid = draft.pattern.trim() !== '' && compile.ok + /** What would be saved: the pattern is trimmed on the way into the store. */ + const savedPattern = draft.pattern.trim() + /** + * The probe verdict for the draft. The editor never decides on its own + * whether a pattern may run: until the main process's worker reports `passed` + * for exactly this pattern and these flags, the draft is not previewed. + */ + const probe = useRuleProbe({ + kind: 'highlight', + pattern: savedPattern, + caseInsensitive: draft.caseInsensitive, + lineAnchor: draft.lineAnchor, + enabled: open && valid + }) + /** + * The verdict the rule already holds, when it still describes exactly what the + * form is showing. Re-opening an unchanged rule must not blank its preview for + * the half second the fresh probe takes: the stored proof is still evidence + * for this pattern and these flags, and the probe only refreshes it. + */ + const seeded = + editing !== undefined && + editing.pattern === savedPattern && + proofMatches(savedPattern, highlightFlags(draft.caseInsensitive, draft.lineAnchor), editing.validation, PROBE_VERSION) + ? editing.validation + : undefined + /** + * What the row/tag shows. `builtin` is its own state, not `pending`: the draft + * matches a shipped preset, so the engines' whitelist lets it run — labelling + * it "waiting for a check" would contradict the preview right below, which + * does execute it. `previewProof` stays undefined there on purpose: there is no + * measurement to cite, the whitelist exemption is what allows the run. + */ + const probeState: RuleRunState = + probe.state === 'probing' || probe.state === 'idle' + ? (seeded?.status ?? 'pending') + : probe.state === 'passed' + ? 'passed' + : probe.state === 'builtin' + ? 'builtin' + : probe.state === 'quarantined' + ? 'quarantined' + : 'pending' + const previewProof = probeState === 'passed' ? (probe.validation ?? seeded) : undefined + /** * Preview the draft rule *together with* the other rules: a span that some * earlier rule steals (`done`, claimed by the shell-keyword rule) then shows up @@ -721,20 +864,25 @@ function HighlightEditor({ const previewRules = useMemo(() => { const draftRule: HighlightRule = { id: ruleId ?? 'draft', - pattern: draft.pattern, + pattern: savedPattern, enabled: true, priority: draft.priority, color: { fg: draft.fg, ...(showBg && draft.bg ? { bg: draft.bg } : {}) }, ...(draft.bands && draft.bands.length > 0 ? { bands: draft.bands } : {}), ...(draft.caseInsensitive ? { caseInsensitive: true } : {}), - ...(draft.lineAnchor ? { lineAnchor: true } : {}) + ...(draft.lineAnchor ? { lineAnchor: true } : {}), + // A pending or quarantined draft carries no proof, and `compileRules` + // fails closed without one — which is precisely "a draft that has not + // passed is not executed", applied through the same gate the terminal + // uses rather than a second rule invented here. + ...(previewProof ? { validation: previewProof } : {}) } const active = [...highlightRules.filter((rule) => rule.id !== ruleId), draftRule] // The preview has to show the colours the terminal will use, so the theme // mapping is applied here exactly as TerminalView applies it. return compileRules(themeColors ? applyThemeColors(active, theme) : active) }, [ - draft.pattern, + savedPattern, draft.priority, draft.fg, draft.bg, @@ -745,7 +893,8 @@ function HighlightEditor({ highlightRules, ruleId, themeColors, - theme + theme, + previewProof ]) const preview = useMemo( @@ -781,9 +930,6 @@ function HighlightEditor({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) - const compile = tryCompile(draft.pattern) - const valid = draft.pattern.trim() !== '' && compile.ok - const patch = (partial: Partial): void => setDraft((prev) => ({ ...prev, ...partial })) const addBand = (): void => { @@ -841,6 +987,16 @@ function HighlightEditor({ const current = useSettingsStore.getState().settings.highlightRules await setHighlightRules([...current, rule]) } else if (editing !== undefined) { + // A proof belongs to the pattern and flags it was obtained for. Editing + // either of them drops it, so the row shows `pending` immediately + // instead of a `passed` that no longer describes the rule (the main + // process would normalize it away anyway). An unchanged pattern and + // flags keep the existing proof — re-saving a rule must not make it + // stop working until it is probed again. + const identityKept = + editing.pattern === draft.pattern.trim() && + (editing.caseInsensitive === true) === draft.caseInsensitive && + (editing.lineAnchor === true) === draft.lineAnchor const rule: HighlightRule = { ...editing, pattern: draft.pattern.trim(), @@ -855,7 +1011,8 @@ function HighlightEditor({ // explicit override (not a conditional spread): unchecking must clear // a basic flag the edited rule may already carry basic: draft.basic ? true : undefined, - note: draft.note?.trim() ? draft.note.trim() : undefined + note: draft.note?.trim() ? draft.note.trim() : undefined, + ...(identityKept ? {} : { validation: undefined }) } await setHighlightRules(highlightRules.map((r) => (r.id === rule.id ? rule : r))) } @@ -896,6 +1053,18 @@ function HighlightEditor({ {compile.ok && compile.slow && (
{t('settings.highlight.patternSlow')}
)} + {valid && ( +
+ {probe.state === 'probing' ? ( + {t('settings.highlight.validating')} + ) : ( + + )} + {probe.state === 'passed' && probe.ms !== undefined && ( + {formatMs(probe.ms)} + )} +
+ )}
))}
+ {valid && probeState !== 'passed' && probeState !== 'builtin' && ( +
{t('settings.highlight.pendingPreview')}
+ )} {t('settings.highlight.testHint')} @@ -1087,6 +1259,17 @@ function HighlightEditor({ {t('settings.highlight.enabled')} patch({ enabled: c })} /> + + {/* Right above the save button, where the decision is made: a + quarantined rule can be saved and edited, but it will not run. */} + {valid && probeState === 'quarantined' && ( + + )} ) diff --git a/src/renderer/src/settings/highlight.css b/src/renderer/src/settings/highlight.css index 7a5f368..d478203 100644 --- a/src/renderer/src/settings/highlight.css +++ b/src/renderer/src/settings/highlight.css @@ -314,6 +314,21 @@ flex-wrap: wrap; } +/* Probe-state badge. The tag itself is antd's; this only keeps it compact + enough for a table cell and stops the label wrapping. */ +.hl-state-tag { + margin-inline-end: 0; + font-size: 11px; + line-height: 18px; + white-space: nowrap; +} + +/* Feature-level warning rows (validator unavailable / quarantined rules) and + the editor's quarantine note above the save button. */ +.hl-probe-alert { + margin: 4px 0; +} + .hl-color-field { display: flex; align-items: center; diff --git a/src/renderer/src/settings/ruleProbe.ts b/src/renderer/src/settings/ruleProbe.ts new file mode 100644 index 0000000..63045fb --- /dev/null +++ b/src/renderer/src/settings/ruleProbe.ts @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from 'react' +import type { RuleValidationKind } from '@shared/ipc' +import { LINK_RULE_FLAGS, highlightFlags, type RuleRunState, type RuleValidation } from '@shared/ruleValidation' + +/** + * Debounced probe of a rule DRAFT (ruleProbe.ts). + * + * The editor cannot decide on its own whether a pattern is safe to run: the + * verdict comes from the main process's probe worker, and running the draft in + * the renderer to find out is exactly what the gate exists to prevent. So the + * editor asks, debounced while the user types, and until the answer is `passed` + * the draft is not executed — no preview, no hot path. + * + * Two details that matter more than they look: + * - every request carries a sequence number, and a reply whose sequence is no + * longer current is dropped. Without it a slow probe of an abandoned draft + * would overwrite the verdict for the pattern actually on screen, which is + * the one failure mode that could get a hostile pattern previewed. + * - `enabled: false` (dialog closed, empty or uncompilable pattern) resets the + * state instead of probing: there is nothing to run, and a probe run costs a + * worker slot. + */ +const DEBOUNCE_MS = 400 + +export interface DraftProbe { + state: 'idle' | 'probing' | RuleRunState + /** slowest probe in ms, when a run completed */ + ms?: number + /** + * A proof the draft may be previewed with — present only when the probe + * passed. It is never stored: the durable proof is written by the main + * process when the rule itself is saved. + */ + validation?: RuleValidation +} + +export interface DraftProbeInput { + kind: RuleValidationKind + /** the pattern as it would be saved (trimmed) */ + pattern: string + caseInsensitive?: boolean + lineAnchor?: boolean + /** false while the draft cannot run at all (closed dialog, invalid regex) */ + enabled: boolean +} + +export function useRuleProbe(input: DraftProbeInput): DraftProbe { + const { kind, pattern, caseInsensitive, lineAnchor, enabled } = input + const [probe, setProbe] = useState({ state: 'idle' }) + const sequence = useRef(0) + + useEffect(() => { + const mine = ++sequence.current + if (!enabled) { + setProbe({ state: 'idle' }) + return + } + setProbe({ state: 'probing' }) + const timer = window.setTimeout(() => { + const api = window.api + if (api?.validateRules === undefined) { + setProbe({ state: 'pending' }) + return + } + const flags = kind === 'link' ? LINK_RULE_FLAGS : highlightFlags(caseInsensitive, lineAnchor) + void api + .validateRules({ kind, items: [{ id: 'draft', pattern, caseInsensitive, lineAnchor }] }) + .then((report) => { + if (mine !== sequence.current) return + const outcome = report.results[0] + const state: DraftProbe['state'] = outcome?.state ?? 'pending' + setProbe({ + state, + ...(outcome?.ms !== undefined ? { ms: outcome.ms } : {}), + // Only a `passed` verdict earns a proof. A `builtin` verdict is + // exempt from probing, so there is no measurement to cite — it runs + // because the engines' whitelist recognizes the pattern+flags, which + // is why the caller shows it as 内置规则 instead of a fresh pass. + ...(state === 'passed' + ? { + validation: { + status: 'passed' as const, + pattern, + flags, + probeVersion: report.probeVersion, + runtime: report.runtime + } + } + : {}) + }) + }) + .catch(() => { + if (mine === sequence.current) setProbe({ state: 'pending' }) + }) + }, DEBOUNCE_MS) + return () => window.clearTimeout(timer) + }, [kind, pattern, caseInsensitive, lineAnchor, enabled]) + + return probe +} diff --git a/src/renderer/src/settings/store.ts b/src/renderer/src/settings/store.ts index 0cbf8c8..75ba9d1 100644 --- a/src/renderer/src/settings/store.ts +++ b/src/renderer/src/settings/store.ts @@ -1,5 +1,8 @@ import type { AppSettings, LockSettings, SystemSettings, TerminalSettings } from '@shared/settings' import { DEFAULT_SETTINGS } from '@shared/settings' +import type { RuleValidationState } from '@shared/ipc' +import { PROBE_VERSION } from '@shared/redosProbes' +import { setRuleRuntimeKey } from '@shared/ruleValidation' import type { TerminalTheme } from '@shared/theme' import { getThemeById } from '@shared/theme' import { useMemo } from 'react' @@ -10,6 +13,14 @@ export interface SettingsState { /** true once initial settings have been loaded from the main process */ hydrated: boolean settings: AppSettings + /** + * The rule validator's aggregate state (worker health, pending/quarantine + * counts). Aggregate on purpose: the settings page warns about the feature as + * a whole from here, while each row derives its own label from its proof. + * The initial value claims nothing: no pending rules and a working worker, so + * a slow first reply cannot flash a warning. + */ + ruleValidation: RuleValidationState hydrate: () => Promise /** shallow-merge into settings.terminal and persist */ updateTerminal: (partial: Partial) => Promise @@ -29,6 +40,8 @@ export interface SettingsState { /** unsubscriber for the cross-window SETTINGS_CHANGED listener; held module-level so hydrate() is idempotent */ let unsubscribeStoreListener: (() => void) | null = null +/** same for the rule validator's state broadcast */ +let unsubscribeValidationListener: (() => void) | null = null /** re-entrancy guard so concurrent hydrate() calls don't double-subscribe */ let hydrating = false @@ -87,6 +100,13 @@ async function persist( export const useSettingsStore = create((set, get) => ({ hydrated: false, settings: DEFAULT_SETTINGS, + ruleValidation: { + runtime: '', + probeVersion: PROBE_VERSION, + workerAvailable: true, + pending: 0, + quarantined: 0 + }, hydrate: async () => { if (get().hydrated) return @@ -94,7 +114,16 @@ export const useSettingsStore = create((set, get) => ({ if (hydrating) return hydrating = true try { - const settings = await window.api.getSettings() + const [settings, info] = await Promise.all([ + window.api.getSettings(), + // The rule-validation gate compares stored proofs against the running + // build; the renderer cannot read the app/Electron/V8 versions itself, + // so it is handed the exact fingerprint the main process installed. + // A failure here only means every custom rule reads as pending until the + // validator's state broadcast supplies the same string. + window.api.appInfo().catch(() => null) + ]) + if (info?.runtimeKey) setRuleRuntimeKey(info.runtimeKey) set({ settings, hydrated: true }) if (unsubscribeStoreListener === null) { @@ -102,6 +131,18 @@ export const useSettingsStore = create((set, get) => ({ set({ settings: s }) ) } + if (unsubscribeValidationListener === null) { + unsubscribeValidationListener = window.api.onRuleValidationState((state) => + set(applyValidationState(state)) + ) + // The broadcast fires on change, and the validator's startup pass ran + // before this window existed — so ask once for the current state instead + // of showing a stale "everything is fine" until the next settings save. + void window.api + .getRuleValidationState() + .then((state) => set(applyValidationState(state))) + .catch(() => undefined) + } } catch (err) { console.error('[settings] hydrate failed', err) } finally { @@ -155,6 +196,17 @@ export const useSettingsStore = create((set, get) => ({ } })) +/** + * The validator's broadcast carries the authoritative fingerprint as well: if + * `appInfo()` failed at startup, this is the second chance to learn the + * environment the verdicts belong to — without it every proof would keep + * failing the gate's runtime comparison. + */ +function applyValidationState(state: RuleValidationState): { ruleValidation: RuleValidationState } { + if (state.runtime) setRuleRuntimeKey(state.runtime) + return { ruleValidation: state } +} + /** * Resolve the currently active TerminalTheme (builtin or custom) from the store. * Subscribes to the two fields it actually reads (shallow), so an unrelated diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index 0bb3697..9558964 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -24,6 +24,32 @@ import { applyThemeColors } from '@renderer/theme/highlightColors' import { findUrls } from './urlLinks' import { WriteBatcher } from './writeBatcher' import { refitDelay } from './fitThrottle' +import { + emitPerfLog, + FIT_LOG_INTERVAL_MS, + fitTriggerFor, + formatFitReport, + formatSlowWriteReport, + formatWebglReport, + getPaneStats, + intervalLogDecision, + newIntervalGate, + noteFit, + notePaneRenderer, + noteWebgl, + noteWriteDone, + noteWriteEnqueue, + noteWriteStart, + registerPane, + SLOW_WRITE_ACK_MS, + unregisterPane, + type FitSample, + type FitTrigger, + type FitTriggerInput, + type IntervalGate, + type PaneRendererMode, + type WebglEvent +} from './perfDiagnostics' /** * A fit slower than this is worth recording: reflow cost scales with scrollback @@ -445,6 +471,19 @@ export function TerminalView({ const suggestHookRef = useRef(null) // M5: absolute position (view-relative) of the floating completion popup. const [popupPos, setPopupPos] = useState<{ left: number; top: number; flip: boolean } | null>(null) + // Diagnostics (phase B1, observation only). The id is per *bind*, not per + // component: a rebind tears the terminal down while the pane lives on, and a + // `term.write` callback or fit note still in flight from the old terminal + // must land on a dropped entry instead of polluting the new pane's numbers. + const paneDiagIdRef = useRef('') + // Per-bind fit log gate (one line per FIT_LOG_INTERVAL_MS, with suppressed=N). + const fitLogGateRef = useRef(newIntervalGate()) + // Last trigger that asked for a fit; the rAF/trailing run reports it. + const fitTriggerRef = useRef('mount') + // Previous font-group FIELD VALUES (not the object), so the metrics effect can + // tell a font change from a scrollback-only change from "the group was rebuilt + // but nothing we compare moved" — diagnostics label only, never a fit gate. + const prevMetricsRef = useRef(null) // Compile keyword-highlight rules once per rules change; keep them in a ref so // the (stable, single-construction) pty/term subscriptions always read the @@ -660,34 +699,72 @@ export function TerminalView({ selIndexRef.current = suggestionIndex }, [suggestionIndex]) - const applyRenderers = useCallback((mode: TerminalSettings['rendererMode']) => { - const term = termRef.current - if (!term) return - if (webglRef.current) { - webglRef.current.dispose() - webglRef.current = null - } - if (mode === 'dom') { - rendererModeRef.current = mode - return - } - try { - const webgl = new WebglAddon() - webgl.onContextLoss(() => { - // WebGL context lost -> fall back to the DOM renderer, keep the buffer. - if (!webglRef.current) return + /** + * Report a WebGL lifecycle event to both the console and the diagnostic log. + * Metadata only — no pixels, no terminal content, no buffer text. + */ + const logWebgl = useCallback( + (event: WebglEvent, mode: PaneRendererMode, detail?: string): void => { + const paneId = paneDiagIdRef.current + if (paneId === '') return + noteWebgl(paneId, event) + if (event === 'context-loss' || event === 'init-failed') notePaneRenderer(paneId, mode) + emitPerfLog(formatWebglReport(event, paneId, mode, detail)) + }, + [] + ) + + const applyRenderers = useCallback( + (mode: TerminalSettings['rendererMode'], trigger?: 'init' | 'switch') => { + const term = termRef.current + if (!term) return + const paneId = paneDiagIdRef.current + const switched = trigger === 'switch' + if (webglRef.current) { webglRef.current.dispose() webglRef.current = null - }) - term.loadAddon(webgl) - webglRef.current = webgl - } catch { - webglRef.current = null - } - rendererModeRef.current = mode - }, []) + logWebgl('dispose', 'dom', 'mode-change') + } + if (mode === 'dom') { + rendererModeRef.current = mode + // No addon loaded: this is xterm's own DOM renderer. + if (paneId !== '') notePaneRenderer(paneId, 'dom') + return + } + try { + const webgl = new WebglAddon() + webgl.onContextLoss(() => { + // WebGL context lost -> fall back to the DOM renderer, keep the buffer. + if (!webglRef.current) return + // Log BEFORE the fallback: this is the event that explains a sudden + // render cost change, and the marker must exist even if disposal throws. + logWebgl('context-loss', 'dom', 'fell back to dom renderer') + webglRef.current.dispose() + webglRef.current = null + }) + term.loadAddon(webgl) + webglRef.current = webgl + if (paneId !== '') notePaneRenderer(paneId, 'webgl') + logWebgl('init', 'webgl', `${mode}${switched ? ' after-switch' : ''}`) + } catch (err) { + webglRef.current = null + if (paneId !== '') notePaneRenderer(paneId, 'dom') + logWebgl('init-failed', 'dom', err instanceof Error ? err.message : 'unknown error') + } + rendererModeRef.current = mode + }, + [logWebgl] + ) - const scheduleFit = useCallback(() => { + /** + * Request a fit. `trigger` names the call site and is only used for + * diagnostics — the fit itself runs on exactly the same schedule as before + * (rAF, refitDelay throttle, trailing timer). A throttled request keeps the + * trigger so the trailing fit reports why the resize stream started. + */ + const scheduleFit = useCallback((trigger: FitTrigger = 'unknown') => { + fitTriggerRef.current = trigger + const ranTrigger = trigger window.cancelAnimationFrame(timerRef.current) timerRef.current = window.requestAnimationFrame(() => { // Throttle reflows while a window drag resizes the pane every frame: one @@ -702,7 +779,8 @@ export function TerminalView({ if (!fitTrailingRef.current) { fitTrailingRef.current = window.setTimeout(() => { fitTrailingRef.current = 0 - scheduleFit() + // The trailing fit belongs to whichever request was throttled. + scheduleFit(fitTriggerRef.current) }, wait) } return @@ -713,8 +791,10 @@ export function TerminalView({ // Instrument fit: it reflows the entire scrollback, which is the one // renderer-side operation whose cost scales with session history. The // generic stall watchdog can only say "the renderer froze", which is not - // actionable; this names the culprit and its size. Only slow fits log, so - // the normal path pays a single performance.now(). + // actionable; this names the culprit and its size. Phase B1 additionally + // records the trigger, the real buffer length and both grid dimensions, + // so a stall can be tied to a resize storm or to history growth — and + // rate-limits the line per pane, because a drag fits many times a second. const fitStart = performance.now() try { fit.fit() @@ -722,12 +802,29 @@ export function TerminalView({ return } const fitMs = performance.now() - fitStart - if (fitMs > FIT_SLOW_LOG_MS) { - const t = termRef.current - const line = `fit took ${Math.round(fitMs)}ms (${t?.cols ?? '?'}x${t?.rows ?? '?'}, scrollback ${t?.options.scrollback ?? '?'})` - console.warn(`[renderer] ${line}`) - if (useSettingsStore.getState().settings.system.diagnosticLog !== false) { - window.api?.appLog?.(line) + const t = termRef.current + const bufferLines = t?.buffer.active.length ?? 0 + const scrollback = t?.options.scrollback ?? 0 + const paneId = paneDiagIdRef.current + const sample: FitSample = { + trigger: ranTrigger, + startedAt: fitStart, + durationMs: fitMs, + cols: t?.cols ?? 0, + rows: t?.rows ?? 0, + bufferLines, + scrollback + } + // Always recorded (the watchdog's snapshot reports the latest fit even + // when the line below is rate-limited); only the log line is gated. + if (paneId !== '') { + noteFit(paneId, sample) + if (fitMs > FIT_SLOW_LOG_MS) { + const decision = intervalLogDecision(fitLogGateRef.current, fitStart, FIT_LOG_INTERVAL_MS) + fitLogGateRef.current = decision.next + if (decision.emit) { + emitPerfLog(formatFitReport(sample, decision.suppressed, paneId), { now: fitStart }) + } } } const term = termRef.current @@ -1133,18 +1230,24 @@ export function TerminalView({ // from a template apply or panel reuse; recording resumes idle. lineBufRef.current = '' ptySizeRef.current = null - // A rebound session's first fit must not inherit the previous session's - // throttle window — the pty spawns at a placeholder size and the shell - // may already be drawing. + // A rebound session must not inherit the previous session's throttle window + // — the pty spawns at a placeholder size and the shell may already be + // drawing. The diagnostic pane id is per BIND for the same reason: leaving + // writes/acks from the old terminal in the new pane's counters would make + // the aggregate numbers meaningless. lastFitAtRef.current = 0 + if (paneDiagIdRef.current !== '') unregisterPane(paneDiagIdRef.current) + paneDiagIdRef.current = crypto.randomUUID() + fitLogGateRef.current = newIntervalGate() + registerPane(paneDiagIdRef.current, { rendererMode: 'unknown' }) setSuggestions([]) setSuggestionIndex(0) if (recordingRef.current) { recordingRef.current = false setRecording(false) } - applyRenderers(rendererMode) - scheduleFit() + applyRenderers(rendererMode, 'init') + scheduleFit('mount') // xterm event hooks return IDisposable objects, api subscriptions return // unsubscribe functions — keep the two shapes apart or cleanup throws. @@ -1165,9 +1268,48 @@ export function TerminalView({ // batcher hands output over in one write per ~16ms window (the carry // cadence), so a flood costs one parser run per frame instead of one per // IPC message. + // + // Phase B1 measures the two halves separately, without changing either: + // - enqueue: how long the `term.write` call itself took; + // - ack: how long xterm's parser took to consume it (the write callback). + // The callback only updates counters (and rate-limited logs): it never + // touches the data, never re-flushes and never pauses the pty. + const paneDiagId = paneDiagIdRef.current const batcher = new WriteBatcher((text) => { - if (!deadRef.current) term.write(text) + if (deadRef.current) return + const chars = text.length + noteWriteStart(paneDiagId, chars) + const enqueueStart = performance.now() + try { + term.write(text, () => { + // Fires when xterm's parser has consumed the chunk: `ackMs` is the + // consume latency, counted from the handover, so it grows when the + // parser is behind even though the enqueue itself was instant. + const ackMs = performance.now() - enqueueStart + noteWriteDone(paneDiagId, chars, ackMs) + if (ackMs > SLOW_WRITE_ACK_MS) { + const stats = getPaneStats(paneDiagId) + if (stats !== undefined) { + emitPerfLog( + formatSlowWriteReport( + paneDiagId, + ackMs, + stats.writes.inflight, + stats.writes.queuedChars + ) + ) + } + } + }) + } finally { + noteWriteEnqueue(paneDiagId, performance.now() - enqueueStart) + } }) + // The watchdog reads this to tell "the batcher is still holding output" + // apart from "xterm is behind"; cleared on teardown so a rebound pane never + // reports the old batcher's backlog. + const paneEntry = getPaneStats(paneDiagId) + if (paneEntry !== undefined) paneEntry.pendingCharsProbe = () => batcher.pendingChars const scheduleCarryFlush = (): void => { if (carryTimer !== undefined || !stream.hasPending()) return carryTimer = window.setTimeout(() => { @@ -1380,7 +1522,7 @@ export function TerminalView({ let observer: ResizeObserver | undefined if (hostRef.current) { - observer = new ResizeObserver(() => scheduleFit()) + observer = new ResizeObserver(() => scheduleFit('resize')) observer.observe(hostRef.current) observerRef.current = observer } @@ -1430,8 +1572,16 @@ export function TerminalView({ // SESSION_STATE answer must not mark the next session dead. disposed = true deadRef.current = true - webglRef.current?.dispose() - webglRef.current = null + if (webglRef.current) { + webglRef.current.dispose() + webglRef.current = null + logWebgl('dispose', 'dom', 'teardown') + } + // Drop this bind's diagnostic counters on unmount/rebind: in-flight write + // callbacks from the disposed terminal must land on a dropped entry, not + // on the counters the next bind will publish. + if (paneDiagId !== '') unregisterPane(paneDiagId) + paneDiagIdRef.current = '' // Drop the stream with the session; a fresh one is created next bind so no // carry survives across sessions. A mid-session carry is flushed below // (harmless: it is treated as raw text by the resync-aware xterm). @@ -1467,7 +1617,24 @@ export function TerminalView({ // Live-appliable in xterm 6: without this the setting only took effect for // terminals opened after the change, which reads as "the setting is broken". term.options.scrollback = fontOpts.scrollback - scheduleFit() + // Diagnostics only: label the fit with what actually changed. A scrollback + // write and a font-size change reflow very different amounts of history, so + // the log line has to say which one it was — and it compares the previous + // FIELD VALUES, because this effect also runs for a group member (cursor + // blink/style) that changes neither. See `fitTriggerFor`. + const snapshot: FitTriggerInput = { + fontSize: fontOpts.fontSize, + fontFamily: fontOpts.fontFamily, + cjkFontFamily: fontOpts.cjkFontFamily ?? '', + fontWeight: fontOpts.fontWeight, + boldFontWeight: fontOpts.boldFontWeight, + letterSpacing: fontOpts.letterSpacing, + lineHeight: fontOpts.lineHeight, + scrollback: fontOpts.scrollback + } + const trigger = fitTriggerFor(prevMetricsRef.current, snapshot) + prevMetricsRef.current = snapshot + scheduleFit(trigger) }, [fontOpts, scheduleFit]) useEffect(() => { @@ -1487,11 +1654,11 @@ export function TerminalView({ // effect runs on mount too); only an actual switch should re-run it. if (next === rendererModeRef.current) return if (next === 'dom') { - applyRenderers('dom') + applyRenderers('dom', 'switch') } else if (!webglRef.current) { - applyRenderers(next) + applyRenderers(next, 'switch') } - scheduleFit() + scheduleFit('renderer-switch') }, [rendererMode, applyRenderers, scheduleFit]) // keep onClose fresh for the dead mask diff --git a/src/renderer/src/terminal/highlightEngine.ts b/src/renderer/src/terminal/highlightEngine.ts index 16c491b..f992ac8 100644 --- a/src/renderer/src/terminal/highlightEngine.ts +++ b/src/renderer/src/terminal/highlightEngine.ts @@ -1,4 +1,24 @@ -import type { HighlightRule } from '@shared/settings' +import { DEFAULT_HIGHLIGHT_RULES, type HighlightRule } from '@shared/settings' +import { + MAX_PATTERN_LEN, + highlightFlags, + makeBuiltinWhitelist, + ruleRunState, + validationAllows, + type RuleRunState +} from '@shared/ruleValidation' +import { PROBE_VERSION } from '@shared/redosProbes' + +// HighlightTab reads the bound from this module — keep the re-export. +export { MAX_PATTERN_LEN } + +/** + * The factory preset set, keyed by pattern + flags. These run without a + * per-rule validation proof: they ship inside the app and the test suite + * proves them, so re-proofing every load would only re-check what we already + * control. + */ +const BUILTIN_WHITELIST = makeBuiltinWhitelist(DEFAULT_HIGHLIGHT_RULES) /** * Keyword highlight engine. @@ -101,11 +121,9 @@ const MAX_CHUNK = 512 * 1024 */ const MAX_LINE_LEN = 4 * 1024 -/** - * Longest user pattern accepted. Bounds the damage a stored rule can do and - * keeps the editor's live preview from choking on a paste. - */ -export const MAX_PATTERN_LEN = 500 +// Longest user pattern accepted: `MAX_PATTERN_LEN` from shared/ruleValidation +// (re-exported above). Bounds the damage a stored rule can do and keeps the +// editor's live preview from choking on a paste. /** * Advisory ReDoS heuristic for the rule EDITOR: flags patterns whose shape @@ -113,8 +131,11 @@ export const MAX_PATTERN_LEN = 500 * * Scope is deliberately limited to a warning. Measured misses — `[(](a+)+` * (4s on 30 chars) and `(?:a|a)+` (7.5s) / `((a|a))+` (16s) both slip through — - * mean it must NOT gate the hot path: rejecting on a heuristic would silently - * disable legitimate rules while still letting hostile ones past. It is useful + * mean the hot path cannot gate on this shape test: rejecting on a heuristic + * would silently disable legitimate rules while still letting hostile ones + * past. The real gate is the validation proof (`validationAllows`): a rule + * runs only with a `passed` record matching its pattern, flags and probe + * version, or as a factory built-in on the whitelist. The heuristic stays * where a false negative costs nothing and a hit saves the user from typing a * pattern that would freeze the settings page. * @@ -277,7 +298,18 @@ export function tokenize(input: string): Token[] { /** Flags a rule compiles with: global, plus `i` / `m` when the rule opts in. */ function flagsFor(rule: HighlightRule): string { - return `g${rule.caseInsensitive === true ? 'i' : ''}${rule.lineAnchor === true ? 'm' : ''}` + return highlightFlags(rule.caseInsensitive, rule.lineAnchor) +} + +/** + * How a highlight rule stands right now, derived from its stored proof rather + * than read off it: `builtin` (factory preset, exempt), `passed` (proof matches + * this pattern, flags, corpus and build), `quarantined` (measured over budget) + * or `pending` (no current verdict). The settings table labels rows with this, + * so the label cannot disagree with what `compileRules` will actually do. + */ +export function highlightRuleRunState(rule: HighlightRule): RuleRunState { + return ruleRunState(rule.pattern, flagsFor(rule), rule.validation, PROBE_VERSION, BUILTIN_WHITELIST) } /** Compile only enabled + safely-compilable rules, sorted by priority asc. */ @@ -290,12 +322,13 @@ export function compileRules(rules: HighlightRule[]): CompiledRule[] { // Bounded like the other per-rule guards: a huge pattern only costs compile // time, but it also means someone pasted a blob into the editor. if (pattern.length > MAX_PATTERN_LEN) continue - // NOTE: the rule is deliberately NOT rejected for a ReDoS-prone shape here. - // `isReDoSProne` is an advisory heuristic used by the editor only — it has - // measured misses (`[(](a+)+`, `(?:a|a)+`) and false positives, so using it - // as a gate would silently disable legitimate rules while still letting - // some hostile ones through. The real bounds on this path remain - // MAX_LINE_LEN (no line over 4KB reaches a user regex) and MAX_PER_RULE. + // Fail-closed admission: a rule runs with a `passed` proof matching its + // pattern, flags and probe version exactly, or as a factory built-in the + // whitelist exempts. This is NOT the `isReDoSProne` heuristic (that stays + // an editor advisory — measured misses would make a shape-based gate + // wrong in both directions). The line-length and per-rule match caps + // (MAX_LINE_LEN / MAX_PER_RULE) remain as depth, not as the front door. + if (!validationAllows(pattern, flagsFor(rule), rule.validation, PROBE_VERSION, BUILTIN_WHITELIST)) continue let regex: RegExp try { // 'g' always; 'i' when the rule asks for case-insensitive matching; 'm' diff --git a/src/renderer/src/terminal/perfDiagnostics.ts b/src/renderer/src/terminal/perfDiagnostics.ts new file mode 100644 index 0000000..2004e91 --- /dev/null +++ b/src/renderer/src/terminal/perfDiagnostics.ts @@ -0,0 +1,775 @@ +/** + * Renderer performance diagnostics — the observation-only first phase of the + * stall-timeline plan (docs/PLAN-2026-10-17-font-highlight.md, 独立方案 B §B2 + * 「第一阶段:低成本统一时间线」). + * + * This phase only *observes*: nothing here changes when a fit runs, how output is + * written, or which renderer is used. The module is deliberately pure — plain + * counters, monotonic timestamps and trigger labels — so it never touches the + * DOM, never grows React state (a diagnostic that re-renders panes would pollute + * the very measurement it takes), and never stores terminal content. Both of + * those properties are also what let `tests/perf-diagnostics.mjs` import the + * `.ts` file directly through Node's type stripping. + * + * Five layers, all of them metadata: + * + * 1. `LongTaskRing` / `overlapLongTasks` / `classifyWatchdogLag` — turns a + * watchdog tick into "a real renderer long task" vs "scheduler delay", with + * the window and estimated start/end, instead of the old blanket "stall". + * 2. Per-pane registry (`registerPane` … `snapshotPanes`) — the watchdog reads + * one aggregate; panes never call back into React. + * 3. Fit samples (`noteFit`) — trigger, start, duration, grid and real buffer + * length, which is what lets reflow cost be tied to history size. + * 4. Write-chain counters (`noteWriteStart` / `noteWriteDone`) — how long the + * `term.write` call itself took (enqueue) and how long xterm took to + * consume it (callback), plus unfinished writes and queued characters. + * 5. Rate limiters (`intervalLogDecision`, `perfLogDecision`) — per-pane fit + * lines at most once per 30s with a `suppressed=N` trailer, and at most 5 + * PERF lines a minute globally. Severe watchdog reports bypass that cap: + * they are the one line worth having. + * + * Characters are JS string lengths (UTF-16 code units), never claimed as bytes. + */ + +/** Why a fit was requested. The trailing (settled-size) fit keeps its origin. */ +export type FitTrigger = 'mount' | 'resize' | 'font' | 'scrollback' | 'renderer-switch' | 'unknown' + +/** Renderer actually installed on a pane at the time of the sample. */ +export type PaneRendererMode = 'webgl' | 'dom' | 'unknown' + +/** WebGL lifecycle events worth one log line each (no pixels, no content). */ +export type WebglEvent = 'init' | 'init-failed' | 'context-loss' | 'dispose' + +/** How many long-task entries the watchdog keeps (≈ the last few seconds). */ +export const MAX_LONG_TASKS = 50 +/** Per-pane minimum gap between two fit log lines. */ +export const FIT_LOG_INTERVAL_MS = 30_000 +/** Global PERF line budget window and size (disk writes, not console). */ +export const PERF_LOG_WINDOW_MS = 60_000 +export const PERF_LOG_MAX_PER_WINDOW = 5 +/** Watchdog lag (over and above the interval) that is worth classifying. */ +export const WATCHDOG_LAG_LOG_MS = 3000 +/** A `term.write` whose parser callback took longer than this is logged once. */ +export const SLOW_WRITE_ACK_MS = 1000 + +export interface FitSample { + trigger: FitTrigger + /** `performance.now()` when the fit started. */ + startedAt: number + durationMs: number + cols: number + rows: number + /** `term.buffer.active.length` after the fit — the real reflowed line count. */ + bufferLines: number + /** Configured scrollback cap, for context against `bufferLines`. */ + scrollback: number +} + +/** + * The font-group values a fit trigger is derived from. Only these are compared: + * font metrics and the scrollback cap decide how much history a fit has to + * rewrap, while the rest of the group (cursor blink / style) changes rendering + * without changing the reflow size. + */ +export interface FitTriggerInput { + fontSize: number + fontFamily: string + /** normalized by the caller: an absent CJK fallback reads as the empty string */ + cjkFontFamily: string + fontWeight: number + boldFontWeight: number + letterSpacing: number + lineHeight: number + scrollback: number +} + +/** + * Why a fit gets the label it gets — diagnostics only; it never decides whether + * a fit runs. `prev === null` means this is the first fit after construction. + * + * The comparison is over FIELD VALUES, not object identity. The settings store + * hands out a new group object whenever ANY field in the group changes, so an + * identity-based comparison reports a cursor-blink change as a scrollback + * reflow — and calls every unrelated churn a scrollback change too, which is the + * one label that would make a stall report point at history size for no reason. + * No difference in any compared field is `unknown`: nothing here changed, so + * nothing here explains the fit. + */ +export function fitTriggerFor(prev: FitTriggerInput | null, next: FitTriggerInput): FitTrigger { + if (prev === null) return 'mount' + if ( + prev.fontSize !== next.fontSize || + prev.fontFamily !== next.fontFamily || + prev.cjkFontFamily !== next.cjkFontFamily || + prev.fontWeight !== next.fontWeight || + prev.boldFontWeight !== next.boldFontWeight || + prev.letterSpacing !== next.letterSpacing || + prev.lineHeight !== next.lineHeight + ) { + return 'font' + } + return prev.scrollback === next.scrollback ? 'unknown' : 'scrollback' +} + +export interface PaneWriteMetrics { + /** `term.write` calls made for this pane. */ + count: number + /** Writes whose parser callback has not fired yet. */ + inflight: number + /** Characters handed to xterm but not yet consumed (string length). */ + queuedChars: number + enqueueMsLast: number + enqueueMsMax: number + ackMsLast: number + ackMsMax: number +} + +export interface PaneFitMetrics { + count: number + last: FitSample | null +} + +export interface PaneWebglMetrics { + initCount: number + initFailedCount: number + contextLossCount: number + disposeCount: number +} + +export interface PaneStats { + paneId: string + rendererMode: PaneRendererMode + registeredAt: number + /** Live Batcher backlog for this pane, read lazily at snapshot time. */ + pendingCharsProbe?: () => number | undefined + pendingChars: number + writes: PaneWriteMetrics + fits: PaneFitMetrics + webgl: PaneWebglMetrics +} + +export interface PanesSnapshot { + paneCount: number + webglPanes: number + domPanes: number + unknownModePanes: number + writeCount: number + /** Sum of unfinished `term.write` calls across panes. */ + writeInflight: number + /** Sum of characters queued inside xterm across panes (string length). */ + queuedChars: number + /** Sum of `WriteBatcher.pendingChars` across panes (string length). */ + batcherPendingChars: number + ackMsMax: number + ackMsLast: number + enqueueMsMax: number + fitCount: number + lastFitPaneId: string + lastFit: FitSample | null + lastFitAgeMs: number + webglContextLosses: number +} + +const nowMs = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()) + +// ---- per-pane registry ----------------------------------------------------- + +/** Opaque per-bind id → that bind's counters. Cleared on unmount/rebind. */ +const panes = new Map() + +function emptyStats(paneId: string, rendererMode: PaneRendererMode, registeredAt: number): PaneStats { + return { + paneId, + rendererMode, + registeredAt, + pendingChars: 0, + writes: { + count: 0, + inflight: 0, + queuedChars: 0, + enqueueMsLast: 0, + enqueueMsMax: 0, + ackMsLast: 0, + ackMsMax: 0 + }, + fits: { count: 0, last: null }, + webgl: { initCount: 0, initFailedCount: 0, contextLossCount: 0, disposeCount: 0 } + } +} + +/** + * Register one pane bind and return its live counters. + * + * The id must be unique per *bind*, not per React component: a rebind tears the + * terminal down while the pane lives on, and a write callback still in flight + * from the old terminal must land on a dead entry instead of the new pane's. + */ +export function registerPane( + paneId: string, + init: { + rendererMode?: PaneRendererMode + registeredAt?: number + pendingCharsProbe?: () => number | undefined + } = {} +): PaneStats { + const stats = emptyStats(paneId, init.rendererMode ?? 'unknown', init.registeredAt ?? nowMs()) + if (init.pendingCharsProbe !== undefined) stats.pendingCharsProbe = init.pendingCharsProbe + panes.set(paneId, stats) + return stats +} + +/** Drop a pane's counters (unmount / rebind). Later notes for it are ignored. */ +export function unregisterPane(paneId: string): boolean { + return panes.delete(paneId) +} + +export function getPaneStats(paneId: string): PaneStats | undefined { + return panes.get(paneId) +} + +export function registeredPaneCount(): number { + return panes.size +} + +/** Test/teardown helper: forget every pane. */ +export function clearPanes(): void { + panes.clear() +} + +/** Record a finished fit. Ignored for an unregistered (stale) pane id. */ +export function noteFit(paneId: string, sample: FitSample): void { + const stats = panes.get(paneId) + if (stats === undefined) return + stats.fits.count += 1 + stats.fits.last = sample +} + +/** Record a `term.write` that has just been handed to xterm. */ +export function noteWriteStart(paneId: string, chars: number): void { + const stats = panes.get(paneId) + if (stats === undefined) return + const w = stats.writes + w.count += 1 + w.inflight += 1 + w.queuedChars += Math.max(0, chars) +} + +/** + * How long the `term.write(...)` call itself took (enqueue cost, not parsing). + * + * Separate from `noteWriteStart` because xterm may invoke the parser callback + * synchronously for trivial input: the enqueue duration is only known after the + * call returns, while the completion has to be bookkept before it can be + * measured. Keeping them apart means a synchronous callback cannot drive + * `inflight` negative or lose its queued-character decrement. + */ +export function noteWriteEnqueue(paneId: string, enqueueMs: number): void { + const stats = panes.get(paneId) + if (stats === undefined) return + const w = stats.writes + w.enqueueMsLast = enqueueMs + if (enqueueMs > w.enqueueMsMax) w.enqueueMsMax = enqueueMs +} + +/** + * Record xterm's parser callback for one write: consumption latency plus the + * dequeue. Clamped at zero because a (pathological) synchronous callback would + * otherwise drive the counters negative. + */ +export function noteWriteDone(paneId: string, chars: number, ackMs: number): void { + const stats = panes.get(paneId) + if (stats === undefined) return + const w = stats.writes + w.inflight = Math.max(0, w.inflight - 1) + w.queuedChars = Math.max(0, w.queuedChars - Math.max(0, chars)) + w.ackMsLast = ackMs + if (ackMs > w.ackMsMax) w.ackMsMax = ackMs +} + +export function notePaneRenderer(paneId: string, mode: PaneRendererMode): void { + const stats = panes.get(paneId) + if (stats === undefined) return + stats.rendererMode = mode +} + +export function noteWebgl(paneId: string, event: WebglEvent): void { + const stats = panes.get(paneId) + if (stats === undefined) return + const w = stats.webgl + if (event === 'init') w.initCount += 1 + else if (event === 'init-failed') w.initFailedCount += 1 + else if (event === 'context-loss') w.contextLossCount += 1 + else w.disposeCount += 1 +} + +/** + * One read-only aggregate for the watchdog report. Probes are called defensively + * — a probe belongs to a pane that may be mid-teardown. + */ +export function snapshotPanes(now: number = nowMs()): PanesSnapshot { + const snap: PanesSnapshot = { + paneCount: panes.size, + webglPanes: 0, + domPanes: 0, + unknownModePanes: 0, + writeCount: 0, + writeInflight: 0, + queuedChars: 0, + batcherPendingChars: 0, + ackMsMax: 0, + ackMsLast: 0, + enqueueMsMax: 0, + fitCount: 0, + lastFitPaneId: '', + lastFit: null, + lastFitAgeMs: 0, + webglContextLosses: 0 + } + let lastFitStartedAt = -Infinity + for (const stats of panes.values()) { + if (stats.rendererMode === 'webgl') snap.webglPanes += 1 + else if (stats.rendererMode === 'dom') snap.domPanes += 1 + else snap.unknownModePanes += 1 + snap.writeCount += stats.writes.count + snap.writeInflight += stats.writes.inflight + snap.queuedChars += stats.writes.queuedChars + snap.enqueueMsMax = Math.max(snap.enqueueMsMax, stats.writes.enqueueMsMax) + snap.ackMsMax = Math.max(snap.ackMsMax, stats.writes.ackMsMax) + snap.ackMsLast = Math.max(snap.ackMsLast, stats.writes.ackMsLast) + snap.fitCount += stats.fits.count + snap.webglContextLosses += stats.webgl.contextLossCount + if (stats.pendingCharsProbe !== undefined) { + try { + const pending = stats.pendingCharsProbe() + stats.pendingChars = typeof pending === 'number' && pending > 0 ? pending : 0 + } catch { + stats.pendingChars = 0 + } + } + snap.batcherPendingChars += stats.pendingChars + const last = stats.fits.last + if (last !== null && last.startedAt > lastFitStartedAt) { + lastFitStartedAt = last.startedAt + snap.lastFitPaneId = stats.paneId + snap.lastFit = last + snap.lastFitAgeMs = Math.max(0, now - last.startedAt) + } + } + return snap +} + +// ---- long tasks & watchdog classification ---------------------------------- + +export interface LongTaskEntry { + /** `performance.now()` origin, as PerformanceEntry.startTime. */ + startTime: number + duration: number +} + +/** Fixed-size FIFO of recent long tasks (newest last). */ +export class LongTaskRing { + private readonly items: LongTaskEntry[] = [] + private readonly capacity: number + + constructor(capacity: number = MAX_LONG_TASKS) { + this.capacity = capacity > 0 ? capacity : MAX_LONG_TASKS + } + + push(entry: LongTaskEntry): void { + this.items.push({ startTime: entry.startTime, duration: entry.duration }) + while (this.items.length > this.capacity) this.items.shift() + } + + get size(): number { + return this.items.length + } + + /** Copy of the buffer, oldest first — never the live array. */ + all(): LongTaskEntry[] { + return this.items.slice() + } + + latest(): LongTaskEntry | null { + return this.items.length > 0 ? this.items[this.items.length - 1] : null + } + + clear(): void { + this.items.length = 0 + } +} + +export interface LongTaskOverlap { + count: number + /** Overlap of every matching task with the window, summed and clamped. */ + overlapMs: number + /** Longest overlapping task's full duration (the real long-task size). */ + maxTaskMs: number + firstStart: number | null + lastEnd: number | null +} + +/** + * Long tasks intersecting `[start, end]`. A task that began before the window or + * ends after it contributes only its clamped overlap, so a tick that catches the + * tail of a long task still counts. + */ +export function overlapLongTasks( + tasks: readonly LongTaskEntry[], + start: number, + end: number +): LongTaskOverlap { + const out: LongTaskOverlap = { count: 0, overlapMs: 0, maxTaskMs: 0, firstStart: null, lastEnd: null } + if (end <= start) return out + for (const task of tasks) { + const taskEnd = task.startTime + task.duration + const from = Math.max(task.startTime, start) + const to = Math.min(taskEnd, end) + if (to <= from) continue + out.count += 1 + out.overlapMs += to - from + if (task.duration > out.maxTaskMs) out.maxTaskMs = task.duration + if (out.firstStart === null || task.startTime < out.firstStart) out.firstStart = task.startTime + if (out.lastEnd === null || taskEnd > out.lastEnd) out.lastEnd = taskEnd + } + return out +} + +/** + * What the missed timer ticks actually were. + * + * The old watchdog called every late tick a stall, which conflated a real + * main-thread long task with the browser throttling the timer (system sleep, + * hidden page, OS scheduling). Only an observed long task may be called a long + * task; everything else is reported as `scheduler-delay` with the unexplained + * part spelled out. + */ +export type WatchdogKind = 'renderer-long-task' | 'scheduler-delay' + +export interface WatchdogVerdict { + kind: WatchdogKind + /** Missed time: elapsed minus the watchdog interval, i.e. the old "lag". */ + lagMs: number + windowStart: number + windowEnd: number + taskCount: number + overlapMs: number + /** Lag not covered by any observed long task. */ + uncoveredMs: number + maxTaskMs: number + firstTaskStart: number | null + lastTaskEnd: number | null +} + +export interface WatchdogInput { + lagMs: number + windowStart: number + windowEnd: number + longTasks?: readonly LongTaskEntry[] + minLagMs?: number +} + +/** Null when the lag is below the threshold (nothing worth logging). */ +export function classifyWatchdogLag(input: WatchdogInput): WatchdogVerdict | null { + const minLagMs = input.minLagMs ?? WATCHDOG_LAG_LOG_MS + if (!(input.lagMs > minLagMs)) return null + const overlap = overlapLongTasks(input.longTasks ?? [], input.windowStart, input.windowEnd) + return { + kind: overlap.count > 0 ? 'renderer-long-task' : 'scheduler-delay', + lagMs: input.lagMs, + windowStart: input.windowStart, + windowEnd: input.windowEnd, + taskCount: overlap.count, + overlapMs: overlap.overlapMs, + uncoveredMs: Math.max(0, input.lagMs - overlap.overlapMs), + maxTaskMs: overlap.maxTaskMs, + firstTaskStart: overlap.firstStart, + lastTaskEnd: overlap.lastEnd + } +} + +// ---- log rate limiters ----------------------------------------------------- + +/** "At most one line per `intervalMs`, carrying the count it swallowed". */ +export interface IntervalGate { + /** Monotonic timestamp of the last emitted line; <= 0 means "none yet". */ + lastEmitAt: number + suppressed: number +} + +export function newIntervalGate(): IntervalGate { + return { lastEmitAt: 0, suppressed: 0 } +} + +export interface IntervalLogDecision { + emit: boolean + /** Lines swallowed since the previous emit — the `suppressed=N` trailer. */ + suppressed: number + next: IntervalGate +} + +/** Pure: returns the gate to store, never mutates the one passed in. */ +export function intervalLogDecision( + gate: IntervalGate, + now: number, + intervalMs: number +): IntervalLogDecision { + if (gate.lastEmitAt <= 0 || now - gate.lastEmitAt >= intervalMs) { + return { emit: true, suppressed: gate.suppressed, next: { lastEmitAt: now, suppressed: 0 } } + } + return { emit: false, suppressed: 0, next: { lastEmitAt: gate.lastEmitAt, suppressed: gate.suppressed + 1 } } +} + +/** Sliding-budget gate for the global PERF line cap. */ +export interface PerfLogGate { + windowStart: number + count: number + suppressed: number +} + +export function newPerfLogGate(): PerfLogGate { + return { windowStart: 0, count: 0, suppressed: 0 } +} + +export interface PerfLogDecision { + emit: boolean + suppressed: number + next: PerfLogGate +} + +/** + * At most `max` lines per `windowMs`, EXCEPT urgent lines (a severe watchdog + * report), which always pass: they are rare by construction and are the reason + * the budget exists. Urgent lines still count against the window, so a burst of + * them cannot hide how much normal traffic was dropped. + */ +export function perfLogDecision( + gate: PerfLogGate, + now: number, + opts: { urgent?: boolean; windowMs?: number; max?: number } = {} +): PerfLogDecision { + const windowMs = opts.windowMs ?? PERF_LOG_WINDOW_MS + const max = opts.max ?? PERF_LOG_MAX_PER_WINDOW + const rolled = gate.windowStart <= 0 || now - gate.windowStart >= windowMs + const windowStart = rolled ? now : gate.windowStart + const count = rolled ? 0 : gate.count + const suppressed = rolled ? 0 : gate.suppressed + if (opts.urgent === true) { + return { emit: true, suppressed, next: { windowStart, count: count + 1, suppressed } } + } + if (count >= max) { + return { + emit: false, + suppressed: suppressed + 1, + next: { windowStart, count, suppressed: suppressed + 1 } + } + } + return { emit: true, suppressed, next: { windowStart, count: count + 1, suppressed } } +} + +// ---- formatting ------------------------------------------------------------ + +const KIND_LABEL: Record = { + 'renderer-long-task': 'renderer long task', + 'scheduler-delay': 'scheduler delay/unclassified' +} + +const WEBGL_LABEL: Record = { + init: 'webgl-init', + 'init-failed': 'webgl-init-failed', + 'context-loss': 'webgl-context-loss', + dispose: 'webgl-dispose' +} + +/** First 6 chars of a pane id: enough to correlate lines, short enough to read. */ +export function shortPaneId(paneId: string): string { + const id = paneId.trim() + if (id === '') return '?' + return id.length <= 6 ? id : id.slice(0, 6) +} + +/** One-line, length-capped detail text (never a terminal buffer, but be safe). */ +export function sanitizePerfDetail(text: string, max = 80): string { + const flat = text.replace(/[\r\n\t]+/g, ' ').trim() + return flat.length > max ? `${flat.slice(0, max)}...` : flat +} + +const pad2 = (n: number): string => String(n).padStart(2, '0') + +/** Local wall clock with milliseconds — log lines must be readable next to them. */ +export function clockLabel(epochMs: number): string { + const d = new Date(epochMs) + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${String( + d.getMilliseconds() + ).padStart(3, '0')}` +} + +const round = (n: number): number => Math.round(n) + +/** `PERF fit pane 3f2a1c trigger=resize 240ms 120x40 bufferLines=5120 ...` */ +export function formatFitReport(sample: FitSample, suppressed: number, paneId = ''): string { + const pane = paneId === '' ? '' : ` pane ${shortPaneId(paneId)}` + const tail = suppressed > 0 ? ` suppressed=${suppressed}` : '' + return ( + `PERF fit${pane} trigger=${sample.trigger} ${round(sample.durationMs)}ms ` + + `${sample.cols}x${sample.rows} bufferLines=${sample.bufferLines} ` + + `scrollback=${sample.scrollback} started=${round(sample.startedAt)}ms${tail}` + ) +} + +/** `PERF renderer webgl-context-loss pane 3f2a1c mode=webgl` */ +export function formatWebglReport( + event: WebglEvent, + paneId: string, + mode: PaneRendererMode, + detail?: string +): string { + const extra = detail === undefined || detail === '' ? '' : ` detail=${sanitizePerfDetail(detail)}` + return `PERF renderer ${WEBGL_LABEL[event]} pane ${shortPaneId(paneId)} mode=${mode}${extra}` +} + +/** `PERF write xterm ack 1450ms pane 3f2a1c inflight=2 queuedChars=9216` */ +export function formatSlowWriteReport( + paneId: string, + ackMs: number, + inflight: number, + queuedChars: number +): string { + return ( + `PERF write xterm ack ${round(ackMs)}ms pane ${shortPaneId(paneId)} ` + + `inflight=${inflight} queuedChars=${queuedChars}` + ) +} + +export interface WatchdogReportContext { + /** `performance.now()` at report time (the renderer already recovered). */ + now: number + /** `Date.now()` at report time, for the wall clock labels. */ + wallNow: number + /** Newest entries first or last, either is fine; only the latest is used. */ + recentLongTasks?: readonly LongTaskEntry[] +} + +/** + * The one line the watchdog writes. Every field is metadata; the timestamps are + * the watchdog's *estimate* of when the freeze happened, reconstructed after the + * fact — the line is written during recovery, so the log's own timestamp is the + * end of the incident, not its start. + */ +export function formatWatchdogReport( + verdict: WatchdogVerdict, + snap: PanesSnapshot, + ctx: WatchdogReportContext +): string { + const windowStartWall = ctx.wallNow - (ctx.now - verdict.windowStart) + const windowEndWall = ctx.wallNow - (ctx.now - verdict.windowEnd) + const estStart = verdict.firstTaskStart ?? verdict.windowStart + const estEnd = verdict.lastTaskEnd ?? verdict.windowEnd + const parts: string[] = [] + parts.push( + `main thread lag ~${round(verdict.lagMs)}ms: ${KIND_LABEL[verdict.kind]}` + ) + parts.push( + `window ${clockLabel(windowStartWall)}-${clockLabel(windowEndWall)} ` + + `(est ${clockLabel(ctx.wallNow - (ctx.now - estStart))}-${clockLabel( + ctx.wallNow - (ctx.now - estEnd) + )})` + ) + if (verdict.taskCount > 0) { + parts.push( + `longtasks ${verdict.taskCount} overlap ${round(verdict.overlapMs)}ms ` + + `uncovered ${round(verdict.uncoveredMs)}ms latest ${round(verdict.maxTaskMs)}ms` + ) + } else { + const recent = ctx.recentLongTasks + const newest = recent !== undefined && recent.length > 0 ? recent[recent.length - 1] : null + parts.push( + `no longtask in window (${newest === null + ? 'none observed' + : `newest ${round(newest.duration)}ms @${round(newest.startTime)}ms`})` + ) + } + const modes = `webgl ${snap.webglPanes}, dom ${snap.domPanes}` + const unknown = snap.unknownModePanes > 0 ? `, unknown ${snap.unknownModePanes}` : '' + parts.push(`panes ${snap.paneCount} (${modes}${unknown}) renderer ${dominantMode(snap)}`) + const fit = snap.lastFit + if (fit === null) { + parts.push('no fit recorded') + } else { + parts.push( + `last fit pane ${shortPaneId(snap.lastFitPaneId)} trigger=${fit.trigger} ` + + `${round(fit.durationMs)}ms ${round(snap.lastFitAgeMs)}ms ago ` + + `${fit.cols}x${fit.rows} bufferLines=${fit.bufferLines} scrollback=${fit.scrollback}` + ) + } + parts.push( + `xterm writes ${snap.writeCount} inflight ${snap.writeInflight} ` + + `queuedChars ${snap.queuedChars} ackMax ${round(snap.ackMsMax)}ms ` + + `enqueueMax ${round(snap.enqueueMsMax)}ms` + ) + parts.push(`batcher pendingChars ${snap.batcherPendingChars}`) + parts.push( + `logged ${clockLabel(ctx.wallNow)} after recovery (log time != fault start)` + ) + return parts.join(' | ') +} + +/** Which renderer most panes are on — the closest thing to a global mode. */ +function dominantMode(snap: PanesSnapshot): PaneRendererMode { + if (snap.paneCount === 0) return 'unknown' + if (snap.webglPanes >= snap.domPanes) return snap.webglPanes > 0 ? 'webgl' : 'unknown' + return 'dom' +} + +// ---- shared emit path (one global budget for every producer) --------------- + +export interface PerfLogContext { + urgent: boolean + /** PERF lines this emit dropped from the budget since the previous accept. */ + suppressed: number +} + +export type PerfLogSink = (line: string, ctx: PerfLogContext) => void + +let sink: PerfLogSink | null = null +let globalGate: PerfLogGate = newPerfLogGate() + +/** + * Install the disk sink once, at renderer entry. The sink owns the + * `settings.system.diagnosticLog` check; this module never reads settings. + */ +export function setPerfLogSink(next: PerfLogSink | null): void { + sink = next +} + +/** Test helper: forget the sink and the budget. */ +export function resetPerfLogSink(): void { + sink = null + globalGate = newPerfLogGate() +} + +export function currentPerfLogGate(): PerfLogGate { + return globalGate +} + +/** + * Emit one PERF line through the shared minute budget. Returns whether the sink + * actually ran. `urgent` (severe watchdog reports) always passes the budget; + * everything else is dropped silently once the 5/minute cap is spent, and the + * next accepted line reports how many were dropped as `suppressed=N`. + */ +export function emitPerfLog( + line: string, + opts: { urgent?: boolean; now?: number } = {} +): boolean { + const now = opts.now ?? nowMs() + const decision = perfLogDecision(globalGate, now, { urgent: opts.urgent === true }) + globalGate = decision.next + if (!decision.emit || sink === null) return false + const text = decision.suppressed > 0 ? `${line} suppressed=${decision.suppressed}` : line + try { + sink(text, { urgent: opts.urgent === true, suppressed: decision.suppressed }) + } catch { + /* diagnostics must never break the caller */ + } + return true +} diff --git a/src/renderer/src/terminal/writeBatcher.ts b/src/renderer/src/terminal/writeBatcher.ts index e08c612..572dd57 100644 --- a/src/renderer/src/terminal/writeBatcher.ts +++ b/src/renderer/src/terminal/writeBatcher.ts @@ -29,6 +29,17 @@ export class WriteBatcher { return this.buf.length > 0 } + /** + * Characters still buffered for the next flush — a JS string length (UTF-16 + * code units), NOT a byte count: the highlight path hands over decoded text, + * and one CJK ideograph is 1 here but 3 bytes on the wire. Diagnostics read + * this to tell "the batcher is holding output" apart from "xterm is behind"; + * callers must not treat it as a transport metric. + */ + get pendingChars(): number { + return this.buf.length + } + /** Append `text`; the first push in a window arms the single flush timer. */ push(text: string): void { if (!text) return diff --git a/src/shared/api.ts b/src/shared/api.ts index 6ac85b8..3e53870 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -12,6 +12,7 @@ import type { import type { AppSettings } from './settings' import type { ReleaseNote, UpdateState } from './ipc' import type { LockOperationResult, LockPasswordInput, LockSettingsState } from './ipc' +import type { RuleValidationReport, RuleValidationRequest, RuleValidationState } from './ipc' import type { HostKeyAction, HostKeyPromptEvent, @@ -125,6 +126,17 @@ export interface AppApi { // ---- fonts ---- listFonts(): Promise + // ---- rule validation (ReDoS probe gate; the main process owns the Worker) ---- + /** + * Probe a batch of draft rules (editor / import dialog). Verdicts come back + * per item and are NOT persisted: a proof becomes durable only when the rule + * itself is saved, which runs the background pass over stored rules. + */ + validateRules(request: RuleValidationRequest): Promise + /** read the validator's aggregate state (pending/quarantine counts, worker health) */ + getRuleValidationState(): Promise + onRuleValidationState(cb: (state: RuleValidationState) => void): () => void + // ---- layout templates ---- listLayouts(): Promise getLayout(id: string): Promise diff --git a/src/shared/highlightIO.ts b/src/shared/highlightIO.ts index 0e6b84c..15d401c 100644 --- a/src/shared/highlightIO.ts +++ b/src/shared/highlightIO.ts @@ -9,7 +9,8 @@ import { isHighlightCategory, type HighlightRule } from './settings' * importing nonsense. Validation here stays deliberately light: past that * envelope it only rejects what cannot be a rule at all (no `rules` array, an * unusable regex). Everything else is repaired by the settings store on save, - * which already owns that job. + * which already owns that job. The internal `validation` proof travels in + * neither direction: stripped on export, reset on import. */ export const HIGHLIGHT_FILE_KIND = 'openterminal.highlight-rules' @@ -37,13 +38,17 @@ export interface ParsedHighlightFile { error?: ImportError } -/** Serialize a rule set into the shareable envelope. */ +/** Serialize a rule set into the shareable envelope. + * + * Proofs are stripped: `validation` is internal metadata about this install's + * probe run, and a file meant to be shared or archived must not carry what + * reads like a trust credential. The reader re-proves every rule anyway. */ export function exportHighlightRules(rules: HighlightRule[]): string { const file: HighlightFile = { kind: HIGHLIGHT_FILE_KIND, version: HIGHLIGHT_FILE_VERSION, exportedAt: new Date().toISOString(), - rules + rules: rules.map(({ validation: _proof, ...rule }) => rule) } return JSON.stringify(file, null, 2) } @@ -93,7 +98,11 @@ export function parseHighlightRules(text: string): ParsedHighlightFile { return { rules, warnings } } -/** Keep the fields we understand; the store repairs the rest on save. */ +/** Keep the fields we understand; the store repairs the rest on save. + * + * `validation` is deliberately NOT among them: a proof is only meaningful for + * the pattern it was obtained for, in the install that obtained it, so an + * imported rule always starts unproven and is re-checked by the validator. */ function coerceImported(value: unknown, index: number, warnings: string[]): HighlightRule | null { if (value === null || typeof value !== 'object') { warnings.push(`#${index + 1}: not an object`) diff --git a/src/shared/i18n/dicts/en/settings.ts b/src/shared/i18n/dicts/en/settings.ts index 1883614..b4cc2ce 100644 --- a/src/shared/i18n/dicts/en/settings.ts +++ b/src/shared/i18n/dicts/en/settings.ts @@ -295,6 +295,25 @@ const settings: Record = { 'settings.highlight.linkTemplate': 'Link template', 'settings.highlight.linkTemplateHint': '$0 is the whole match, $1–$9 the capture groups, $$ a literal $; the template must expand to an http:// or https:// link', 'settings.highlight.linkInvalidTemplate': 'The template must expand to an http:// or https:// link', + 'settings.highlight.validation': 'Probe check', + 'settings.highlight.state.builtin': 'Built-in', + 'settings.highlight.state.passed': 'Passed current probes', + 'settings.highlight.state.pending': 'Pending check', + 'settings.highlight.state.quarantined': 'Quarantined', + 'settings.highlight.state.builtinHint': + 'Factory preset: shipped with the app and proved by the test suite, so it is not probed individually', + 'settings.highlight.state.passedHint': + 'Finished every fixed probe inside the budget on this app / Electron / V8 build. That means "passed the current probes", not "safe for any input"', + 'settings.highlight.state.pendingHint': 'No probe result for this environment yet: the rule is kept, but it does not run', + 'settings.highlight.state.quarantinedHint': + 'Exceeded the probe time budget: the rule is kept for editing, but it does not run', + 'settings.highlight.validating': 'Checking…', + 'settings.highlight.probeFailed': 'The check service did not answer; the rule stays pending', + 'settings.highlight.validatorUnavailable': + 'Rule checking is unavailable (the worker did not start): custom highlight and link rules are paused, built-in presets are unaffected', + 'settings.highlight.quarantinedWarning': '{n} rule(s) failed the probes and are quarantined: kept, but never executed', + 'settings.highlight.pendingPreview': 'This pattern has not passed the check yet, so the preview does not run', + 'settings.highlight.importValidated': 'Probe check: {passed} passed, {pending} pending, {quarantined} quarantined', 'settings.highlight.linkPattern': 'Pattern', 'settings.highlight.linkEnabled': 'Enabled', 'settings.highlight.actions': 'Actions', diff --git a/src/shared/i18n/dicts/ja/settings.ts b/src/shared/i18n/dicts/ja/settings.ts index dbc9ec4..5ccaf3b 100644 --- a/src/shared/i18n/dicts/ja/settings.ts +++ b/src/shared/i18n/dicts/ja/settings.ts @@ -290,6 +290,24 @@ const settings: Record = { 'settings.highlight.linkTemplate': 'リンクテンプレート', 'settings.highlight.linkTemplateHint': '$0 は一致全体、$1–$9 はキャプチャーグループ、$$ はリテラルの $。テンプレートは http:// または https:// で始まる URL に展開される必要があります', 'settings.highlight.linkInvalidTemplate': 'テンプレートは http:// または https:// で始まる URL に展開される必要があります', + 'settings.highlight.validation': '検証', + 'settings.highlight.state.builtin': '組み込み', + 'settings.highlight.state.passed': '現在の探針に合格', + 'settings.highlight.state.pending': '検証待ち', + 'settings.highlight.state.quarantined': '隔離済み', + 'settings.highlight.state.builtinHint': + '組み込みプリセット:アプリに同梱されテストスイートで検証済みのため、個別の探針は行いません', + 'settings.highlight.state.passedHint': + '現在のアプリ / Electron / V8 環境で固定探針をすべて予算内で完了しています。これは「現在の探針に合格」を意味し、あらゆる入力に対して安全という意味ではありません', + 'settings.highlight.state.pendingHint': 'この環境での探針結果がまだありません。ルールは保持されますが実行されません', + 'settings.highlight.state.quarantinedHint': '探針の時間予算を超えました。ルールは編集用に保持されますが実行されません', + 'settings.highlight.validating': '検証中…', + 'settings.highlight.probeFailed': '検証サービスが応答しません。ルールは検証待ちのままです', + 'settings.highlight.validatorUnavailable': + 'ルール検証サービスを利用できません(Worker が起動しませんでした)。カスタムのハイライト/リンクルールは停止中です(組み込みプリセットは影響を受けません)', + 'settings.highlight.quarantinedWarning': '{n} 件のルールが探針に合格せず隔離されました。保持されますが実行されません', + 'settings.highlight.pendingPreview': 'この正規表現はまだ検証に合格していないため、プレビューは実行しません', + 'settings.highlight.importValidated': '探針検証:合格 {passed}、検証待ち {pending}、隔離 {quarantined}', 'settings.highlight.linkPattern': '正規表現', 'settings.highlight.linkEnabled': '有効', 'settings.highlight.actions': '操作', diff --git a/src/shared/i18n/dicts/zh-CN/settings.ts b/src/shared/i18n/dicts/zh-CN/settings.ts index 4d643dd..5594be0 100644 --- a/src/shared/i18n/dicts/zh-CN/settings.ts +++ b/src/shared/i18n/dicts/zh-CN/settings.ts @@ -277,6 +277,23 @@ const settings: Record = { 'settings.highlight.linkTemplate': '链接模板', 'settings.highlight.linkTemplateHint': '$0 为整段匹配,$1–$9 为第 1–9 个分组,$$ 为字面 $;模板必须以 http:// 或 https:// 开头才会生效', 'settings.highlight.linkInvalidTemplate': '模板必须展开成 http:// 或 https:// 开头的链接', + 'settings.highlight.validation': '校验', + 'settings.highlight.state.builtin': '内置', + 'settings.highlight.state.passed': '已通过当前探针', + 'settings.highlight.state.pending': '待校验', + 'settings.highlight.state.quarantined': '已隔离', + 'settings.highlight.state.builtinHint': '出厂预设:随应用发布并由测试套件验证,不逐条探测', + 'settings.highlight.state.passedHint': + '已在当前应用 / Electron / V8 环境下跑完全部固定探针且都在预算内;这只表示「已通过当前探针」,不代表对任意输入都安全', + 'settings.highlight.state.pendingHint': '还没有当前环境下的探针结果:规则保留,但不会执行', + 'settings.highlight.state.quarantinedHint': '跑探针时超出时间预算:规则保留可编辑,但不会执行', + 'settings.highlight.validating': '正在校验…', + 'settings.highlight.probeFailed': '校验服务没有响应,规则保持待校验', + 'settings.highlight.validatorUnavailable': + '规则校验服务不可用(Worker 未能启动):自定义高亮与链接规则已暂停执行,内置预设不受影响', + 'settings.highlight.quarantinedWarning': '有 {n} 条规则未通过探针,已隔离:规则保留,但不会执行', + 'settings.highlight.pendingPreview': '该正则尚未通过校验,暂不执行预览', + 'settings.highlight.importValidated': '探针校验:通过 {passed},待校验 {pending},隔离 {quarantined}', 'settings.highlight.linkPattern': '正则', 'settings.highlight.linkEnabled': '启用', 'settings.highlight.actions': '操作', diff --git a/src/shared/i18n/dicts/zh-TW/settings.ts b/src/shared/i18n/dicts/zh-TW/settings.ts index b31b510..d5c95b8 100644 --- a/src/shared/i18n/dicts/zh-TW/settings.ts +++ b/src/shared/i18n/dicts/zh-TW/settings.ts @@ -277,6 +277,23 @@ const settings: Record = { 'settings.highlight.linkTemplate': '連結模板', 'settings.highlight.linkTemplateHint': '$0 為整段比對,$1–$9 為第 1–9 個分組,$$ 為字面 $;模板必須展開成 http:// 或 https:// 開頭的連結', 'settings.highlight.linkInvalidTemplate': '模板必須展開成 http:// 或 https:// 開頭的連結', + 'settings.highlight.validation': '驗證', + 'settings.highlight.state.builtin': '內建', + 'settings.highlight.state.passed': '已通過目前探針', + 'settings.highlight.state.pending': '待驗證', + 'settings.highlight.state.quarantined': '已隔離', + 'settings.highlight.state.builtinHint': '出廠預設:隨應用發布並由測試套件驗證,不逐條探測', + 'settings.highlight.state.passedHint': + '已在目前應用程式 / Electron / V8 環境下跑完全部固定探針且都在預算內;這只表示「已通過目前探針」,不代表對任意輸入都安全', + 'settings.highlight.state.pendingHint': '還沒有目前環境下的探針結果:規則保留,但不會執行', + 'settings.highlight.state.quarantinedHint': '跑探針時超出時間預算:規則保留可編輯,但不會執行', + 'settings.highlight.validating': '正在驗證…', + 'settings.highlight.probeFailed': '驗證服務沒有回應,規則維持待驗證', + 'settings.highlight.validatorUnavailable': + '規則驗證服務無法使用(Worker 未能啟動):自訂高亮與連結規則已暫停執行,內建預設不受影響', + 'settings.highlight.quarantinedWarning': '有 {n} 條規則未通過探針,已隔離:規則保留,但不會執行', + 'settings.highlight.pendingPreview': '此正則尚未通過驗證,暫不執行預覽', + 'settings.highlight.importValidated': '探針驗證:通過 {passed},待驗證 {pending},隔離 {quarantined}', 'settings.highlight.linkPattern': '正則', 'settings.highlight.linkEnabled': '啟用', 'settings.highlight.actions': '操作', diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 3ae672f..e9b78a4 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -3,6 +3,8 @@ * DO NOT rename channels or change payload shapes without updating all consumers. */ +import type { RuleRunState } from './ruleValidation' + export const Ipc = { // ---- PTY session lifecycle ---- // NOTE: since M2 these channels are session-generic: `id` addresses both @@ -22,6 +24,14 @@ export const Ipc = { /** main -> renderer broadcast after any save */ SETTINGS_CHANGED: 'settings:changed', + // ---- rule validation (ReDoS probe gate; the main process owns the Worker) ---- + /** renderer -> main: probe a batch of draft rules (editor / import dialog) */ + RULE_VALIDATE: 'rules:validate', + /** renderer pulls the validator's aggregate state without waiting for a change */ + RULE_VALIDATION_STATE_GET: 'rules:validationStateGet', + /** main -> renderer broadcast: RuleValidationState */ + RULE_VALIDATION_STATE: 'rules:validationState', + // ---- fonts ---- FONTS_LIST: 'fonts:list', @@ -239,6 +249,75 @@ export interface AppInfo { platform: NodeJS.Platform | string appVersion: string homeDir: string + /** + * The environment fingerprint the rule-validation gate compares proofs + * against (`shared/ruleValidation.ts`). The renderer cannot read the app, + * Electron and V8 versions itself, so it is handed the exact string the main + * process installed rather than deriving a second one that could differ. + */ + runtimeKey: string +} + +// ---- rule validation (ReDoS probe gate) ---- +/** Which rule family a batch belongs to: it decides the flags probed under. */ +export type RuleValidationKind = 'highlight' | 'link' + +/** + * One draft rule to probe. `id` is the caller's own handle, echoed back in the + * report — the validator knows nothing about rule storage. + */ +export interface RuleValidationRequestItem { + id: string + pattern: string + /** highlight rules only: link rules always compile with `g` */ + caseInsensitive?: boolean + lineAnchor?: boolean +} + +export interface RuleValidationRequest { + kind: RuleValidationKind + items: RuleValidationRequestItem[] +} + +/** One rule's standing after a batch. */ +export interface RuleValidationOutcome { + id: string + /** `builtin` = a factory preset, exempt from probing; `pending` = no verdict */ + state: RuleRunState + /** slowest probe in ms, when a run actually completed */ + ms?: number +} + +export interface RuleValidationReport { + /** the fingerprint the verdicts belong to; `''` when nothing was installed */ + runtime: string + probeVersion: number + /** + * false = a worker creation was ATTEMPTED and failed (or the factory is + * missing), so every non-builtin rule is pending. Not merely "no worker has + * been needed yet": a default install with no custom rules reports `true`. + */ + workerAvailable: boolean + /** the request carried more items than one batch accepts; the tail was dropped */ + truncated: boolean + results: RuleValidationOutcome[] +} + +/** + * What the validator is doing overall — the settings page shows a warning from + * this rather than from a per-rule verdict, because "no worker could be built" + * is a state of the whole feature. `workerAvailable` is deliberately not + * "has a worker been started": the pool is lazy, and reporting the normal + * no-custom-rules install as unavailable would be a false alarm. + */ +export interface RuleValidationState { + runtime: string + probeVersion: number + workerAvailable: boolean + /** rules waiting for a verdict (no proof, or a proof from another environment) */ + pending: number + /** rules the validator quarantined: kept for editing, never executed */ + quarantined: number } // ---- updater ---- diff --git a/src/shared/linkRules.ts b/src/shared/linkRules.ts index b6a3f28..5d3e1a5 100644 --- a/src/shared/linkRules.ts +++ b/src/shared/linkRules.ts @@ -1,4 +1,14 @@ import type { LinkRule } from './settings' +// The explicit `.ts` extensions on the two imports below are deliberate: +// `tests/link-rules.mjs` loads this file directly through Node's type stripping, +// and Node's ESM resolver does not try extensions (measured: an extension-less +// specifier here fails with ERR_MODULE_NOT_FOUND). tsc raises TS5097 for the +// extension and nothing else, so the directive silences it — the import still +// resolves and stays fully typed. +// @ts-expect-error TS5097 — see above +import { LINK_RULE_FLAGS, MAX_PATTERN_LEN, ruleRunState, sanitizeRuleValidation, validationAllows, type RuleRunState } from './ruleValidation.ts' +// @ts-expect-error TS5097 — see above +import { PROBE_VERSION } from './redosProbes.ts' /** * User-defined clickable link rules (WezTerm `hyperlink_rules` in spirit). @@ -7,17 +17,24 @@ import type { LinkRule } from './settings' * match becomes an underlined range, and a Ctrl/Cmd+Click opens the template * expanded with the capture groups. This module is the whole engine minus the * xterm plumbing — validation, template expansion and the non-overlapping span - * merge — so it stays a zero-import pure module the Node test can load - * directly (`tests/link-rules.mjs`), and nothing here can touch a terminal. + * merge — so it stays a pure module the Node test can load directly + * (`tests/link-rules.mjs`), and nothing here can touch a terminal. * * Safety: `findRuleLinks` is the only producer of a rule-made target, and it * drops anything whose scheme is not http/https. A template is user input, and * `file://`, `javascript:` or an `ms-settings:` handler must never reach the * shell just because a pattern matched some output. + * + * The same fail-closed gate the highlight engine applies (`validationAllows`) + * runs in `compileLinkRules`: a user pattern executes only with a proof that + * matches it exactly. Link rules have NO built-in whitelist — none of them + * ships with the app, so there is nothing to exempt. */ -/** Longest pattern / template we accept; longer entries are dropped. */ -export const LINK_PATTERN_MAX = 500 +/** Longest pattern we accept; longer entries are dropped. One constant with + * the highlight rules and the engine (`MAX_PATTERN_LEN`), so a link pattern + * can never be admitted at a length the engine would refuse. */ +export const LINK_PATTERN_MAX = MAX_PATTERN_LEN export const LINK_TEMPLATE_MAX = 500 /** Note is a table caption, not documentation — same budget as a highlight rule. */ export const LINK_NOTE_MAX = 60 @@ -111,12 +128,19 @@ export function sanitizeLinkRules( } seen.add(id) const note = typeof candidate.note === 'string' ? candidate.note.trim() : '' + // Internal metadata, so only its SHAPE is checked on load; the compile gate + // re-checks it against the pattern, the flags and the probe version. + const validation = sanitizeRuleValidation(candidate.validation) + if (candidate.validation !== undefined && validation === undefined) { + warn?.(`linkRules[${index}].validation: malformed — dropped`) + } rules.push({ id, pattern, template, // Absent means on: a rule the user just created and did not switch off. enabled: candidate.enabled !== false, + ...(validation ? { validation } : {}), ...(note ? { note: note.slice(0, LINK_NOTE_MAX) } : {}) }) }) @@ -209,8 +233,15 @@ export function compileLinkRules(rules: readonly LinkRule[] | undefined): Compil for (const rule of rules) { if (rule.enabled === false) continue if (rule.pattern.trim() === '' || rule.template.trim() === '') continue + // Fail-closed admission, exactly like the highlight engine's `compileRules` + // (link rules pass no built-in whitelist: none of them ships with the app). + // Without a `passed` proof for this precise pattern + flag string + probe + // version the rule is inert — an imported set, a hand-edited settings.json, + // a proof that expired with a corpus update or a pattern edited after the + // fact all land here as "no proof", never as "probably fine". + if (!validationAllows(rule.pattern, LINK_RULE_FLAGS, rule.validation, PROBE_VERSION)) continue try { - out.push({ id: rule.id, regex: new RegExp(rule.pattern, 'g'), template: rule.template }) + out.push({ id: rule.id, regex: new RegExp(rule.pattern, LINK_RULE_FLAGS), template: rule.template }) } catch { // sanitizeLinkRules already dropped these; a hand-edited settings.json or // a rule list built in memory could still carry one. @@ -219,6 +250,16 @@ export function compileLinkRules(rules: readonly LinkRule[] | undefined): Compil return out } +/** + * How a link rule stands right now — the same derivation the highlight engine + * exposes for its own rules, and the label the settings table shows. Link rules + * have no built-in whitelist, so the states are `passed` / `pending` / + * `quarantined` only. + */ +export function linkRuleRunState(rule: LinkRule): RuleRunState { + return ruleRunState(rule.pattern, LINK_RULE_FLAGS, rule.validation, PROBE_VERSION) +} + /** * Every rule-made link on one logical line, in rule order (an earlier rule * claims its span first, like the highlight engine) and non-overlapping. diff --git a/src/shared/redosProbes.ts b/src/shared/redosProbes.ts new file mode 100644 index 0000000..2aadb6a --- /dev/null +++ b/src/shared/redosProbes.ts @@ -0,0 +1,122 @@ +/** + * ReDoS probe corpus: inputs built to punish a pattern that backtracks + * catastrophically. A stored rule carries a validation proof saying it ran + * through every one of these inside the budget; the proof's `probeVersion` is + * this module's `PROBE_VERSION`, so an app upgrade that revises the corpus + * invalidates every proof written against the old one. + * + * Zero imports on purpose: the probe set is data the test loads directly + * (Node's native type stripping), and the later-step executor (a budgeted + * Worker) bundles the same array — neither needs this module to reach out. + * + * What the corpus is NOT: it is not a regex parser and not a proof of safety. + * Nothing here reads a pattern, so a hostile shape nobody thought of passes — + * that is why the plan calls the result "passed the current probes", never + * "safe". The corpus only has to be hostile enough that the shapes we have + * *measured* as catastrophic blow their budget on it, while the shipped + * presets and ordinary rules stay far inside theirs. + */ + +/** Bump whenever the probe set below changes shape or content. */ +export const PROBE_VERSION = 1 + +/** Wall budget (ms) a rule gets against every probe before it is failed. */ +export const PROBE_BUDGET_MS = 500 + +/** Longest probe input a rule is ever executed against. */ +export const PROBE_MAX_LEN = 4096 + +export interface RedosProbe { + /** Which backtracking family the probe is built to hit. */ + shape: 'repeat' | 'failing-terminator' | 'alternation' | 'overlap-class' | 'literal-prefix' + /** The input handed to the rule's exec loop. */ + text: string +} + +/** + * Filler run helper. Probes are written at (or one character below) the length + * cap on purpose: the cost of a catastrophic shape grows exponentially with the + * input, so a corpus of short samples would let a hostile rule finish and look + * healthy. 4096 is also the engine's `MAX_LINE_LEN` — the longest line that can + * ever reach a user regex — so nothing here exceeds what a terminal could + * legitimately deliver. + */ +const CAP = PROBE_MAX_LEN +const run = (char: string, length: number = CAP): string => char.repeat(length) + +/** + * The corpus. Five families, each with several variants because a pattern can + * be immune to one filler and helpless against the next (`\d+`-based rules do + * not care about a run of `a`, `[a-z]+`-based ones do not care about digits): + * + * - `repeat` — one long run, the `(a+)+` / `(.*)*` family: the inner + * quantifier splits the run in exponentially many ways once a later part of + * the pattern fails. + * - `failing-terminator` — a long run plus a tail that defeats a required + * `$` / literal suffix. This is the family that catches the shapes the + * static heuristic missed (`(?:a|a)+$`, `((a|a))+$`): without a failing + * tail a greedy match succeeds on the first try and never backtracks. The + * digit-run variant is what punishes a `(\w|\d)+$`-shaped rule wherever + * that shape is actually slow. + * - `alternation` — interleaved characters (`abab…`) so branches of a + * `(?:a|ab)+`-style pattern overlap at every step. + * - `overlap-class` — `aab` / `aaab` / `a1_` runs, where the character + * classes a branch uses (`\w` vs `\d`, `[a-z]` vs `[a-z]+`) overlap. + * - `literal-prefix` — a known literal head (`(`, `[`, `/`, tab, `error `, + * `http://`) in front of the run, so a pattern that requires that literal + * still gets to the exponential part instead of failing at offset 0. The + * `(` head is the measured `[(](a+)+$` regression: without a literal `(` + * in the input the pattern never matches anything and never backtracks. + * + * Every text is synthetic, deterministic and carries no terminal content — no + * recorded output, no user data, no randomness. The one non-printable character + * allowed is a single trailing LF (see the `.`-family probe): it is the only + * way to make a pattern whose body is `.` fail at the end of the input. + * + * A note on what "hostile" means here: the corpus is judged by measurement on + * the engine that actually runs it, not by shape. `(\w|\d)+$`, one of the four + * shapes the static heuristic missed, turns out to be FAST on V8 12.4 (a 4KB + * digit run finishes in well under a millisecond — the engine appears to merge + * the two single-character alternatives), while `([a-z]+)*$` and `(a|a)+$` grow + * exponentially on the same engine. The digit-run probe below stays in the + * corpus so the shape is punished wherever it is slow; nothing here claims a + * shape is dangerous just because a table says so. + */ +export const REDOS_PROBES: readonly RedosProbe[] = [ + // --- repeat: one long run ------------------------------------------------ + { shape: 'repeat', text: run('a') }, + { shape: 'repeat', text: run('A') }, + { shape: 'repeat', text: run('0') }, + { shape: 'repeat', text: run(' ') }, + + // --- failing-terminator: a run a required suffix cannot accept ----------- + { shape: 'failing-terminator', text: run('a', CAP - 1) + 'b' }, + { shape: 'failing-terminator', text: run('a', CAP - 1) + '!' }, + { shape: 'failing-terminator', text: run('1', CAP - 1) + 'x' }, + // `.` cannot cross a line terminator, so a trailing LF is the only tail that + // makes a `.`-based shape fail: `(.+)+$` and `(.*)*$` match a whole `a` run + // instantly and only backtrack when this probe denies them the end of input. + { shape: 'failing-terminator', text: run('a', CAP - 1) + '\n' }, + { shape: 'failing-terminator', text: run('a', CAP / 2) + 'b' + run('a', CAP / 2 - 1) }, + + // --- alternation: interleaved characters --------------------------------- + { shape: 'alternation', text: 'ab'.repeat(CAP / 2) }, + { shape: 'alternation', text: 'ab'.repeat(CAP / 2 - 1) + 'a' }, + { shape: 'alternation', text: 'aba'.repeat(1365) }, + { shape: 'alternation', text: 'a1'.repeat(CAP / 2) }, + + // --- overlap-class: characters two branches both accept ------------------ + { shape: 'overlap-class', text: 'aab'.repeat(1365) }, + { shape: 'overlap-class', text: 'aaab'.repeat(1024) }, + { shape: 'overlap-class', text: 'a1_'.repeat(1365) }, + { shape: 'overlap-class', text: 'aaa'.repeat(1365) }, + + // --- literal-prefix: a known head in front of the run -------------------- + { shape: 'literal-prefix', text: '(' + run('a', CAP - 2) + 'b' }, + { shape: 'literal-prefix', text: '(' + run('a', CAP - 1) }, + { shape: 'literal-prefix', text: '[' + run('a', CAP - 1) }, + { shape: 'literal-prefix', text: '/' + run('a', CAP - 1) }, + { shape: 'literal-prefix', text: '\t' + run('a', CAP - 1) }, + { shape: 'literal-prefix', text: 'error '.repeat(400) + 'x' }, + { shape: 'literal-prefix', text: 'http://' + run('a', CAP - 7) } +] diff --git a/src/shared/ruleValidation.ts b/src/shared/ruleValidation.ts new file mode 100644 index 0000000..7eb50d1 --- /dev/null +++ b/src/shared/ruleValidation.ts @@ -0,0 +1,263 @@ +/** + * Load-time admission for stored rules: the fail-closed gate the hot path + * checks before a pattern may run, plus the shared flag spellings, the shape + * cleaner for persisted proofs, the environment fingerprint a proof is scoped + * to and the built-in whitelist factory. + * + * A rule runs when it holds a *passed* proof that matches its pattern, its + * compile flags, the current probe version and the current runtime exactly — or + * when it is a factory built-in on the whitelist (proven by the test suite, + * shipped inside the app). Everything else is inert until the validator + * re-proves it. + * + * Zero imports on purpose: `tests/rule-validation.mjs` loads this file + * directly through Node's native type stripping, so nothing here may reach + * out at runtime. + */ + +/** + * One rule's proof that it ran within budget on the probe corpus — in one + * environment. The fingerprint fields are all part of the proof: a record is + * only evidence for the exact pattern, flags, corpus and runtime it was + * obtained in, which is why the gate compares every one of them instead of + * trusting `status`. + */ +export interface RuleValidation { + status: 'passed' | 'pending' | 'quarantined' + /** the exact pattern the proof was obtained for */ + pattern: string + /** the exact flag string the proof was obtained under */ + flags: string + /** the probe version the proof was obtained against */ + probeVersion: number + /** the runtime fingerprint the proof was obtained in (see `runtimeKeyOf`) */ + runtime: string +} + +/** Longest pattern accepted anywhere (engine, store, editor) — one constant. */ +export const MAX_PATTERN_LEN = 500 + +/** Every status a proof may carry. Anything else is a corrupt record. */ +const STATUSES: readonly RuleValidation['status'][] = ['passed', 'pending', 'quarantined'] + +/** How a rule stands right now: the four states the UI and the gate speak in. */ +export type RuleRunState = 'builtin' | 'passed' | 'pending' | 'quarantined' + +/** + * The version triple a proof is scoped to: the app build, the Electron build + * and the V8 build. V8 is what actually runs the regex, so a new V8 (a new + * Electron, i.e. a new app version) can change how a pattern behaves — a proof + * measured on one engine says nothing about another. The probe corpus is + * versioned separately (`PROBE_VERSION`): a corpus change invalidates proofs + * without the engine having moved. + */ +export interface RuleRuntimeVersions { + /** the app version (`app.getVersion()`) */ + app: string + /** the Electron version (`process.versions.electron`) */ + electron: string + /** the V8 version (`process.versions.v8`) — the engine the probes ran on */ + v8: string +} + +/** Fingerprint of the environment a probe run happened in. */ +export function runtimeKeyOf(versions: RuleRuntimeVersions): string { + return `${versions.app}|${versions.electron}|${versions.v8}` +} + +/** + * The runtime this process accepts proofs for. Deliberately a module-level + * value rather than a parameter threaded through every call site: the gate is + * called from the highlight engine, the link-rule engine, the settings store + * and the validator, and each of those would otherwise have to carry a value + * none of them owns. The main process installs its own fingerprint at startup + * (`main/ruleRuntime.ts`), the renderer installs the one it is handed over IPC + * (`AppInfo.runtimeKey`), tests set one explicitly — same shape as the i18n + * module's current language. + * + * The default is the empty string, which no proof carries, so an unset runtime + * rejects every proof instead of accepting one from an unknown environment. + */ +let currentRuntime = '' + +/** Install the fingerprint this process's proofs must match. */ +export function setRuleRuntimeKey(key: string): void { + currentRuntime = key +} + +/** The installed fingerprint (`''` when nothing was installed yet). */ +export function ruleRuntimeKey(): string { + return currentRuntime +} + +/** + * Flags a highlight rule compiles with: `g`, plus `i` when case-insensitive + * and `m` when line-anchored. The single spelling shared by the engine's + * compile step and the whitelist — a rule proved under different flags than + * it runs under is a rule that was never proved. + */ +export function highlightFlags(caseInsensitive?: boolean, lineAnchor?: boolean): string { + return `g${caseInsensitive === true ? 'i' : ''}${lineAnchor === true ? 'm' : ''}` +} + +/** + * Flags a link rule compiles with: always plain `g`. Exported so + * `compileLinkRules` compiles with the same string this module's gate compares + * against — the gate is only meaningful while those two cannot drift apart. + */ +export const LINK_RULE_FLAGS = 'g' + +/** + * Identity of one rule for the gate: its exact source plus the exact flags. + * Also the validator's dedupe key, so two rules that compile to the same thing + * cost one probe run. + */ +export function ruleKeyOf(pattern: string, flags: string): string { + return `${pattern}\u0000${flags}` +} + +/** + * Shape-clean one persisted proof. Load paths (settings store, link-rule + * sanitizer) run every stored record through this instead of trusting the + * field: a hand-edited `settings.json` or an imported file can carry anything + * under `validation`, and a record with the wrong shape must not be carried + * forward into the gate — the gate compares exact strings and would treat a + * malformed record as "no proof" anyway, so keeping it would only mean a + * corrupt object in memory and in the next save. + * + * Only the five known fields survive (extras are dropped), and `undefined` + * means "no usable proof". This is a SHAPE check: whether the proof still + * matches the pattern, the flags, the probe version and the runtime is the + * gate's job (`validationAllows`), so a stale-but-well-formed record is kept as + * evidence rather than silently erased. A record written before proofs carried + * a runtime fingerprint has no `runtime` and is therefore not a usable proof — + * it lands as `pending` and is re-proved, which is the point of the field. + */ +export function sanitizeRuleValidation(raw: unknown): RuleValidation | undefined { + if (raw === null || typeof raw !== 'object') return undefined + const candidate = raw as Record + const { status, pattern, flags, probeVersion, runtime } = candidate + if (typeof status !== 'string' || !STATUSES.includes(status as RuleValidation['status'])) return undefined + if (typeof pattern !== 'string' || typeof flags !== 'string') return undefined + if (typeof probeVersion !== 'number' || !Number.isFinite(probeVersion)) return undefined + if (typeof runtime !== 'string' || runtime === '') return undefined + return { status: status as RuleValidation['status'], pattern, flags, probeVersion, runtime } +} + +/** + * Factory for a built-in whitelist, keyed by pattern + flags. The rule list is + * injected rather than imported so a caller derives the set from whatever + * presets it actually ships — a preset that carries `lineAnchor` (or any flag + * combination) is always covered, and this module stays zero-import. + */ +export function makeBuiltinWhitelist( + rules: readonly { pattern: string; caseInsensitive?: boolean; lineAnchor?: boolean }[] +): ReadonlySet { + const out = new Set() + for (const rule of rules) { + out.add(ruleKeyOf(rule.pattern, highlightFlags(rule.caseInsensitive, rule.lineAnchor))) + } + return out +} + +/** + * Whether a proof still describes the rule it is attached to, in THIS + * environment. Every field is compared: the same pattern under other flags, a + * proof from another corpus or from another engine is not the same proof. + */ +export function proofMatches( + pattern: string, + flags: string, + validation: RuleValidation | undefined, + expectedProbeVersion: number +): boolean { + return ( + validation !== undefined && + validation.pattern === pattern && + validation.flags === flags && + validation.probeVersion === expectedProbeVersion && + validation.runtime === currentRuntime + ) +} + +/** + * Whether a rule may run on the hot path. Fail-closed: without a `passed` + * proof that matches pattern, flags, probe version and runtime exactly the rule + * does not run — except a hit on the built-in whitelist, which a caller may + * supply (the highlight engine passes one; link rules pass none). + */ +export function validationAllows( + pattern: string, + flags: string, + validation: RuleValidation | undefined, + expectedProbeVersion: number, + builtinWhitelist?: ReadonlySet +): boolean { + if (builtinWhitelist?.has(ruleKeyOf(pattern, flags))) return true + return proofMatches(pattern, flags, validation, expectedProbeVersion) && validation?.status === 'passed' +} + +/** + * The rule's standing, derived rather than read off the record: a stored + * `passed` whose pattern/flags/corpus/runtime no longer match is a pending + * rule, whatever the field says. This is what the settings UI labels rows with, + * so the label cannot disagree with what the engine will actually do. + */ +export function ruleRunState( + pattern: string, + flags: string, + validation: RuleValidation | undefined, + expectedProbeVersion: number, + builtinWhitelist?: ReadonlySet +): RuleRunState { + if (builtinWhitelist?.has(ruleKeyOf(pattern, flags))) return 'builtin' + if (!proofMatches(pattern, flags, validation, expectedProbeVersion)) return 'pending' + return validation?.status === 'quarantined' ? 'quarantined' : validation?.status === 'passed' ? 'passed' : 'pending' +} + +/** + * Rewrite a proof that no longer applies to its rule into an explicit `pending` + * record for the CURRENT environment. The main process runs this on every load + * and every save (settings store), so a persisted `passed` from another app + * build, another corpus or an edited pattern never reaches the renderer + * claiming to be a verdict — the renderer would fail closed anyway, but the + * file and the UI would both be lying about it. + * + * The rewritten record keeps the proof machinery intact (it is a well-formed + * record for the rule as it now stands), which is what lets `needsRuleValidation` + * see it as work to do rather than as corrupt data. + */ +export function normalizeRuleValidation( + validation: RuleValidation | undefined, + pattern: string, + flags: string, + expectedProbeVersion: number +): RuleValidation | undefined { + if (validation === undefined) return undefined + if (proofMatches(pattern, flags, validation, expectedProbeVersion)) return validation + // Without an installed fingerprint there is no environment to normalize TO: + // writing a `pending` record stamped with an empty runtime would only produce + // a record the shape cleaner rejects as malformed on the next load. The gate + // fails closed on the stale record anyway, so leaving it alone is both honest + // and harmless. + if (currentRuntime === '') return validation + return { status: 'pending', pattern, flags, probeVersion: expectedProbeVersion, runtime: currentRuntime } +} + +/** + * Whether the validator should spend a probe run on this rule. True for a rule + * with no proof, for one whose stored verdict is `pending`, and for one whose + * proof belongs to another pattern/flags/corpus/runtime (the normalization + * above turns those into `pending` too, but a caller holding a raw record gets + * the same answer). `quarantined` and `passed` records that still match settle + * the question: re-probing them would only repeat a run we already paid for. + */ +export function needsRuleValidation( + pattern: string, + flags: string, + validation: RuleValidation | undefined, + expectedProbeVersion: number +): boolean { + if (!proofMatches(pattern, flags, validation, expectedProbeVersion)) return true + return validation?.status === 'pending' +} diff --git a/src/shared/settings.ts b/src/shared/settings.ts index 08f67f0..c124c51 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -1,5 +1,6 @@ import type { TerminalTheme } from './theme' import type { Language } from './i18n' +import type { RuleValidation } from './ruleValidation' /** Coarse grouping for the settings table's grouped view. */ export type HighlightCategory = 'safety' | 'status' | 'file' | 'net' | 'text' | 'metric' @@ -59,6 +60,14 @@ export interface HighlightRule { note?: string /** part of the factory preset set (restored by 恢复默认) */ builtin?: boolean + /** + * Proof the pattern ran within budget on the probe corpus + * (shared/redosProbes.ts). Internal metadata: only the app's own validator + * ever writes one, load paths keep nothing but its shape + * (`sanitizeRuleValidation`), and the engine re-checks pattern + flags + + * probe version at compile time before the rule may run. + */ + validation?: RuleValidation } export interface TerminalSettings { @@ -321,6 +330,13 @@ export interface LinkRule { /** absent means enabled */ enabled?: boolean note?: string + /** + * Proof the pattern ran within budget on the probe corpus — same contract as + * HighlightRule.validation: kept only as a shape by the load path, re-checked + * by `compileLinkRules` (flags must be exactly 'g'). Link rules have no + * built-in whitelist, so an unproven rule never runs. + */ + validation?: RuleValidation } export interface AppSettings { diff --git a/tests/build-bundles.cjs b/tests/build-bundles.cjs index dae45d9..b6e399b 100644 --- a/tests/build-bundles.cjs +++ b/tests/build-bundles.cjs @@ -73,7 +73,14 @@ const BUNDLES = [ // Preset-rule assertions (word boundaries, case flag) over the same engine. { entry: 'tests/hl-rules.mjs', out: 'tests/.hl-rules.cjs' }, // Diagnostic app log: daily naming, retention sweep, async append queue. - { entry: 'src/main/appLog.ts', out: 'tests/.app-log.cjs' } + { entry: 'src/main/appLog.ts', out: 'tests/.app-log.cjs' }, + // Rule-validation service: worker pool, hard budget, dedupe, write-back. + // `node:worker_threads` is a Node builtin, so the pool itself bundles. + { entry: 'src/main/redosValidator.ts', out: 'tests/.redos-validator.cjs' }, + // The REAL probe worker as a standalone script the test can spawn in a thread + // — the pool's terminate path only means something against the shipped + // implementation, not against a stub. + { entry: 'src/main/redosProbeWorker.ts', out: 'tests/.redos-probe-worker.cjs' } ] for (const { entry, out, format = 'cjs', external = [], alias = {} } of BUNDLES) { diff --git a/tests/hl-rules.mjs b/tests/hl-rules.mjs index d0ea9d0..d699a49 100644 --- a/tests/hl-rules.mjs +++ b/tests/hl-rules.mjs @@ -32,6 +32,8 @@ import { paletteMatch, themeColorFor } from '../src/renderer/src/theme/highlightColors.ts' +import { PROBE_VERSION } from '../src/shared/redosProbes.ts' +import { highlightFlags, setRuleRuntimeKey } from '../src/shared/ruleValidation.ts' let failed = 0 const ok = (cond, msg) => { @@ -39,8 +41,35 @@ const ok = (cond, msg) => { if (!cond) failed++ } +/** + * The environment the proofs in this file belong to. In the app the main + * process installs its own fingerprint and the renderer is handed the same + * string over IPC; here it is a literal, and installing it is what makes the + * hand-written proofs below count for THIS engine. + */ +const RUNTIME = 'test-app|test-electron|test-v8' +setRuleRuntimeKey(RUNTIME) + const rules = compileRules(DEFAULT_HIGHLIGHT_RULES) +/** + * `compileRules` with a passed proof stamped on every rule the caller wrote by + * hand. The tables in this file are about matching semantics, and an unproven + * rule is inert by design — so each hand-written rule gets the proof its own + * pattern and flags need. The gate itself (pending / quarantined / expired / + * edited pattern, another runtime, and the preset whitelist) is pinned in + * `tests/rule-validation.mjs` and in the `[proof gate]` section at the end. + */ +const proofFor = (rule) => ({ + status: 'passed', + pattern: rule.pattern, + flags: highlightFlags(rule.caseInsensitive, rule.lineAnchor), + probeVersion: PROBE_VERSION, + runtime: RUNTIME +}) +const proved = (rule) => ({ ...rule, validation: proofFor(rule) }) +const compile = (list) => compileRules(list.map(proved)) + /** truecolor prefix for a hex colour, as the engine emits it */ const sgr = (hex) => { const n = parseInt(hex.slice(1), 16) @@ -254,11 +283,11 @@ console.log('[danger]') // ---- 8. case-insensitive flag is opt-in per rule ---------------------------- console.log('[case flag]') { - const plain = compileRules([ + const plain = compile([ { id: 'cs', pattern: '\\bERROR\\b', enabled: true, priority: 1, color: { fg: BAD_RED } } ]) ok(!applyHighlights('error', plain).includes(sgr(BAD_RED)), 'a rule without the flag still respects case') - const folded = compileRules([ + const folded = compile([ { id: 'ci', pattern: '\\bERROR\\b', enabled: true, priority: 1, color: { fg: BAD_RED }, caseInsensitive: true } ]) ok(applyHighlights('error', folded).includes(sgr(BAD_RED)), 'the flag makes a rule case-insensitive') @@ -417,13 +446,13 @@ console.log('[value bands]') ) // a rule without bands keeps one colour whatever the number - const flat = compileRules([ + const flat = compile([ { id: 'flat', pattern: '\\d+%', enabled: true, priority: 1, color: { fg: BAD_RED } } ]) ok(applyHighlights('77%', flat).includes(sgr(BAD_RED)), 'a rule without bands stays flat') // with bands: the match's own number decides, and no number falls back - const banded = compileRules([ + const banded = compile([ { id: 'banded', pattern: '\\bDONE\\b|\\d+%', @@ -441,7 +470,7 @@ console.log('[value bands]') ok(applyHighlights('95%', banded).includes(sgr(OK_GREEN)), 'a number above the last min → the last band') // bands stored out of order still resolve lowest-first - const unsorted = compileRules([ + const unsorted = compile([ { id: 'unsorted', pattern: '\\d+%', @@ -595,6 +624,40 @@ console.log('[import / export]') ok(appended.length === DEFAULT_HIGHLIGHT_RULES.length + 1, 'append keeps the existing rules') ok(new Set(appended.map((r) => r.id)).size === appended.length, 'append never leaves duplicate ids') ok(mergeRules([mine], back.rules, 'replace').length === DEFAULT_HIGHLIGHT_RULES.length, 'replace drops the old set') + + // The internal validation proof travels in neither direction. An exported file + // is meant to be shared or archived, so it must not carry what reads like a + // trust credential; an imported file is untrusted input, so a proof inside it + // is ignored and the rule starts unproven (the validator re-proves it). + { + const proven = DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule, validation: proofFor(rule) })) + const file = exportHighlightRules(proven) + ok(!file.includes('"validation"'), 'an export carries no validation field') + ok(!file.includes('probeVersion'), 'an export carries no probe version either') + + const reloaded = parseHighlightRules(file) + ok( + reloaded.rules.length === proven.length && reloaded.rules.every((r) => r.validation === undefined), + 'an exported set comes back whole and unproven' + ) + + const smuggled = parseHighlightRules( + JSON.stringify({ + kind: HIGHLIGHT_FILE_KIND, + version: HIGHLIGHT_FILE_VERSION, + rules: [ + { + pattern: '\\bOK\\b', + validation: { status: 'passed', pattern: '\\bOK\\b', flags: 'g', probeVersion: PROBE_VERSION, runtime: RUNTIME } + } + ] + }) + ) + ok( + smuggled.rules.length === 1 && smuggled.rules[0].validation === undefined, + 'a validation field inside an imported file is ignored' + ) + } } // ---- 13. editor preview spans ---------------------------------------------- @@ -999,10 +1062,10 @@ console.log('[long lines]') console.log('[line anchor]') { const TEXT = 'a\nERROR\nERROR b\n' - const plain = compileRules([ + const plain = compile([ { id: 'anch', pattern: '^ERROR$', enabled: true, priority: 1, color: { fg: BAD_RED } } ]) - const perLine = compileRules([ + const perLine = compile([ { id: 'anch', pattern: '^ERROR$', @@ -1033,7 +1096,7 @@ console.log('[line anchor]') ) // Anchoring only moves the boundaries: '$' works the same way. - const tail = compileRules([ + const tail = compile([ { id: 'tail', pattern: 'ERROR$', enabled: true, priority: 1, color: { fg: BAD_RED }, lineAnchor: true } ]) const tailOut = applyHighlights(TEXT, tail) @@ -1044,7 +1107,7 @@ console.log('[line anchor]') ) // The two flags compose: 'i' and 'm' both apply when both switches are on. - const both = compileRules([ + const both = compile([ { id: 'both', pattern: '^error$', @@ -1067,7 +1130,7 @@ console.log('[match events]') // Only spans that actually get painted are reported, and each carries the // rule id plus the raw matched text. const events = [] - const one = compileRules([ + const one = compile([ { id: 'ok', pattern: '\\bOK\\b', enabled: true, priority: 1, color: { fg: OK_GREEN } } ]) const out = applyHighlights('all OK here', one, undefined, undefined, (id, text) => @@ -1087,7 +1150,7 @@ console.log('[match events]') // report either — otherwise a consumer would alert about colour that is not // on screen. `done` is the documented case: shellkw beats status words. const events = [] - const overlap = compileRules([ + const overlap = compile([ { id: 'first', pattern: '\\bdone\\b', enabled: true, priority: 1, color: { fg: OK_GREEN } }, { id: 'second', pattern: '\\b(done|DONE)\\b', enabled: true, priority: 2, color: { fg: BAD_RED } } ]) @@ -1101,7 +1164,7 @@ console.log('[match events]') // The per-rule budget is the other silent case: a rule that hits MAX_PER_RULE // (300) times in one run reports exactly that many times, not one more. let count = 0 - const many = compileRules([ + const many = compile([ { id: 'x', pattern: 'ERROR', enabled: true, priority: 1, color: { fg: BAD_RED } } ]) applyHighlights(Array.from({ length: 301 }, () => 'ERROR').join(' '), many, undefined, undefined, () => { @@ -1122,7 +1185,7 @@ console.log('[match events]') // only completes once the held carry is flushed still reports exactly once. const events = [] const stream = new HighlightStream( - compileRules([ + compile([ { id: 'ok', pattern: '\\bOK\\b', enabled: true, priority: 1, color: { fg: OK_GREEN } } ]), (id, text) => events.push([id, text]) @@ -1143,7 +1206,7 @@ console.log('[match events]') // Rules without the flag are irrelevant to the engine: it reports every painted // span and leaves the notify policy to the consumer. const events = [] - const mixed = compileRules([ + const mixed = compile([ { id: 'silent', pattern: '\\bWARN\\b', enabled: true, priority: 1, color: { fg: WARN_YELLOW } } ]) applyHighlights('WARN', mixed, undefined, undefined, (id) => events.push(id)) @@ -1181,7 +1244,7 @@ console.log('[reset spellings and CSI parameters]') // canonical ones let the other spellings be APPENDED to the SGR accumulator, // so it grew a few bytes per line until it blew the cap and every later span // lost its restore. Go/Java log libraries routinely emit ESC[0;32m. - const warn = compileRules([{ id: 'w', pattern: 'WARNING', priority: 1, color: { fg: '#ff0000' } }]) + const warn = compile([{ id: 'w', pattern: 'WARNING', priority: 1, color: { fg: '#ff0000' } }]) const tailAfter = (input) => { const out = applyHighlights(input, warn) return out.slice(out.indexOf('WARNING') + 'WARNING'.length) @@ -1211,18 +1274,19 @@ 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. + // The editor's advisory heuristic. It flags the shapes that compile in + // microseconds and then block for seconds (`(a+)+$` takes ~8s on 30 + // characters), so the user gets a warning while typing. It is NOT the gate — + // it has measured misses and false positives, so a shape test can decide + // neither what is safe nor what is hostile. What runs is decided by the proof + // (see [proof gate] below). 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}`) + ok(isReDoSProne(p), `flags 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 + // shipped presets: flagging one of those would nag about 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', @@ -1233,20 +1297,102 @@ console.log('[ReDoS shapes]') '\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b' ] for (const p of safe) { - ok(!isReDoSProne(p), `accepts safe pattern ${p}`) + ok(!isReDoSProne(p), `leaves safe pattern ${p} unflagged`) } 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'})`) + ok(flaggedPresets.length === 0, `no shipped preset is flagged (flagged: ${flaggedPresets.join(', ') || 'none'})`) +} - // 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: 'flagged', pattern: '(a+)+$', priority: 1, color: { fg: '#ff0000' } }, - { id: 'plain', pattern: 'ERROR', priority: 2, color: { fg: '#00ff00' } } - ]) - ok(compiled.length === 2, 'compileRules keeps flagged rules (the check is advisory, not a gate)') +console.log('[proof gate]') +{ + // What admits a stored pattern is the validation proof: a `passed` record + // matching the rule's own pattern, its compile flags and the current probe + // version (shared/ruleValidation + shared/redosProbes). The full truth table + // lives in tests/rule-validation.mjs; these assertions pin the engine's side, + // which is where a bypass would actually hurt. + const plain = { id: 'plain', pattern: 'ERROR', priority: 2, color: { fg: '#00ff00' } } + const flagged = { id: 'flagged', pattern: '(a+)+$', priority: 1, color: { fg: '#ff0000' } } + + ok(compileRules([flagged, plain]).length === 0, 'unproven custom rules never reach the hot path') + ok( + compile([flagged, plain]).length === 2, + 'the heuristic is not the gate: a PROVEN rule compiles even when it is flagged' + ) + ok( + compileRules(DEFAULT_HIGHLIGHT_RULES).length === DEFAULT_HIGHLIGHT_RULES.length, + `every shipped preset compiles without a proof (${compileRules(DEFAULT_HIGHLIGHT_RULES).length}/${DEFAULT_HIGHLIGHT_RULES.length})` + ) + + // Each way a proof can stop matching its rule is inert, and the rule itself is + // kept in settings — a stale or edited rule is skipped, never repaired. + ok(compileRules([{ ...plain, validation: proofFor(plain) }]).length === 1, 'a matching proof admits the rule') + ok( + compileRules([{ ...plain, caseInsensitive: true, validation: proofFor(plain) }]).length === 0, + 'a proof under other flags does not admit the rule' + ) + ok( + compileRules([{ ...plain, pattern: 'WARNING', validation: proofFor(plain) }]).length === 0, + 'a proof for the pre-edit pattern does not admit the edited rule' + ) + ok( + compileRules([ + { + ...plain, + validation: { status: 'passed', pattern: 'ERROR', flags: 'g', probeVersion: PROBE_VERSION - 1, runtime: RUNTIME } + } + ]).length === 0, + 'a proof from an older probe set does not admit the rule' + ) + ok( + compileRules([ + { + ...plain, + validation: { status: 'passed', pattern: 'ERROR', flags: 'g', probeVersion: PROBE_VERSION, runtime: 'other|other|other' } + } + ]).length === 0, + 'a proof measured in another runtime does not admit the rule' + ) + for (const status of ['pending', 'quarantined']) { + ok( + compileRules([ + { ...plain, validation: { status, pattern: 'ERROR', flags: 'g', probeVersion: PROBE_VERSION, runtime: RUNTIME } } + ]).length === 0, + `a ${status} rule is kept in settings but does not run` + ) + } + + // The gate only removes rules: priorities still decide which rule claims a + // span first, so a filtered set keeps its order. + { + const provenRule = (id, pattern, priority) => ({ + id, + pattern, + priority, + color: { fg: '#ff0000' }, + validation: { status: 'passed', pattern, flags: 'g', probeVersion: PROBE_VERSION, runtime: RUNTIME } + }) + const out = compileRules([ + provenRule('late', 'LATE', 9), + { id: 'unproven', pattern: 'SKIP', priority: 1, color: { fg: '#ff0000' } }, + provenRule('early', 'EARLY', 2) + ]) + ok( + out.map((r) => r.id).join(',') === 'early,late', + `the gate filters without disturbing priority order (got ${out.map((r) => r.id).join(',')})` + ) + } + + // The editor preview runs on compiled rules, so it cannot bypass the gate: + // an unproven rule yields the plain sample, a proven one yields a coloured span. + { + const unproven = previewSpans('ERROR', compileRules([plain])) + ok(unproven.length === 1 && unproven[0].fg === undefined, 'the preview cannot bypass the gate') + const provenPreview = previewSpans('ERROR', compile([plain])) + ok( + provenPreview.some((s) => s.text === 'ERROR' && s.fg === '#00ff00'), + 'a proven rule previews with its colour' + ) + } } console.log(failed === 0 ? '\n[hl-rules] ALL CHECKS PASSED' : `\n[hl-rules] ${failed} CHECK(S) FAILED`) diff --git a/tests/ipc-guard.mjs b/tests/ipc-guard.mjs index b9fb76f..3395503 100644 --- a/tests/ipc-guard.mjs +++ b/tests/ipc-guard.mjs @@ -105,7 +105,7 @@ const invoke = (channel, frame, ...args) => { return listener({ senderFrame: frame, sender: { id: 1 } }, ...args) } -withDevUrl(DEV_URL, () => { +await withDevUrl(DEV_URL, () => { ok(typeof handlers.get(Ipc.APP_INFO) === 'function', 'APP_INFO reached the registration path') ok(handlers.get(Ipc.APP_INFO) !== undefined, 'the stub recorded a handler (registrations are not dropped)') @@ -139,13 +139,25 @@ withDevUrl(DEV_URL, () => { // A hostile frame gets the same refusal on every other invoke channel, and the // handler is never reached: CWD_OPEN would have thrown/misbehaved, PTY_CREATE - // would have spawned a shell. - for (const channel of [Ipc.CWD_OPEN, Ipc.PTY_CREATE, Ipc.CONNECTIONS_LIST, Ipc.SETTINGS_GET, Ipc.LOCK_STATE_GET]) { + // would have spawned a shell, RULE_VALIDATE would have started a probe worker. + for (const channel of [ + Ipc.CWD_OPEN, + Ipc.PTY_CREATE, + Ipc.CONNECTIONS_LIST, + Ipc.SETTINGS_GET, + Ipc.LOCK_STATE_GET, + Ipc.RULE_VALIDATE, + Ipc.RULE_VALIDATION_STATE_GET + ]) { if (!handlers.has(channel)) continue const m = refused(channel) ok(typeof m === 'string' && m.includes('untrusted frame'), `refused on ${channel}`) } + // The rule-validation batch answers a trusted frame without touching settings: + // drafts are probed, never stored. Its own section below awaits the handler, + // so it cannot live inside this synchronous block (see the note there). + const missingFrame = (() => { try { handlers.get(Ipc.APP_INFO)({ sender: { id: 1 } }, ) @@ -217,6 +229,42 @@ withDevUrl(DEV_URL, () => { ok((m.match(/untrusted frame/g) ?? []).length === 1, 'the refusal message is not nested (exactly one guard layer)') }) +// ---- 4. the rule-validation channel answers a trusted frame ----------------- +// Awaiting the handler needs an async scope, and `withDevUrl` restores the env +// var as soon as its callback returns — which for an async callback is the +// moment it hits its first `await`, i.e. before the awaited work resumes. So +// the variable is set by hand around this block instead. +console.log('the rule-validation channel') +{ + const previous = process.env.ELECTRON_RENDERER_URL + process.env.ELECTRON_RENDERER_URL = DEV_URL + try { + const report = await invoke(Ipc.RULE_VALIDATE, trustedFrame, { + kind: 'highlight', + items: [{ id: 'draft', pattern: '\\bERROR\\b' }] + }) + ok( + report && Array.isArray(report.results) && report.results.length === 1, + 'a trusted frame gets a report back from RULE_VALIDATE' + ) + // No worker factory is installed in this bundle (the `?nodeWorker` wiring + // lives in index.ts), so every rule stays pending — the fail-closed answer, + // not an error. + ok(report.results[0].state === 'pending', `no worker wired here → pending (got ${report.results[0].state})`) + ok(report.workerAvailable === false, 'the report says the validator is unavailable') + ok(typeof report.runtime === 'string', 'the report names the runtime fingerprint it belongs to') + + const state = invoke(Ipc.RULE_VALIDATION_STATE_GET, trustedFrame) + ok( + state && typeof state.pending === 'number' && typeof state.quarantined === 'number', + `the aggregate state answers with counts (${JSON.stringify(state)})` + ) + } finally { + if (previous === undefined) delete process.env.ELECTRON_RENDERER_URL + else process.env.ELECTRON_RENDERER_URL = previous + } +} + rmSync(userData, { recursive: true, force: true }) if (failed > 0) { diff --git a/tests/link-rules.mjs b/tests/link-rules.mjs index 25c9408..061ca71 100644 --- a/tests/link-rules.mjs +++ b/tests/link-rules.mjs @@ -22,8 +22,12 @@ * ranges, including a pattern that can match the empty string; * - `mergeLinkSpans` keeps the user rule and drops the colliding built-in. * - * Direct .ts import (no esbuild bundle): the module has no imports and no - * runtime-only TS syntax, and Node >= 22.18 strips types itself. + * The rules written here are all legitimate, so `proved()` stamps each one with + * the proof the compile gate now requires (a rule without one is inert by + * design — the gate itself is pinned in tests/rule-validation.mjs, not here). + * + * Direct .ts import (no esbuild bundle): the module has no extension-less + * imports and no runtime-only TS syntax, and Node >= 22.18 strips types itself. * Run: node tests/link-rules.mjs (must exit 0) */ import { @@ -40,6 +44,8 @@ import { sanitizeLinkRules, isSafeLinkTemplate } from '../src/shared/linkRules.ts' +import { PROBE_VERSION } from '../src/shared/redosProbes.ts' +import { LINK_RULE_FLAGS, setRuleRuntimeKey } from '../src/shared/ruleValidation.ts' let failed = 0 const ok = (cond, msg) => { @@ -47,6 +53,21 @@ const ok = (cond, msg) => { if (!cond) failed += 1 } +/** + * The environment the proofs below belong to. The main process installs its own + * fingerprint and the renderer is handed the same string over IPC; here it is a + * literal, and installing it is what makes a proof count for THIS engine. + */ +const RUNTIME = 'test-app|test-electron|test-v8' +setRuleRuntimeKey(RUNTIME) + +/** Stamp the proof the gate demands for this exact pattern + flag string. */ +const proved = (rule) => ({ + ...rule, + validation: { status: 'passed', pattern: rule.pattern, flags: LINK_RULE_FLAGS, probeVersion: PROBE_VERSION, runtime: RUNTIME } +}) +const compile = (rules) => compileLinkRules(rules.map(proved)) + const raw = (over) => ({ id: 'r1', pattern: '\\b(\\d+)\\b', template: 'https://x/$1', ...over }) // ---- sanitize --------------------------------------------------------------- @@ -205,19 +226,25 @@ console.log('[compileLinkRules]') { ok(compileLinkRules(undefined).length === 0, 'an absent rule list compiles to nothing') ok(compileLinkRules([]).length === 0, 'an empty rule list compiles to nothing') - const compiled = compileLinkRules([ + const compiled = compile([ { id: 'a', pattern: 'x', template: 'https://x/a' }, { id: 'b', pattern: 'y', template: 'https://x/b', enabled: false }, { id: 'c', pattern: 'z', template: 'https://x/c', enabled: true } ]) ok(compiled.length === 2, 'disabled rules are not compiled') ok(compiled[0].regex.flags.includes('g'), 'the compiled regex is global') + + // The compile gate is fail-closed: a rule with no proof (or one that does not + // match its pattern) never reaches the terminal. `tests/rule-validation.mjs` + // owns the full truth table; this is the caller-side reminder that `proved()` + // is load-bearing for every table above. + ok(compileLinkRules([{ id: 'a', pattern: 'x', template: 'https://x/a' }]).length === 0, 'an unproven rule is inert') + ok(compiled.every((r) => r.regex.flags === LINK_RULE_FLAGS), `every compiled rule uses '${LINK_RULE_FLAGS}'`) } // ---- findRuleLinks ---------------------------------------------------------- console.log('[findRuleLinks]') -const find = (text, rules) => - findRuleLinks(text, compileLinkRules(rules)).map((s) => [s.start, s.end, s.url]) +const find = (text, rules) => findRuleLinks(text, compile(rules)).map((s) => [s.start, s.end, s.url]) const ruleSet = [{ id: 't', pattern: 'BUG-(\\d+)', template: 'https://tracker/bug/$1' }] diff --git a/tests/perf-diagnostics.mjs b/tests/perf-diagnostics.mjs new file mode 100644 index 0000000..6b6b147 --- /dev/null +++ b/tests/perf-diagnostics.mjs @@ -0,0 +1,598 @@ +/** + * perf-diagnostics self-test (perf-diagnostics.mjs). + * + * The observation-only first phase of the stall timeline + * (docs/PLAN-2026-10-17-font-highlight.md, 独立方案 B §B2) is observation only, so + * the thing worth pinning is the *decision* logic that turns raw ticks into log + * lines: + * + * - `classifyWatchdogLag`: a late tick is only a "renderer long task" when an + * observed long task actually overlaps the window; otherwise it is + * "scheduler delay/unclassified" with the unexplained remainder (`uncovered`) + * carried explicitly. Below the lag threshold nothing is reported at all. + * - `fitTriggerFor`: the fit label comes from the font group's FIELD VALUES, so + * a group object rebuilt for a non-metric member (cursor blink/style) is not + * mislabelled as a scrollback reflow. + * - `intervalLogDecision`: per-pane fit lines — one per window, the next one + * carrying `suppressed=N`. + * - `perfLogDecision`: the 5/minute global PERF cap, which urgent (severe + * watchdog) lines bypass without resetting the normal budget. + * - the pane registry: per-bind counters, aggregate snapshot, and the rule that + * a dropped (unmounted/rebound) pane id stops receiving notes so an in-flight + * write callback cannot pollute the next bind's numbers. + * - `WriteBatcher.pendingChars`: a JS string length (NOT bytes) that tracks + * the buffer and clears on flush/dispose. + * + * Direct .ts import (no esbuild bundle needed): perfDiagnostics.ts has no + * imports and writeBatcher.ts has none either, and Node >= 22.18 strips types + * itself. + * Run: node tests/perf-diagnostics.mjs (must exit 0) + */ +import { + classifyWatchdogLag, + clearPanes, + currentPerfLogGate, + emitPerfLog, + FIT_LOG_INTERVAL_MS, + fitTriggerFor, + formatFitReport, + formatWatchdogReport, + intervalLogDecision, + LongTaskRing, + MAX_LONG_TASKS, + newIntervalGate, + newPerfLogGate, + noteFit, + notePaneRenderer, + noteWebgl, + noteWriteDone, + noteWriteEnqueue, + noteWriteStart, + overlapLongTasks, + PERF_LOG_MAX_PER_WINDOW, + PERF_LOG_WINDOW_MS, + perfLogDecision, + registerPane, + resetPerfLogSink, + setPerfLogSink, + snapshotPanes, + unregisterPane, + WATCHDOG_LAG_LOG_MS, + sanitizePerfDetail +} from '../src/renderer/src/terminal/perfDiagnostics.ts' +import { WriteBatcher } from '../src/renderer/src/terminal/writeBatcher.ts' + +const DELAY = 10 +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +let failed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 +} +const eq = (actual, expected, msg) => + ok(actual === expected, `${msg} (expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)})`) + +// ---- watchdog classification (table driven) -------------------------------- + +console.log('watchdog: lag below the threshold is not reported at all') +{ + const rows = [ + { lag: 0, minLag: undefined, want: null }, + { lag: 2999, minLag: undefined, want: null }, + { lag: WATCHDOG_LAG_LOG_MS, minLag: undefined, want: null }, // strictly greater + { lag: 3001, minLag: undefined, want: true }, + { lag: -5000, minLag: undefined, want: null } + ] + for (const row of rows) { + const got = classifyWatchdogLag({ + lagMs: row.lag, + windowStart: 1000, + windowEnd: 1000 + 2000 + row.lag, + minLagMs: row.minLag + }) + eq(got === null, row.want === null, `lag ${row.lag}ms is ${row.want === null ? 'silent' : 'reported'}`) + } +} + +console.log('watchdog: classification follows long-task overlap, not the lag alone') +{ + // window [1000, 9000]; lag 6000 + const rows = [ + { + name: 'no long tasks -> scheduler delay', + tasks: [], + kind: 'scheduler-delay', + count: 0, + overlap: 0, + uncovered: 6000 + }, + { + name: 'one long task fully inside -> renderer long task', + tasks: [{ startTime: 3000, duration: 4000 }], + kind: 'renderer-long-task', + count: 1, + overlap: 4000, + uncovered: 2000 + }, + { + name: 'long task straddling the start is clamped', + tasks: [{ startTime: 0, duration: 2500 }], + kind: 'renderer-long-task', + count: 1, + overlap: 1500, + uncovered: 4500 + }, + { + name: 'task ending exactly at the window start does not overlap', + tasks: [{ startTime: 0, duration: 1000 }], + kind: 'scheduler-delay', + count: 0, + overlap: 0, + uncovered: 6000 + }, + { + name: 'task starting exactly at the window end does not overlap', + tasks: [{ startTime: 9000, duration: 500 }], + kind: 'scheduler-delay', + count: 0, + overlap: 0, + uncovered: 6000 + }, + { + name: 'two adjacent tasks sum their overlap', + tasks: [ + { startTime: 2000, duration: 2000 }, + { startTime: 4000, duration: 3000 } + ], + kind: 'renderer-long-task', + count: 2, + overlap: 5000, + uncovered: 1000 + }, + { + name: 'a long task outside the window is ignored', + tasks: [{ startTime: 20000, duration: 9000 }], + kind: 'scheduler-delay', + count: 0, + overlap: 0, + uncovered: 6000 + } + ] + for (const row of rows) { + const got = classifyWatchdogLag({ + lagMs: 6000, + windowStart: 1000, + windowEnd: 9000, + longTasks: row.tasks + }) + ok(got !== null, `${row.name}: reported`) + if (got === null) continue + eq(got.kind, row.kind, `${row.name}: kind`) + eq(got.taskCount, row.count, `${row.name}: taskCount`) + eq(got.overlapMs, row.overlap, `${row.name}: overlapMs`) + eq(got.uncoveredMs, row.uncovered, `${row.name}: uncoveredMs`) + } +} + +console.log('watchdog: maxTaskMs reports the real long-task size, not the clamp') +{ + const got = classifyWatchdogLag({ + lagMs: 5000, + windowStart: 1000, + windowEnd: 7000, + longTasks: [{ startTime: 900, duration: 3000 }] + }) + ok(got !== null, 'reported') + eq(got.overlapMs, 2900, 'overlap is clamped to the 1000..3900 intersection') + eq(got.maxTaskMs, 3000, 'maxTaskMs keeps the full task duration') +} + +console.log('overlapLongTasks: degenerate windows report nothing') +{ + for (const [start, end] of [[1000, 1000], [2000, 1000]]) { + const got = overlapLongTasks([{ startTime: 500, duration: 5000 }], start, end) + eq(got.count, 0, `window [${start}, ${end}] is empty`) + } +} + +console.log('LongTaskRing: bounded FIFO, newest last') +{ + const ring = new LongTaskRing(3) + for (let i = 0; i < 5; i += 1) ring.push({ startTime: i * 10, duration: 50 }) + eq(ring.size, 3, 'capacity is enforced') + const all = ring.all() + eq(all[0].startTime, 20, 'oldest surviving entry is the 3rd push') + eq(all[2].startTime, 40, 'newest entry is last') + eq(ring.latest()?.startTime, 40, 'latest() returns the newest') + const copy = ring.all() + copy.push({ startTime: 999, duration: 1 }) + eq(ring.size, 3, 'all() returns a copy, not the live array') + ring.clear() + eq(ring.size, 0, 'clear empties the ring') + eq(ring.latest(), null, 'latest() is null when empty') + + const big = new LongTaskRing() + for (let i = 0; i < MAX_LONG_TASKS + 20; i += 1) big.push({ startTime: i, duration: 60 }) + eq(big.size, MAX_LONG_TASKS, 'default capacity is MAX_LONG_TASKS') +} + +// ---- fit trigger labelling ------------------------------------------------- + +console.log('fit trigger: labelled from field values, not from object identity') +{ + const base = { + fontSize: 14, + fontFamily: 'Consolas', + cjkFontFamily: '', + fontWeight: 400, + boldFontWeight: 700, + letterSpacing: 0, + lineHeight: 1, + scrollback: 5000 + } + const rows = [ + { name: 'the first fit after construction', next: base, want: 'mount' }, + { name: 'a group object rebuilt with identical values', next: { ...base }, want: 'unknown' }, + { name: 'font size', next: { ...base, fontSize: 15 }, want: 'font' }, + { name: 'font family', next: { ...base, fontFamily: 'Fira Code' }, want: 'font' }, + { name: 'the CJK fallback slot', next: { ...base, cjkFontFamily: 'Sarasa Mono SC' }, want: 'font' }, + { name: 'font weight', next: { ...base, fontWeight: 500 }, want: 'font' }, + { name: 'bold font weight', next: { ...base, boldFontWeight: 800 }, want: 'font' }, + { name: 'letter spacing', next: { ...base, letterSpacing: 1 }, want: 'font' }, + { name: 'line height', next: { ...base, lineHeight: 1.2 }, want: 'font' }, + { name: 'only the scrollback cap', next: { ...base, scrollback: 10000 }, want: 'scrollback' }, + { + name: 'scrollback and a font metric together', + next: { ...base, scrollback: 10000, fontSize: 16 }, + want: 'font' + } + ] + for (const row of rows) { + const got = fitTriggerFor(row.name.startsWith('the first') ? null : base, row.next) + eq(got, row.want, row.name) + } + + // The point of the value comparison: a member of the font group that changes + // neither a metric nor the scrollback cap must NOT be reported as a scrollback + // reflow. The caller's snapshot simply has no cursor field, so this is the same + // call it would make. + eq(fitTriggerFor(base, { ...base }), 'unknown', 'a non-metric group change is not called a scrollback reflow') +} + +// ---- log rate limiters ----------------------------------------------------- + +console.log('fit log gate: one line per window, next line carries suppressed=N') +{ + const rows = [ + { now: 1000, emit: true, suppressed: 0, note: 'first line always passes' }, + { now: 1000 + 1, emit: false, suppressed: 0, note: 'just after: swallowed' }, + { now: 1000 + 5000, emit: false, suppressed: 0, note: 'mid-window: swallowed' }, + { now: 1000 + FIT_LOG_INTERVAL_MS, emit: true, suppressed: 2, note: 'window elapsed: emits with the 2 swallowed' }, + { now: 1000 + FIT_LOG_INTERVAL_MS + 1, emit: false, suppressed: 0, note: 'new window: counter reset' } + ] + let gate = newIntervalGate() + for (const row of rows) { + const decision = intervalLogDecision(gate, row.now, FIT_LOG_INTERVAL_MS) + eq(decision.emit, row.emit, row.note) + eq(decision.suppressed, row.suppressed, `${row.note}: suppressed`) + gate = decision.next + } +} + +console.log('fit log gate: the decision is pure (input gate is not mutated)') +{ + const gate = newIntervalGate() + const before = { ...gate } + const first = intervalLogDecision(gate, 5000, FIT_LOG_INTERVAL_MS) + eq(gate.lastEmitAt, before.lastEmitAt, 'input gate untouched') + eq(gate.suppressed, before.suppressed, 'input suppressed untouched') + eq(first.next.lastEmitAt, 5000, 'returned gate carries the new timestamp') +} + +console.log('PERF budget: max 5 lines per minute, urgent lines bypass it') +{ + let gate = newPerfLogGate() + const decisions = [] + for (let i = 0; i < PERF_LOG_MAX_PER_WINDOW + 2; i += 1) { + const d = perfLogDecision(gate, 10_000, {}) + decisions.push(d.emit) + gate = d.next + } + eq(decisions.filter(Boolean).length, PERF_LOG_MAX_PER_WINDOW, 'exactly 5 normal lines pass') + eq(decisions.at(-1), false, 'the 6th and 7th are dropped') + eq(gate.suppressed, 2, 'the dropped count is carried') + + const urgent = perfLogDecision(gate, 10_000, { urgent: true }) + eq(urgent.emit, true, 'an urgent line passes a spent budget') + eq(urgent.suppressed, 2, 'and still reports what was dropped before it') + + const afterWindow = perfLogDecision(urgent.next, 10_000 + PERF_LOG_WINDOW_MS, {}) + eq(afterWindow.emit, true, 'a new window restores the budget') + eq(afterWindow.suppressed, 0, 'and clears the dropped count') +} + +console.log('PERF budget: emitPerfLog drops silently once the cap is spent') +{ + const lines = [] + resetPerfLogSink() + setPerfLogSink((line) => lines.push(line)) + for (let i = 0; i < PERF_LOG_MAX_PER_WINDOW + 3; i += 1) emitPerfLog(`line-${i}`, { now: 5000 }) + eq(lines.length, PERF_LOG_MAX_PER_WINDOW, 'only the budgeted lines reach the sink') + ok(lines.every((l) => !l.includes('suppressed=')), 'no suppressed trailer while nothing was dropped') + // The dropped count rides on the next accepted line, once the window rolls. + const emitted = emitPerfLog('after-window', { now: 5000 + PERF_LOG_WINDOW_MS }) + eq(emitted, true, 'the first line of the next window is accepted') + eq(lines.length, PERF_LOG_MAX_PER_WINDOW + 1, 'and it reached the sink') + + const urgent = emitPerfLog('urgent-line', { urgent: true, now: 5000 + PERF_LOG_WINDOW_MS }) + eq(urgent, true, 'urgent lines bypass the budget through emitPerfLog') + resetPerfLogSink() +} + +console.log('PERF budget: a thrown sink never propagates') +{ + resetPerfLogSink() + setPerfLogSink(() => { + throw new Error('sink exploded') + }) + let threw = false + try { + emitPerfLog('boom', { now: 1 }) + } catch { + threw = true + } + ok(!threw, 'emitPerfLog swallows a failing sink') + resetPerfLogSink() +} + +// ---- pane registry --------------------------------------------------------- + +console.log('pane registry: counters aggregate across panes') +{ + clearPanes() + const a = registerPane('pane-a', { rendererMode: 'webgl', registeredAt: 0 }) + const b = registerPane('pane-b', { rendererMode: 'dom', registeredAt: 0 }) + registerPane('pane-c', { registeredAt: 0 }) // unknown mode on purpose + + noteWriteStart('pane-a', 120) + noteWriteEnqueue('pane-a', 4) + noteWriteDone('pane-a', 120, 45) + noteWriteStart('pane-b', 30) + noteWriteEnqueue('pane-b', 9) + // pane-b's write is deliberately left in flight + noteFit('pane-a', { + trigger: 'resize', + startedAt: 1000, + durationMs: 240, + cols: 120, + rows: 40, + bufferLines: 5120, + scrollback: 5000 + }) + noteFit('pane-b', { + trigger: 'mount', + startedAt: 500, + durationMs: 12, + cols: 80, + rows: 24, + bufferLines: 24, + scrollback: 5000 + }) + noteWebgl('pane-a', 'init') + noteWebgl('pane-a', 'context-loss') + noteWebgl('pane-b', 'init-failed') + a.pendingCharsProbe = () => 77 + + const snap = snapshotPanes(3000) + eq(snap.paneCount, 3, 'all three panes are counted') + eq(snap.webglPanes, 1, 'one webgl pane') + eq(snap.domPanes, 1, 'one dom pane') + eq(snap.unknownModePanes, 1, 'one pane with no renderer recorded yet') + eq(snap.writeCount, 2, 'write count sums across panes') + eq(snap.writeInflight, 1, 'the unfinished write is still counted') + eq(snap.queuedChars, 30, 'queued characters are what is left unconsumed') + eq(snap.batcherPendingChars, 77, 'the batcher probe contributes its pendingChars') + eq(snap.ackMsLast, 45, 'ack latency aggregates') + eq(snap.ackMsMax, 45, 'ack max aggregates') + eq(snap.enqueueMsMax, 9, 'enqueue max aggregates') + eq(snap.fitCount, 2, 'fit count sums') + eq(snap.lastFitPaneId, 'pane-a', 'the newest fit wins') + eq(snap.lastFit?.trigger, 'resize', 'and carries its trigger') + eq(snap.lastFitAgeMs, 2000, 'fit age is measured from the sample start') + eq(snap.webglContextLosses, 1, 'context losses are counted') + eq(b.writes.inflight, 1, 'per-pane counters stay intact') + + // A probe that throws must not take the snapshot down. + b.pendingCharsProbe = () => { + throw new Error('probe exploded') + } + const safe = snapshotPanes(3000) + eq(safe.batcherPendingChars, 77, 'a throwing probe contributes 0') + clearPanes() +} + +console.log('pane registry: counters are per BIND — a dropped id stops receiving notes') +{ + clearPanes() + registerPane('bind-1', { rendererMode: 'webgl', registeredAt: 0 }) + noteWriteStart('bind-1', 500) + noteWriteDone('bind-1', 500, 20) + eq(snapshotPanes(100).writeCount, 1, 'the first bind counted its write') + + // Rebind: the old terminal is disposed, its in-flight callback still fires. + unregisterPane('bind-1') + registerPane('bind-2', { rendererMode: 'webgl', registeredAt: 100 }) + noteWriteDone('bind-1', 500, 9999) + noteFit('bind-1', { + trigger: 'resize', + startedAt: 50, + durationMs: 5000, + cols: 1, + rows: 1, + bufferLines: 1, + scrollback: 1 + }) + noteWebgl('bind-1', 'context-loss') + + const snap = snapshotPanes(200) + eq(snap.writeCount, 0, "the new bind did not inherit the old bind's writes") + eq(snap.fitCount, 0, 'nor its fits') + eq(snap.webglContextLosses, 0, 'nor its webgl events') + eq(snap.ackMsMax, 0, 'nor its ack latency') + eq(snap.paneCount, 1, 'only the live bind is registered') + clearPanes() +} + +console.log('pane registry: write completion cannot drive the counters negative') +{ + clearPanes() + registerPane('p', { registeredAt: 0 }) + noteWriteDone('p', 100, 5) // completion with no matching start + noteWriteDone('p', 0, -100) // a negative/zero measurement + const stats = snapshotPanes(1) + eq(stats.writeInflight, 0, 'inflight floors at 0') + eq(stats.queuedChars, 0, 'queued chars floor at 0') + eq(stats.ackMsMax, 5, 'the max ack is unaffected by a clamp') + clearPanes() +} + +console.log('pane registry: renderer mode re-records in place') +{ + clearPanes() + registerPane('p', { rendererMode: 'webgl', registeredAt: 0 }) + notePaneRenderer('p', 'dom') + eq(snapshotPanes(1).domPanes, 1, 'the pane moved to the dom bucket') + eq(snapshotPanes(1).webglPanes, 0, 'and left the webgl bucket') + clearPanes() +} + +// ---- log line formatting --------------------------------------------------- + +console.log('fit report: metadata only, with a suppressed trailer when needed') +{ + const sample = { + trigger: 'resize', + startedAt: 12345.6, + durationMs: 240.4, + cols: 120, + rows: 40, + bufferLines: 5120, + scrollback: 5000 + } + const plain = formatFitReport(sample, 0, 'abcdef123456') + ok(plain.startsWith('PERF fit pane abcdef '), 'pane id is shortened to 6 chars') + ok(plain.includes('trigger=resize'), 'trigger is present') + ok(plain.includes('240ms'), 'duration is rounded') + ok(plain.includes('120x40'), 'grid is present') + ok(plain.includes('bufferLines=5120'), 'real buffer length is present') + ok(plain.includes('scrollback=5000'), 'scrollback cap is present') + ok(!plain.includes('suppressed='), 'no trailer when nothing was swallowed') + + const gated = formatFitReport(sample, 7, 'abcdef123456') + ok(gated.endsWith('suppressed=7'), 'the swallowed count is the last field') + + const noPane = formatFitReport(sample, 0, '') + ok(!noPane.includes(' pane '), 'an empty pane id is omitted') +} + +console.log('watchdog report: names the kind, the window and the estimate') +{ + const snap = snapshotPanes(0) + const verdict = classifyWatchdogLag({ + lagMs: 5000, + windowStart: 1000, + windowEnd: 8000, + longTasks: [{ startTime: 2000, duration: 3000 }] + }) + ok(verdict !== null, 'verdict built') + const line = formatWatchdogReport(verdict, snap, { + now: 8000, + wallNow: 1_700_000_000_000, + recentLongTasks: [{ startTime: 2000, duration: 3000 }] + }) + ok(line.includes('renderer long task'), 'the kind is spelled out') + ok(line.includes('panes 0'), 'pane count is present') + ok(line.includes('no fit recorded'), 'a missing fit says so instead of printing zeros') + ok(line.includes('after recovery'), 'the log-time caveat is present') + ok(!/[\r\n]/.test(line), 'and the whole report is one line') + + const noTask = classifyWatchdogLag({ lagMs: 5000, windowStart: 1000, windowEnd: 8000, longTasks: [] }) + ok(noTask !== null, 'verdict built without long tasks') + const schedulerLine = formatWatchdogReport(noTask, snap, { + now: 8000, + wallNow: 1_700_000_000_000, + recentLongTasks: [] + }) + ok(schedulerLine.includes('scheduler delay/unclassified'), 'unexplained lag is not called a stall') + ok(schedulerLine.includes('none observed'), 'and says no long task was seen') +} + +console.log('sanitizePerfDetail: one line, capped') +{ + eq(sanitizePerfDetail('a\nb\tc'), 'a b c', 'newlines and tabs are flattened') + eq(sanitizePerfDetail(' '), '', 'whitespace-only detail becomes empty') + const long = sanitizePerfDetail('x'.repeat(200), 10) + eq(long.length, 13, 'long text is truncated with an ellipsis') +} + +// ---- WriteBatcher.pendingChars -------------------------------------------- + +console.log('WriteBatcher: pendingChars is a string length, not bytes') +{ + const calls = [] + const b = new WriteBatcher((text) => calls.push(text), DELAY) + eq(b.pendingChars, 0, 'an empty batcher reports 0') + b.push('abc') + eq(b.pendingChars, 3, 'three ASCII chars = 3') + b.push('中文') + eq(b.pendingChars, 5, 'two CJK chars add 2 code units (NOT 6 bytes)') + ok(b.hasPending(), 'hasPending agrees with the count') + b.push('') + eq(b.pendingChars, 5, 'an empty push changes nothing') + await sleep(DELAY * 4) + eq(calls.length, 1, 'one flush') + eq(b.pendingChars, 0, 'pendingChars clears after the flush') + b.dispose() +} + +console.log('WriteBatcher: dispose drops pendingChars') +{ + const calls = [] + const b = new WriteBatcher((text) => calls.push(text), DELAY) + b.push('dropped') + eq(b.pendingChars, 7, 'the buffer is counted before dispose') + b.dispose() + eq(b.pendingChars, 0, 'dispose clears the count') + await sleep(DELAY * 4) + eq(calls.length, 0, 'and nothing is written') +} + +console.log('WriteBatcher: flushNow clears pendingChars synchronously') +{ + const calls = [] + const b = new WriteBatcher((text) => calls.push(text), DELAY) + b.push('tail') + b.flushNow() + eq(b.pendingChars, 0, 'flushNow empties the count before returning') + eq(calls[0], 'tail', 'and handed the text over') + await sleep(DELAY * 4) + eq(calls.length, 1, 'the cancelled timer never fires again') + b.dispose() +} + +// ---- global budget sanity -------------------------------------------------- + +console.log('the shared gate starts fresh') +{ + resetPerfLogSink() + const gate = currentPerfLogGate() + eq(gate.windowStart, 0, 'no window is open yet') + eq(gate.count, 0, 'and no lines have been counted') +} + +if (failed > 0) { + console.error(`\n[perf-diagnostics] ${failed} check(s) FAILED`) + process.exit(1) +} +console.log('\n[perf-diagnostics] ALL CHECKS PASSED') diff --git a/tests/redos-probes.mjs b/tests/redos-probes.mjs new file mode 100644 index 0000000..e51ad43 --- /dev/null +++ b/tests/redos-probes.mjs @@ -0,0 +1,211 @@ +/** + * ReDoS probe corpus self-test (redos-probes.mjs). + * + * `src/shared/redosProbes.ts` is the corpus a stored rule is executed against + * before it may run in the terminal. It is data, so the test has to prove the + * data does its job: + * + * - the set is versioned, bounded (every probe ≤ the engine's 4KB line limit, + * no more than the plan's ~24 entries) and synthetic — printable ASCII with + * a handful of distinct characters, so nothing here can be recorded + * terminal output or user data; + * - all five backtracking families are present with several fillers, because + * a pattern can be immune to one filler and helpless against the next; + * - every shipped preset and ordinary user rule stays FAR inside the budget + * on the whole corpus (a corpus that false-fails a preset would disable a + * built-in rule for every user); + * - the shapes measured as catastrophic in the plan — including the four the + * static heuristic missed — do NOT finish inside the budget. Those run in a + * child process with a hard timeout: a single `exec` on a hostile pattern + * blocks for seconds to minutes and cannot be interrupted from inside, so + * running them in-process would hang the suite instead of failing it. + * + * Direct .ts import (no esbuild bundle): redosProbes.ts and settings.ts have no + * runtime imports, and Node >= 22.18 strips types itself. + * Run: node tests/redos-probes.mjs (must exit 0; takes a few seconds) + */ +import { spawnSync } from 'node:child_process' +import { PROBE_BUDGET_MS, PROBE_MAX_LEN, PROBE_VERSION, REDOS_PROBES } from '../src/shared/redosProbes.ts' +import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts' + +let failed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 +} + +const SHAPES = ['repeat', 'failing-terminator', 'alternation', 'overlap-class', 'literal-prefix'] + +// ---- 1. the corpus itself --------------------------------------------------- +console.log('[corpus]') +{ + ok(Number.isInteger(PROBE_VERSION) && PROBE_VERSION >= 1, `PROBE_VERSION is a positive integer (${PROBE_VERSION})`) + ok(PROBE_BUDGET_MS === 500, `the single-rule budget is the planned 500ms (${PROBE_BUDGET_MS})`) + ok(PROBE_MAX_LEN === 4096, `the input cap is the engine's 4KB line limit (${PROBE_MAX_LEN})`) + ok(Array.isArray(REDOS_PROBES) && REDOS_PROBES.length > 0, `the corpus is a non-empty array (${REDOS_PROBES.length})`) + ok(REDOS_PROBES.length <= 24, `the corpus stays within the plan's ~24-probe budget (${REDOS_PROBES.length})`) + + const overCap = REDOS_PROBES.filter((p) => p.text.length > PROBE_MAX_LEN) + ok(overCap.length === 0, `no probe exceeds the cap (${overCap.map((p) => p.text.length).join(', ') || 'none'})`) + const atCap = REDOS_PROBES.filter((p) => p.text.length === PROBE_MAX_LEN).length + ok(atCap >= 3, `several probes run at the cap, so a hostile shape cannot finish early (${atCap} at ${PROBE_MAX_LEN})`) + const short = REDOS_PROBES.filter((p) => p.text.length < 1024) + ok(short.length === 0, `every probe is long enough to expose exponential growth (${short.length} under 1KB)`) + + // Synthetic, not recorded: printable ASCII plus a tab head, with a single LF + // allowed only as the FINAL character (the `.`-family probe). No ESC, no CR, + // no non-ASCII — nothing here could be recorded terminal output. + const noisy = REDOS_PROBES.filter((p) => !/^[\x20-\x7e\t]*\n?$/.test(p.text)) + ok(noisy.length === 0, `every probe is printable ASCII with at most a trailing LF (${noisy.length} offending)`) + const wide = REDOS_PROBES.filter((p) => new Set(p.text).size > 8) + ok(wide.length === 0, `no probe carries a rich character mix (max ${Math.max(...REDOS_PROBES.map((p) => new Set(p.text).size))} distinct)`) + + const texts = REDOS_PROBES.map((p) => p.text) + ok(new Set(texts).size === texts.length, 'no probe text is repeated') + + const present = new Set(REDOS_PROBES.map((p) => p.shape)) + for (const shape of SHAPES) { + const n = REDOS_PROBES.filter((p) => p.shape === shape).length + ok(n >= 2, `the ${shape} family is covered by several variants (${n})`) + } + ok(present.size === SHAPES.length, `no probe uses an unknown shape (${[...present].join(', ')})`) + + // The measured `[(](a+)+$` regression only backtracks when the input actually + // contains the literal `(`: without it the pattern fails at offset 0 and looks + // perfectly healthy. The corpus has to carry that head. + const literalHeads = REDOS_PROBES.filter((p) => p.shape === 'literal-prefix' && p.text.startsWith('(')) + ok(literalHeads.length > 0, 'a literal-prefix probe starts with `(`, so `[(](a+)+$` can backtrack') +} + +// ---- 2. legitimate rules stay far inside the budget ------------------------- +console.log('[legit rules]') + +/** Worst single-probe time for one rule over the whole corpus, in ms. Mirrors + * what the future worker does: fresh lastIndex per probe, and a step past a + * zero-width match so an `a*`-style rule cannot spin on one index forever. */ +const corpusCost = (pattern, flags = 'g') => { + const re = new RegExp(pattern, flags) + let worst = 0 + for (const probe of REDOS_PROBES) { + re.lastIndex = 0 + const start = performance.now() + let match + let steps = 0 + while ((match = re.exec(probe.text)) !== null) { + if (match[0] === '') re.lastIndex = match.index + 1 + if (++steps > 20000) break + } + const ms = performance.now() - start + if (ms > worst) worst = ms + } + return worst +} + +{ + const flagOf = (rule) => `g${rule.caseInsensitive ? 'i' : ''}${rule.lineAnchor ? 'm' : ''}` + const slowPresets = DEFAULT_HIGHLIGHT_RULES.filter((rule) => corpusCost(rule.pattern, flagOf(rule)) >= PROBE_BUDGET_MS) + ok( + slowPresets.length === 0, + `no shipped preset would be quarantined by the corpus (${slowPresets.map((r) => r.id).join(', ') || 'none'})` + ) + + // A practical margin on top of the hard budget: the presets are plain keyword + // patterns, so a probe that made one of them take anywhere near the budget + // would mean the corpus itself is wrong, not the rule. + const PRACTICAL_MS = 50 + const worst = DEFAULT_HIGHLIGHT_RULES.map((rule) => [rule.id, corpusCost(rule.pattern, flagOf(rule))]) + const over = worst.filter(([, ms]) => ms >= PRACTICAL_MS) + ok( + over.length === 0, + `every preset stays far under the budget (worst: ${Math.max(...worst.map(([, ms]) => ms)).toFixed(2)}ms < ${PRACTICAL_MS}ms)` + ) + + // Ordinary user rules — the shapes the editor invites — are just as cheap. + const userPatterns = [ + '\\b(?:ERROR|FAILED)\\b', + '\\d+%', + '(? corpusCost(p) >= PROBE_BUDGET_MS) + ok(slowUser.length === 0, `no ordinary user pattern is quarantined (${slowUser.join(' | ') || 'none'})`) +} + +// ---- 3. measured catastrophic shapes do not finish in budget --------------- +console.log('[hostile shapes]') + +/** Hard timeout for a child that runs one pattern over the corpus. Long enough + * that node startup plus a full corpus scan fits (see the control below), short + * enough that several of them do not dominate the suite. */ +const CHILD_TIMEOUT_MS = 800 +const CORPUS_URL = new URL('../src/shared/redosProbes.ts', import.meta.url).href + +/** Run one pattern over the real corpus in a child process. Returns true when + * the child finished on its own; false when it had to be killed. */ +const finishes = (pattern) => { + const code = ` + const { REDOS_PROBES } = await import(${JSON.stringify(CORPUS_URL)}) + const re = new RegExp(${JSON.stringify(pattern)}, 'g') + for (const probe of REDOS_PROBES) { + re.lastIndex = 0 + let match + while ((match = re.exec(probe.text)) !== null) { + if (match[0] === '') re.lastIndex = match.index + 1 + } + } + console.log('finished') + ` + const child = spawnSync(process.execPath, ['--input-type=module', '-e', code], { + encoding: 'utf8', + timeout: CHILD_TIMEOUT_MS + }) + return { finished: child.status === 0 && (child.stdout ?? '').includes('finished'), killed: child.error?.code === 'ETIMEDOUT' } +} + +{ + // Control: a cheap pattern must complete inside the same timeout, otherwise a + // killed child would prove nothing about the pattern. It also exercises the + // child's whole path (startup, .ts import, corpus scan). + const started = performance.now() + const control = finishes('a') + const controlMs = performance.now() - started + ok( + control.finished, + `the control child scans the whole corpus (${controlMs.toFixed(0)}ms of ${CHILD_TIMEOUT_MS}ms, killed=${control.killed})` + ) + ok( + controlMs < CHILD_TIMEOUT_MS * 0.6, + `the control leaves headroom, so a killed child means the pattern, not the machine (${controlMs.toFixed(0)}ms)` + ) + + // `(a+)+$` is the classic. The rest include the plan's static-heuristic misses + // that DO reproduce here (`[(](a+)+$`, `(?:a|a)+$`, `((a|a))+$`), plus the + // `(`-star families that need a line-terminator tail (`(.+)+$`, `(.*)*$`) and + // an inner quantifier that can match empty (`(a*)*b`). All of them compile + // instantly and then block for seconds on a few dozen characters, so no length + // or time guard downstream can help. + // + // The plan's fourth miss, `(\w|\d)+$`, is NOT asserted: it finishes in well + // under a millisecond on this V8 (see the note in redosProbes.ts), so the + // corpus correctly does not quarantine it. The probe set is judged by + // measurement, not by a table of supposedly-dangerous shapes. + const dangerous = ['(a+)+$', '[(](a+)+$', '(?:a|a)+$', '((a|a))+$', '([a-z]+)*$', '(a*)*b', '(.+)+$', '(.*)*$'] + for (const pattern of dangerous) { + const { finished, killed } = finishes(pattern) + ok( + !finished && killed, + `the corpus blows the budget on ${pattern} (finished=${finished} killed=${killed})` + ) + } +} + +if (failed > 0) { + console.error(`\n[redos-probes] ${failed} check(s) FAILED`) + process.exit(1) +} +console.log('\n[redos-probes] ALL CHECKS PASSED') diff --git a/tests/redos-validator.mjs b/tests/redos-validator.mjs new file mode 100644 index 0000000..9449dae --- /dev/null +++ b/tests/redos-validator.mjs @@ -0,0 +1,486 @@ +/** + * Rule-validation service self-test (redos-validator.mjs). + * + * `src/main/redosValidator.ts` is the controlling half of the ReDoS gate: it + * owns the probe worker pool and decides what a rule's verdict is. The corpus + * itself is pinned in `tests/redos-probes.mjs`, and the gate the engines apply + * in `tests/rule-validation.mjs` — this file covers the service in between, and + * the properties that make the gate trustworthy rather than decorative: + * + * - a legal rule finishes the corpus and comes back `passed`; the 22 shipped + * presets are never quarantined (a corpus that failed one would disable a + * built-in rule for every user); + * - a rule that blows the wall-clock budget is terminated from the OUTSIDE and + * reported `quarantined` — the worker cannot stop itself, which is the whole + * reason the budget lives here; + * - the main thread keeps ticking while a hostile pattern blocks its worker + * (a timer that fired late would mean the pool had blocked the process); + * - a worker that cannot be created, crashes, or exits yields `pending` and + * never `quarantined` — "we could not measure it" must not read as "we + * measured it as dangerous"; + * - the pool stays at its concurrency cap, reuses one run for two rules with + * the same pattern, rebuilds a terminated worker and reclaims idle ones; + * - "no worker was needed yet" is not "the worker is broken": a fresh + * validator reports the feature as available, and only an attempted-and- + * failed creation (or a worker that dies on the job) marks it unavailable; + * - a reset (the test seam / the app's quit path) settles whatever was in + * flight in a worker slot as `pending`, so no caller is left awaiting an + * answer that can never arrive; + * - the background pass over stored rules writes proofs back through the + * settings pipeline, and a proof from another environment is normalized to + * `pending` instead of being trusted. + * + * Build: node tests/build-bundles.cjs + * Run: node tests/redos-validator.mjs (must exit 0; ~2s) + */ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { Worker } from 'node:worker_threads' +import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts' +import { PROBE_VERSION, PROBE_BUDGET_MS } from '../src/shared/redosProbes.ts' +import { runtimeKeyOf } from '../src/shared/ruleValidation.ts' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const require = createRequire(import.meta.url) + +const userData = mkdtempSync(join(tmpdir(), 'ot-redos-validator-')) +process.env.OT_STUB_USERDATA = userData + +const validator = require('./.redos-validator.cjs') +const settingsStore = require('./.settings-store.cjs') +const PROBE_WORKER_PATH = join(__dirname, '.redos-probe-worker.cjs') + +let failed = 0 +let passed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 + else passed += 1 +} + +/** The fingerprint the bundled modules installed (same inputs, same function). */ +const RUNTIME = runtimeKeyOf({ + app: '0.0.0-stub', + electron: process.versions.electron ?? '', + v8: process.versions.v8 ?? '' +}) + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** Poll until `check()` is truthy or the deadline passes. */ +const waitFor = async (check, timeoutMs = 4000, stepMs = 20) => { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (check()) return true + await sleep(stepMs) + } + return check() +} + +const request = (pattern, flags = 'g') => ({ key: `${pattern}\u0000${flags}`, pattern, flags }) + +/** The shipped probe worker, in a real thread. */ +const realWorkerFactory = () => { + const worker = new Worker(PROBE_WORKER_PATH) + worker.unref?.() + return worker +} + +/** + * A scriptable stand-in for the worker thread: the test decides what happens + * (reply, stay silent, crash, exit) and can count what the pool did to it. + * `postMessage`/`on`/`terminate` are the only surface the pool uses. + */ +function makeStubWorker(options = {}) { + const listeners = { message: [], error: [], exit: [] } + const stub = { + created: true, + posts: [], + terminated: false, + on(event, listener) { + listeners[event]?.push(listener) + }, + postMessage(message) { + stub.posts.push(message) + if (options.reply === false) return + const delay = options.delayMs ?? 0 + const answer = + options.answer ?? + ((request) => ({ id: request.id, ok: true, probeVersion: PROBE_VERSION, worstMs: 1, totalMs: 1, probes: 24 })) + setTimeout(() => { + if (stub.terminated) return + for (const listener of listeners.message) listener(answer(message)) + }, delay) + }, + terminate() { + stub.terminated = true + }, + emitError(message = 'stub worker failed') { + for (const listener of listeners.error) listener(new Error(message)) + }, + emitExit(code = 1) { + for (const listener of listeners.exit) listener(code) + } + } + return stub +} + +/** A factory that hands out stubs and keeps them for assertions. */ +const stubFactory = (options = {}) => { + const made = [] + const factory = () => { + const stub = makeStubWorker(options) + made.push(stub) + return stub + } + factory.made = made + return factory +} + +// ---- 1. no worker at all ---------------------------------------------------- +console.log('[unavailable]') +{ + // The other half of the flag: a validator that simply has not needed a worker + // yet must NOT report the feature as broken. A default install ships no custom + // rules, so the pool is never created — if that read as "unavailable", every + // user would see a red error for a feature that is working perfectly. + validator.resetRuleValidator() + validator.setValidatorWorkerFactory(realWorkerFactory) + const fresh = validator.getRuleValidationState() + ok(fresh.workerAvailable === true, 'a fresh validator with a factory reports the worker as available') + ok(fresh.pending === 0, `and no shipped preset is waiting for a verdict (pending ${fresh.pending})`) + + validator.resetRuleValidator() + validator.setValidatorWorkerFactory(null) + const results = await validator.validateRules([request('\\bOK\\b')]) + ok(results[0].verdict === 'pending', `no factory → pending, never quarantined (got ${results[0].verdict})`) + ok(validator.getRuleValidationState().workerAvailable === false, 'the state reports the worker as unavailable') + + // A factory that throws is the "build artifact missing" path: the pool must + // report it, not crash the settings pipeline. + validator.resetRuleValidator() + validator.setValidatorWorkerFactory(() => { + throw new Error('probe worker chunk not found') + }) + const report = await validator.handleRuleValidationRequest({ + kind: 'highlight', + items: [{ id: 'a', pattern: '\\bOK\\b' }] + }) + ok(report.results[0].state === 'pending', `an unbuildable worker leaves rules pending (got ${report.results[0].state})`) + ok(report.workerAvailable === false, 'the report says the worker is unavailable') + ok(report.probeVersion === PROBE_VERSION, `the report carries the probe version (${report.probeVersion})`) + ok(report.runtime === RUNTIME, `the report carries the runtime fingerprint (${report.runtime})`) +} + +// ---- 1b. a reset settles in-flight work ------------------------------------- +console.log('[reset with a probe in flight]') +{ + validator.resetRuleValidator() + const factory = stubFactory({ reply: false }) + validator.setValidatorWorkerFactory(factory) + validator.setValidatorBudgets({ ruleBudgetMs: 30_000 }) + const promise = validator.validateRules([request('\\bOK\\b')]) + await waitFor(() => factory.made.length > 0 && factory.made[0].posts.length > 0, 500) + // The worker is holding the task; the reset tears the thread down under it. + // The caller's promise must still settle: a dangling await is exactly the + // shape of a hung shutdown path. + validator.resetRuleValidator() + const settled = await Promise.race([promise, sleep(500).then(() => 'timeout')]) + ok(settled !== 'timeout', 'a reset with a probe in flight does not hang the caller') + ok( + Array.isArray(settled) && settled[0].verdict === 'pending', + `the in-flight task is settled as pending (got ${JSON.stringify(settled)})` + ) + ok(factory.made[0].terminated, 'and its worker was terminated') + ok(validator.getRuleValidationState().workerAvailable === true, 'the reset returns the feature flag to its default (available)') +} + +// ---- 2. the real worker: legal rules pass ----------------------------------- +console.log('[legit rules through the real worker]') +{ + validator.resetRuleValidator() + validator.setValidatorWorkerFactory(realWorkerFactory) + const requests = DEFAULT_HIGHLIGHT_RULES.map((rule) => request(rule.pattern, `g${rule.caseInsensitive ? 'i' : ''}${rule.lineAnchor ? 'm' : ''}`)) + const started = Date.now() + const results = await validator.validateRules(requests) + const elapsed = Date.now() - started + const bad = results.filter((result) => result.verdict !== 'passed') + ok(bad.length === 0, `every shipped preset passes the corpus (${bad.map((r) => r.pattern).join(' | ') || 'none'} failed)`) + const worst = Math.max(...results.map((result) => result.ms ?? 0)) + ok(worst < PROBE_BUDGET_MS / 2, `the slowest preset stays well inside the budget (${worst.toFixed(2)}ms < ${PROBE_BUDGET_MS / 2}ms)`) + ok(elapsed < 3000, `the whole preset set finishes quickly (${elapsed}ms for ${results.length} rules)`) + + const user = await validator.validateRules([request('BUG-(\\d+)'), request('(? result.verdict === 'passed'), 'ordinary user patterns pass too') +} + +// ---- 3. a hostile rule is terminated from the outside ------------------------ +console.log('[hard budget]') +{ + validator.resetRuleValidator() + validator.setValidatorWorkerFactory(realWorkerFactory) + validator.setValidatorBudgets({ ruleBudgetMs: 150 }) + + // Main-thread responsiveness while the worker is stuck: a hostile pattern + // blocks its own thread for seconds, and the pool must not block this one. + let ticks = 0 + const ticker = setInterval(() => { + ticks += 1 + }, 10) + const started = Date.now() + const results = await validator.validateRules([request('(a|a)+$')]) + const elapsed = Date.now() - started + clearInterval(ticker) + ok(results[0].verdict === 'quarantined', `a pattern that blows the budget is quarantined (got ${results[0].verdict})`) + ok(elapsed < 1500, `the verdict lands near the budget, not after the pattern finishes (${elapsed}ms)`) + ok(ticks > 0, `the main thread kept ticking while the worker was blocked (${ticks} ticks in ${elapsed}ms)`) + ok(validator.getRuleValidationState().workerAvailable === true, 'a terminated worker does not mark the feature unavailable') + + // The terminated worker is gone: the next rule gets a fresh thread. + const after = await validator.validateRules([request('\\bOK\\b')]) + ok(after[0].verdict === 'passed', 'the pool rebuilds a terminated worker and keeps serving') +} + +// ---- 4. crashes and exits never look like a hostile rule --------------------- +console.log('[worker failures]') +{ + validator.resetRuleValidator() + const factory = stubFactory({ reply: false }) + validator.setValidatorWorkerFactory(factory) + validator.setValidatorBudgets({ ruleBudgetMs: 5000 }) + const promise = validator.validateRules([request('\\bOK\\b')]) + // Let the pool dispatch, then kill the initial try and its retry: the task gets + // one retry on a fresh worker and then gives up. + await waitFor(() => factory.made.length > 0, 500) + factory.made[0].emitError('boom') + await waitFor(() => factory.made.length > 1, 500) + factory.made[1].emitError('boom again') + const results = await promise + ok(results[0].verdict === 'pending', `a twice-crashed worker yields pending, not quarantined (got ${results[0].verdict})`) + ok(factory.made.length === 2, `the task was retried exactly once (${factory.made.length} workers created)`) + ok(validator.getRuleValidationState().workerAvailable === false, 'a crashing worker marks the feature unavailable') + + // The unavailable flag is not sticky: a reset (which is also the app's quit + // path) restores the default, so a later run does not inherit the verdict. + validator.resetRuleValidator() + ok(validator.getRuleValidationState().workerAvailable === true, 'a reset clears the unavailable flag back to its default') + + // Same for an exit while busy (a thread that dies without an error event). + validator.resetRuleValidator() + const exits = stubFactory({ reply: false }) + validator.setValidatorWorkerFactory(exits) + const exitPromise = validator.validateRules([request('\\bOK\\b')]) + await waitFor(() => exits.made.length > 0, 500) + exits.made[0].emitExit(1) + await waitFor(() => exits.made.length > 1, 500) + exits.made[1].emitExit(1) + const exitResults = await exitPromise + ok(exitResults[0].verdict === 'pending', `an exiting worker yields pending (got ${exitResults[0].verdict})`) +} + +// ---- 5. dedupe, concurrency cap, idle reclamation --------------------------- +console.log('[pool behaviour]') +{ + validator.resetRuleValidator() + const factory = stubFactory({ delayMs: 60 }) + validator.setValidatorWorkerFactory(factory) + validator.setValidatorBudgets({ ruleBudgetMs: 5000, idleMs: 40 }) + + const [a, b] = await Promise.all([ + validator.validateRules([request('\\bOK\\b')]), + validator.validateRules([request('\\bOK\\b')]) + ]) + ok(a[0].verdict === 'passed' && b[0].verdict === 'passed', 'two callers asking for the same rule both get an answer') + ok(factory.made.length === 1, `the second caller shares the in-flight run (${factory.made.length} worker(s) created)`) + ok(factory.made[0].posts.length === 1, `the rule was probed once, not twice (${factory.made[0].posts.length} request(s))`) + + // Concurrency: three rules at once may not open more than two threads. + validator.resetRuleValidator() + const capped = stubFactory({ reply: false }) + validator.setValidatorWorkerFactory(capped) + validator.setValidatorBudgets({ ruleBudgetMs: 4000, maxWorkers: 2 }) + const pending = validator.validateRules([request('A'), request('B'), request('C')]) + await waitFor(() => capped.made.length >= 2, 500) + await sleep(60) + ok(capped.made.length === 2, `the pool stops at its concurrency cap (${capped.made.length} workers for 3 rules)`) + ok(validator.validatorConfig().maxWorkers === 2, 'the cap is the configured one') + for (const stub of capped.made) stub.emitError('done') + await pending + + // Idle reclamation: a worker that has nothing to do is terminated. + validator.resetRuleValidator() + const idler = stubFactory({ delayMs: 0 }) + validator.setValidatorWorkerFactory(idler) + validator.setValidatorBudgets({ idleMs: 40 }) + await validator.validateRules([request('\\bOK\\b')]) + ok(idler.made.length === 1, 'a worker was created for the run') + const reclaimed = await waitFor(() => idler.made[0].terminated, 1000) + ok(reclaimed, 'the idle worker is terminated instead of sitting on a thread') + ok(validator.validatorConfig().ruleBudgetMs === PROBE_BUDGET_MS, `reset restores the production budget (${validator.validatorConfig().ruleBudgetMs}ms)`) +} + +// ---- 6. IPC batch semantics ------------------------------------------------- +console.log('[batch]') +{ + validator.resetRuleValidator() + validator.setValidatorWorkerFactory(realWorkerFactory) + const builtin = DEFAULT_HIGHLIGHT_RULES[0] + const report = await validator.handleRuleValidationRequest({ + kind: 'highlight', + items: [ + { id: 'builtin', pattern: builtin.pattern, caseInsensitive: builtin.caseInsensitive, lineAnchor: builtin.lineAnchor }, + { id: 'custom', pattern: 'BUG-(\\d+)' }, + { id: 'dupe', pattern: 'BUG-(\\d+)' }, + { id: 'empty', pattern: '' }, + { id: 'long', pattern: 'a'.repeat(600) } + ] + }) + const byId = new Map(report.results.map((result) => [result.id, result])) + ok(byId.get('builtin').state === 'builtin', 'a factory preset is reported as built-in, not probed') + ok(byId.get('custom').state === 'passed', 'a custom pattern comes back passed') + ok(byId.get('dupe').state === 'passed', 'a duplicate pattern in the same batch gets the same verdict') + ok(byId.get('empty').state === 'pending', 'an empty pattern gets no verdict') + ok(byId.get('long').state === 'pending', 'a pattern past the length bound gets no verdict') + ok(report.truncated === false, 'a batch inside the cap is not reported as truncated') + ok( + report.results.map((result) => result.id).join(',') === 'builtin,custom,dupe,empty,long', + `results come back in request order (${report.results.map((result) => result.id).join(',')})` + ) + + // The order key is the request INDEX, not the id: two items may legitimately + // share an id (the caller echoes back whatever it was handed), and an + // id-keyed map collapses both onto one rank and reorders the report. + const sameIds = await validator.handleRuleValidationRequest({ + kind: 'highlight', + items: [ + { id: 'same', pattern: '\\bOK\\b' }, + { id: 'other', pattern: '' }, + { id: 'same', pattern: 'BUG-(\\d+)' } + ] + }) + ok( + sameIds.results.map((result) => result.id).join(',') === 'same,other,same', + `repeated ids keep their request positions (${sameIds.results.map((result) => result.id).join(',')})` + ) + + const linkReport = await validator.handleRuleValidationRequest({ + kind: 'link', + items: [{ id: 'link', pattern: 'BUG-(\\d+)' }] + }) + ok(linkReport.results[0].state === 'passed', 'a link rule is probed with the link flag string') + + const hostile = await validator.handleRuleValidationRequest({ + kind: 'highlight', + items: [{ id: 'hostile', pattern: '((a|a))+$' }] + }) + ok(hostile.results[0].state === 'quarantined', `a hostile draft is quarantined over IPC (got ${hostile.results[0].state})`) + + validator.setValidatorBudgets({ ruleBudgetMs: 60 }) + const big = await validator.handleRuleValidationRequest({ + kind: 'highlight', + items: Array.from({ length: validator.MAX_VALIDATE_ITEMS + 5 }, (_v, index) => ({ + id: `#${index}`, + pattern: 'x' + })) + }) + ok(big.truncated === true, 'a batch past the cap is reported as truncated') + ok(big.results.length <= validator.MAX_VALIDATE_ITEMS, `the tail is dropped (${big.results.length} results)`) + ok( + validator.MAX_VALIDATE_ITEMS <= 200, + `the batch cap stays small enough to bound memory (${validator.MAX_VALIDATE_ITEMS})` + ) +} + +// ---- 7. the background pass writes proofs back ------------------------------ +console.log('[settings pass]') +{ + validator.resetRuleValidator() + validator.setValidatorWorkerFactory(realWorkerFactory) + const rule = { + id: 'custom', + pattern: 'BUG-(\\d+)', + enabled: true, + priority: 5, + color: { fg: '#ff0000' } + } + // Written straight to the file: this is the "rules already on disk" path the + // applier runs at startup. + writeFileSync( + join(userData, 'settings.json'), + JSON.stringify({ ...settingsStore.loadSettings(), highlightRules: [rule] }, null, 2), + 'utf8' + ) + const stored = settingsStore.loadSettings() + ok(stored.highlightRules[0].validation === undefined, 'a rule without a proof starts unproven') + + validator.validateSettingsRules(stored) + const proved = await waitFor(() => { + const reloaded = settingsStore.loadSettings() + return reloaded.highlightRules[0].validation?.status === 'passed' + }, 4000) + const reloaded = settingsStore.loadSettings() + const proof = reloaded.highlightRules[0].validation + ok(proved, `the background pass writes a passed proof back to settings (${JSON.stringify(proof)})`) + ok(proof?.pattern === rule.pattern, 'the proof names the exact pattern it was obtained for') + ok(proof?.flags === 'g', `the proof carries the engine's flag string (${proof?.flags})`) + ok(proof?.probeVersion === PROBE_VERSION, 'the proof carries the current probe version') + ok(proof?.runtime === RUNTIME, `the proof carries the runtime it was measured in (${proof?.runtime})`) + + // Idempotence: a second pass over the same rules finds nothing to do, so the + // write-back cannot loop through the settings broadcast. + const before = readFileSync(join(userData, 'settings.json'), 'utf8') + validator.validateSettingsRules(reloaded) + await sleep(200) + ok(readFileSync(join(userData, 'settings.json'), 'utf8') === before, 'a second pass over proved rules writes nothing') + + // A proof from another environment is not trusted: the store normalizes it to + // pending, and the pass then re-proves it. + const foreign = { + ...reloaded, + highlightRules: [{ ...rule, validation: { ...proof, runtime: 'other-app|other-electron|other-v8' } }] + } + writeFileSync(join(userData, 'settings.json'), JSON.stringify(foreign, null, 2), 'utf8') + const normalized = settingsStore.loadSettings() + ok( + normalized.highlightRules[0].validation?.status === 'pending', + `a proof from another build is normalized to pending (got ${normalized.highlightRules[0].validation?.status})` + ) + validator.validateSettingsRules(normalized) + const reproved = await waitFor(() => settingsStore.loadSettings().highlightRules[0].validation?.runtime === RUNTIME, 4000) + ok(reproved, 'the pass re-proves a rule whose verdict no longer applies') + + // A hostile stored rule ends up quarantined, and stays that way: the stored + // status settles the question, so a settings save does not re-run a 500ms + // probe over and over. + validator.setValidatorBudgets({ ruleBudgetMs: 150 }) + writeFileSync( + join(userData, 'settings.json'), + JSON.stringify({ ...settingsStore.loadSettings(), highlightRules: [{ ...rule, pattern: '(a|a)+$' }] }, null, 2), + 'utf8' + ) + const hostile = settingsStore.loadSettings() + validator.validateSettingsRules(hostile) + const quarantined = await waitFor(() => settingsStore.loadSettings().highlightRules[0].validation?.status === 'quarantined', 4000) + ok(quarantined, 'a stored hostile rule is quarantined by the background pass') + const state = validator.getRuleValidationState() + ok(state.quarantined >= 1, `the aggregate state counts the quarantined rule (${state.quarantined})`) + validator.validateSettingsRules(settingsStore.loadSettings()) + await sleep(200) + ok( + settingsStore.loadSettings().highlightRules[0].validation?.status === 'quarantined', + 'a quarantined rule is not re-probed on the next pass' + ) +} + +validator.shutdownRuleValidator() +rmSync(userData, { recursive: true, force: true }) + +console.log(`\n[redos-validator] ${passed} passed, ${failed} failed`) +if (failed > 0) { + console.error(`\n[redos-validator] ${failed} check(s) FAILED`) + process.exit(1) +} +console.log('\n[redos-validator] ALL CHECKS PASSED') diff --git a/tests/rule-validation.mjs b/tests/rule-validation.mjs new file mode 100644 index 0000000..ad54dc4 --- /dev/null +++ b/tests/rule-validation.mjs @@ -0,0 +1,418 @@ +/** + * Rule-validation gate self-test (rule-validation.mjs). + * + * `src/shared/ruleValidation.ts` is the fail-closed front door: a stored pattern + * runs only with a proof that matches it exactly, or as a factory built-in on + * the whitelist. It is shared by the highlight engine and the link-rule engine, + * so its decisions are pinned here rather than through either caller: + * + * - the shape cleaner keeps exactly the four known fields and rejects + * everything else (a hand-edited settings.json can put anything under + * `validation`), while a stale-but-intact record survives it — staleness is + * the gate's business, not the cleaner's; + * - the flag spelling is the one both engines compile with, so a proof can + * never be compared against flags the rule does not run under; + * - the truth table is fail-closed in every direction: pending, quarantined, + * missing, expired probe version, edited pattern, edited flags; + * - the built-in whitelist is DERIVED from the shipped presets (no hand-copied + * pattern list) and exempts them without a proof, while a preset whose + * pattern or flags were edited loses the exemption; + * - link rules get no whitelist at all, and their gate runs inside + * `compileLinkRules` — the function the terminal's link provider calls. + * + * Direct .ts import (no esbuild bundle): ruleValidation.ts is zero-import and + * linkRules.ts reaches for it through an explicit `.ts` specifier (Node's ESM + * resolver does not try extensions). settings.ts has no runtime imports. + * Run: node tests/rule-validation.mjs (must exit 0) + */ +import { + LINK_RULE_FLAGS, + MAX_PATTERN_LEN, + highlightFlags, + makeBuiltinWhitelist, + needsRuleValidation, + normalizeRuleValidation, + ruleKeyOf, + ruleRunState, + sanitizeRuleValidation, + setRuleRuntimeKey, + validationAllows +} from '../src/shared/ruleValidation.ts' +import { PROBE_VERSION } from '../src/shared/redosProbes.ts' +import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts' +import { compileLinkRules, sanitizeLinkRules } from '../src/shared/linkRules.ts' + +let failed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 +} + +/** + * The environment the proofs in this file belong to. The main process installs + * its own fingerprint at startup and the renderer is handed the same string + * over IPC; here it is a literal, which also makes the runtime dimension + * testable — a proof is only evidence for the environment it was measured in. + */ +const RUNTIME = 'test-app|test-electron|test-v8' +setRuleRuntimeKey(RUNTIME) + +/** A well-formed proof for one pattern + flags. */ +const proof = (pattern, flags = 'g', over = {}) => ({ + status: 'passed', + pattern, + flags, + probeVersion: PROBE_VERSION, + runtime: RUNTIME, + ...over +}) + +// ---- 1. shape cleaner ------------------------------------------------------- +console.log('[shape cleaner]') +{ + for (const status of ['passed', 'pending', 'quarantined']) { + const raw = proof('a', 'g', { status }) + const out = sanitizeRuleValidation(raw) + ok(out?.status === status, `a well-formed '${status}' record survives`) + } + + { + const raw = proof('a', 'g', { junk: 'x', nested: { deep: true } }) + const out = sanitizeRuleValidation(raw) + ok(out !== undefined, 'a well-formed record with extra fields is kept') + ok( + JSON.stringify(Object.keys(out).sort()) === JSON.stringify(['flags', 'pattern', 'probeVersion', 'runtime', 'status']), + `only the five known fields survive (${Object.keys(out).join(', ')})` + ) + ok(out !== raw, 'the cleaner returns a fresh object rather than the input') + ok(raw.junk === 'x', 'the cleaner does not mutate the input') + } + + { + // Staleness is not a shape problem: the record is kept and the GATE refuses + // it. Erasing it here would lose the evidence a settings page wants to show. + const stale = sanitizeRuleValidation(proof('a', 'g', { probeVersion: PROBE_VERSION - 1 })) + ok(stale?.probeVersion === PROBE_VERSION - 1, 'a record from an older probe set is kept as-is') + } + + const rejected = [ + ['undefined', undefined], + ['null', null], + ['a string', 'passed'], + ['a number', 1], + ['an array', []], + ['an empty object', {}], + ['a missing status', { pattern: 'a', flags: 'g', probeVersion: 1 }], + ['an unknown status', proof('a', 'g', { status: 'trusted' })], + ['a non-string pattern', proof(7, 'g')], + ['a missing pattern', proof('a', 'g', { pattern: undefined })], + ['a non-string flags', proof('a', 7)], + ['a missing probeVersion', proof('a', 'g', { probeVersion: undefined })], + ['a string probeVersion', proof('a', 'g', { probeVersion: '1' })], + ['a NaN probeVersion', proof('a', 'g', { probeVersion: Number.NaN })], + ['an infinite probeVersion', proof('a', 'g', { probeVersion: Number.POSITIVE_INFINITY })], + // A record written before proofs carried a runtime cannot say which engine + // measured it, so it is not evidence for any environment — the rule lands as + // `pending` and is re-proved, which is the point of the field. + ['a missing runtime', proof('a', 'g', { runtime: undefined })], + ['an empty runtime', proof('a', 'g', { runtime: '' })], + ['a non-string runtime', proof('a', 'g', { runtime: 7 })] + ] + for (const [label, value] of rejected) { + ok(sanitizeRuleValidation(value) === undefined, `${label} is rejected`) + } + + ok(MAX_PATTERN_LEN === 500, `the shared pattern bound is 500 (${MAX_PATTERN_LEN})`) +} + +// ---- 2. flag spelling ------------------------------------------------------- +console.log('[flags]') +{ + const cases = [ + ['no switches', undefined, undefined, 'g'], + ['case-insensitive', true, undefined, 'gi'], + ['line-anchored', undefined, true, 'gm'], + ['both', true, true, 'gim'], + ['a truthy non-boolean stays off', 1, 'yes', 'g'] + ] + for (const [label, ci, la, want] of cases) { + const got = highlightFlags(ci, la) + ok(got === want, `${label} compiles as '${want}' (got '${got}')`) + } + + ok(LINK_RULE_FLAGS === 'g', `link rules compile as plain 'g' (${LINK_RULE_FLAGS})`) + { + // The gate compares against this constant and `compileLinkRules` compiles + // with it, so the two cannot drift apart into "proved under X, runs under Y". + const [compiled] = compileLinkRules([ + { id: 'r', pattern: 'x', template: 'https://x', validation: proof('x', LINK_RULE_FLAGS) } + ]) + ok(compiled?.regex.flags === LINK_RULE_FLAGS, `a compiled link regex uses exactly '${LINK_RULE_FLAGS}'`) + } +} + +// ---- 3. the gate ------------------------------------------------------------ +console.log('[gate]') +{ + const cases = [ + ['no proof at all', 'ERROR', 'g', undefined, false], + ['a matching passed proof', 'ERROR', 'g', proof('ERROR'), true], + ['a pending proof', 'ERROR', 'g', proof('ERROR', 'g', { status: 'pending' }), false], + ['a quarantined proof', 'ERROR', 'g', proof('ERROR', 'g', { status: 'quarantined' }), false], + ['a proof for another pattern', 'ERROR', 'g', proof('WARNING'), false], + ['a proof for another flag string', 'ERROR', 'gi', proof('ERROR', 'g'), false], + ['a proof from an older probe set', 'ERROR', 'g', proof('ERROR', 'g', { probeVersion: PROBE_VERSION - 1 }), false], + ['a proof from a newer probe set', 'ERROR', 'g', proof('ERROR', 'g', { probeVersion: PROBE_VERSION + 1 }), false], + // The runtime is part of the proof: the same pattern measured on another V8 + // (another app build) says nothing about this one. + ['a proof from another runtime', 'ERROR', 'g', proof('ERROR', 'g', { runtime: 'other|other|other' }), false], + ['a proof for a prefixed pattern', 'ERROR', 'g', proof('^ERROR'), false] + ] + for (const [label, pattern, flags, validation, want] of cases) { + const got = validationAllows(pattern, flags, validation, PROBE_VERSION) + ok(got === want, `${label} → ${want} (got ${got})`) + } + + // The engine passes a whitelist; link rules pass none. Everything above was + // "no whitelist", so the empty-set case must behave identically. + ok( + !validationAllows('ERROR', 'g', undefined, PROBE_VERSION, new Set()), + 'an empty whitelist changes nothing' + ) + + // With no fingerprint installed there is no environment a proof could belong + // to, so the gate refuses every one of them rather than accepting a verdict + // from somewhere unknown. + setRuleRuntimeKey('') + ok( + !validationAllows('ERROR', 'g', proof('ERROR'), PROBE_VERSION), + 'an uninstalled runtime refuses even a matching proof (fail-closed)' + ) + setRuleRuntimeKey(RUNTIME) +} + +// ---- 3b. run state, normalization and the work predicate -------------------- +console.log('[run state]') +{ + const whitelist = makeBuiltinWhitelist(DEFAULT_HIGHLIGHT_RULES) + const sample = DEFAULT_HIGHLIGHT_RULES[0] + const sampleFlags = highlightFlags(sample.caseInsensitive, sample.lineAnchor) + + ok( + ruleRunState(sample.pattern, sampleFlags, undefined, PROBE_VERSION, whitelist) === 'builtin', + 'a factory preset reads as built-in' + ) + ok(ruleRunState('ERROR', 'g', proof('ERROR'), PROBE_VERSION) === 'passed', 'a matching passed proof reads as passed') + ok(ruleRunState('ERROR', 'g', undefined, PROBE_VERSION) === 'pending', 'no proof reads as pending') + ok( + ruleRunState('ERROR', 'g', proof('ERROR', 'g', { status: 'pending' }), PROBE_VERSION) === 'pending', + 'a pending record reads as pending' + ) + ok( + ruleRunState('ERROR', 'g', proof('ERROR', 'g', { status: 'quarantined' }), PROBE_VERSION) === 'quarantined', + 'a quarantined record reads as quarantined' + ) + ok( + ruleRunState('WARNING', 'g', proof('ERROR', 'g', { status: 'quarantined' }), PROBE_VERSION) === 'pending', + 'a quarantined record for another pattern does not label this one' + ) + ok( + ruleRunState('ERROR', 'g', proof('ERROR', 'g', { status: 'quarantined', runtime: 'other' }), PROBE_VERSION) === 'pending', + 'a quarantine from another runtime reads as pending' + ) + + // Normalization: what the settings store runs on every load and save. + ok( + normalizeRuleValidation(proof('ERROR'), 'ERROR', 'g', PROBE_VERSION)?.status === 'passed', + 'a proof that still applies is kept untouched' + ) + { + const stale = normalizeRuleValidation(proof('ERROR'), 'ERROR', 'gi', PROBE_VERSION) + ok(stale?.status === 'pending', 'a proof for other flags is rewritten to pending') + ok(stale?.flags === 'gi' && stale?.pattern === 'ERROR', 'the rewritten record describes the rule as it stands') + ok(stale?.runtime === RUNTIME && stale?.probeVersion === PROBE_VERSION, 'the rewritten record names the current environment') + ok( + sanitizeRuleValidation(stale) !== undefined, + 'the rewritten record is itself a well-formed record (not "corrupt data" on the next load)' + ) + } + ok( + normalizeRuleValidation(proof('ERROR'), 'ERROR', 'g', PROBE_VERSION + 1)?.status === 'pending', + 'a proof from another probe set is rewritten to pending' + ) + ok( + normalizeRuleValidation(proof('ERROR', 'g', { runtime: 'other' }), 'ERROR', 'g', PROBE_VERSION)?.status === 'pending', + 'a proof from another runtime is rewritten to pending' + ) + ok(normalizeRuleValidation(undefined, 'ERROR', 'g', PROBE_VERSION) === undefined, 'no proof stays no proof') + { + setRuleRuntimeKey('') + const untouched = normalizeRuleValidation(proof('ERROR', 'g', { runtime: 'other' }), 'ERROR', 'g', PROBE_VERSION) + ok(untouched?.runtime === 'other', 'without a fingerprint the record is left alone instead of being stamped with nothing') + setRuleRuntimeKey(RUNTIME) + } + + // The validator's work predicate: probe what has no verdict, never re-probe a + // verdict that still applies (a write-back would otherwise loop through the + // settings broadcast). + ok(needsRuleValidation('ERROR', 'g', undefined, PROBE_VERSION), 'a rule with no proof needs validation') + ok(needsRuleValidation('ERROR', 'g', proof('ERROR', 'g', { status: 'pending' }), PROBE_VERSION), 'a pending rule needs validation') + ok( + needsRuleValidation('ERROR', 'g', proof('ERROR', 'g', { runtime: 'other' }), PROBE_VERSION), + 'a rule whose verdict belongs to another runtime needs validation' + ) + ok(!needsRuleValidation('ERROR', 'g', proof('ERROR'), PROBE_VERSION), 'a proved rule does not need validation') + ok( + !needsRuleValidation('ERROR', 'g', proof('ERROR', 'g', { status: 'quarantined' }), PROBE_VERSION), + 'a quarantined rule is not probed again on every settings change' + ) + + ok(ruleKeyOf('a', 'g') !== ruleKeyOf('a', 'gi'), 'the rule key separates flag strings') + ok(ruleKeyOf('a', 'g') === ruleKeyOf('a', 'g'), 'the rule key is stable for the same rule') +} + +// ---- 4. built-in whitelist -------------------------------------------------- +console.log('[builtin whitelist]') +{ + const whitelist = makeBuiltinWhitelist(DEFAULT_HIGHLIGHT_RULES) + ok(DEFAULT_HIGHLIGHT_RULES.length > 20, `the presets are the whitelist source (${DEFAULT_HIGHLIGHT_RULES.length} rules)`) + ok( + whitelist.size === DEFAULT_HIGHLIGHT_RULES.length, + `every preset contributes its own key, none collide (${whitelist.size} keys)` + ) + + // Derived, not hand-copied: no preset may be missing, under its OWN flags. + const missing = DEFAULT_HIGHLIGHT_RULES.filter( + (rule) => !validationAllows(rule.pattern, highlightFlags(rule.caseInsensitive, rule.lineAnchor), undefined, PROBE_VERSION, whitelist) + ) + ok(missing.length === 0, `every preset runs without a proof (missing: ${missing.map((r) => r.id).join(', ') || 'none'})`) + + // A preset is exempt because of the EXACT pattern + flags, so an edited copy + // loses the exemption and has to earn a proof like any other rule. + const sample = DEFAULT_HIGHLIGHT_RULES[0] + ok(sample !== undefined, 'there is a preset to edit') + ok( + !validationAllows(sample.pattern, 'gi', undefined, PROBE_VERSION, whitelist) || + highlightFlags(sample.caseInsensitive, sample.lineAnchor) === 'gi', + 'a preset pattern under a DIFFERENT flag string is not exempt' + ) + ok( + !validationAllows(`${sample.pattern}x`, highlightFlags(sample.caseInsensitive, sample.lineAnchor), undefined, PROBE_VERSION, whitelist), + 'a preset pattern with an edited pattern is not exempt' + ) + ok( + !validationAllows('(a+)+$', 'g', undefined, PROBE_VERSION, whitelist), + 'a user pattern is not exempt just for looking like something' + ) + ok( + !validationAllows('', 'g', undefined, PROBE_VERSION, whitelist), + 'the empty pattern is never exempt' + ) + + // The exemption is about the pattern, not about the proof: a preset stays + // runnable whatever state a stale record happens to be in. + ok( + validationAllows(sample.pattern, highlightFlags(sample.caseInsensitive, sample.lineAnchor), undefined, PROBE_VERSION + 5, whitelist), + 'a preset is exempt regardless of the probe version' + ) + + // ...and a user rule still needs its proof even when a whitelist is supplied. + ok( + validationAllows('BUG-\\d+', 'g', proof('BUG-\\d+'), PROBE_VERSION, whitelist), + 'a proven user rule runs alongside the whitelist' + ) + ok( + !validationAllows('BUG-\\d+', 'g', undefined, PROBE_VERSION, whitelist), + 'an unproven user rule does not run just because a whitelist exists' + ) +} + +// ---- 5. link rules: gate, no whitelist -------------------------------------- +console.log('[link gate]') +{ + const base = { id: 'r', pattern: 'BUG-(\\d+)', template: 'https://tracker/$1' } + const compiled = (over) => compileLinkRules([{ ...base, ...over }]) + + ok(compiled({}).length === 0, 'an unproven link rule is not compiled') + ok(compiled({ validation: proof(base.pattern) }).length === 1, 'a proven link rule is compiled') + ok(compiled({ validation: proof(base.pattern, 'g', { status: 'pending' }) }).length === 0, 'a pending link rule is not compiled') + ok(compiled({ validation: proof(base.pattern, 'g', { status: 'quarantined' }) }).length === 0, 'a quarantined link rule is not compiled') + ok( + compiled({ validation: proof(base.pattern, 'g', { probeVersion: PROBE_VERSION - 1 }) }).length === 0, + 'a link proof from an older probe set does not compile' + ) + ok(compiled({ validation: proof('BUG-\\d+') }).length === 0, 'a proof for an edited pattern does not compile') + ok(compiled({ validation: proof(base.pattern, 'gi') }).length === 0, 'a proof under other flags does not compile') + ok(compiled({ enabled: false, validation: proof(base.pattern) }).length === 0, 'a disabled rule stays off even with a proof') + + { + // No built-in exemption on this side: nothing in a link rule ships with the + // app, so a preset pattern pasted into a link rule is just another user + // pattern and needs its own proof. + const preset = DEFAULT_HIGHLIGHT_RULES.find((rule) => rule.id === 'ipv4') + ok(preset !== undefined, 'the ipv4 preset is available for the paste test') + const pasted = compileLinkRules([{ id: 'p', pattern: preset.pattern, template: 'https://x/$0' }]) + ok(pasted.length === 0, 'a link rule reusing a preset pattern is not exempt') + const proved = compileLinkRules([{ id: 'p', pattern: preset.pattern, template: 'https://x/$0', validation: proof(preset.pattern) }]) + ok(proved.length === 1, 'the same link rule compiles once it holds its own proof') + } + + { + // Rule order is what decides which rule claims a span first, so the gate may + // only remove rules, never reorder the survivors. + const rules = [ + { id: 'a', pattern: 'A', template: 'https://a', validation: proof('A') }, + { id: 'b', pattern: 'B', template: 'https://b' }, + { id: 'c', pattern: 'C', template: 'https://c', validation: proof('C') } + ] + const out = compileLinkRules(rules) + ok( + out.map((r) => r.id).join(',') === 'a,c', + `the gate filters without reordering (got ${out.map((r) => r.id).join(',')})` + ) + } + + ok(compileLinkRules(undefined).length === 0, 'an absent list still compiles to nothing') +} + +// ---- 6. the link load path keeps the proof's shape -------------------------- +console.log('[link sanitize]') +{ + const raw = (over) => ({ id: 'r1', pattern: 'BUG-(\\d+)', template: 'https://x/$1', ...over }) + + { + const out = sanitizeLinkRules([raw({ validation: proof('BUG-(\\d+)', 'g', { junk: 1 }) })]) + ok(out.length === 1, 'a rule with a proof survives the sanitizer') + ok(out[0].validation !== undefined, 'a well-formed proof is kept') + ok(out[0].validation.junk === undefined, 'extra fields are dropped from the proof') + } + + { + const warnings = [] + const out = sanitizeLinkRules([raw({ validation: { status: 'nonsense' } })], (m) => warnings.push(m)) + ok(out.length === 1, 'a malformed proof does not cost the user the rule') + ok(out[0].validation === undefined, 'a malformed proof is dropped') + ok( + warnings.some((m) => m.includes('validation')), + `the drop is reported to the caller (${warnings.join(' | ') || 'no warning'})` + ) + } + + { + const out = sanitizeLinkRules([raw()]) + ok(out[0].validation === undefined, 'a rule without a proof stays without one (not defaulted to passed)') + } + + { + // End to end through the load path: sanitize keeps the proof, the gate then + // accepts it — the two must not disagree about the flag string. + const loaded = sanitizeLinkRules([raw({ validation: proof('BUG-(\\d+)', LINK_RULE_FLAGS) })]) + ok(compileLinkRules(loaded).length === 1, 'a sanitized proven rule still compiles') + } +} + +if (failed > 0) { + console.error(`\n[rule-validation] ${failed} check(s) FAILED`) + process.exit(1) +} +console.log('\n[rule-validation] ALL CHECKS PASSED') diff --git a/tests/settings-store.mjs b/tests/settings-store.mjs index e7e220b..9b874d5 100644 --- a/tests/settings-store.mjs +++ b/tests/settings-store.mjs @@ -21,6 +21,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createRequire } from 'node:module' import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts' +import { MAX_PATTERN_LEN, runtimeKeyOf } from '../src/shared/ruleValidation.ts' +import { PROBE_VERSION } from '../src/shared/redosProbes.ts' const require = createRequire(import.meta.url) const userData = mkdtempSync(join(tmpdir(), 'ot-settings-')) @@ -144,6 +146,131 @@ console.log('[highlight rules]') ok(log.includes('highlightRules: not an array'), 'the fallback is recorded in settings-warnings.log') } +// ---- 2b. the internal validation proof -------------------------------------- +// `validation` is internal metadata written by the rule validator, not user +// input. The store keeps a well-formed record — the compile gate re-checks it +// against the rule's pattern, its flags, the current probe version and the +// running build — and drops a malformed one with a trace, without costing the +// user the rule itself. A proof that is well-formed but no longer applies (an +// older corpus, another environment, an edited pattern) is rewritten to +// `pending` rather than carried forward claiming to be a verdict. +console.log('[rule validation]') +{ + /** The fingerprint the bundled store installed (same inputs, same function). */ + const runtime = runtimeKeyOf({ + app: '0.0.0-stub', + electron: process.versions.electron ?? '', + v8: process.versions.v8 ?? '' + }) + const proof = (over = {}) => ({ + status: 'passed', + pattern: '\\bERROR\\b', + flags: 'g', + probeVersion: PROBE_VERSION, + runtime, + ...over + }) + + const kept = (await roundTrip({ highlightRules: [rule({ validation: proof() })] })).highlightRules + ok(kept.length === 1 && kept[0].validation?.status === 'passed', 'a well-formed proof survives the round trip') + ok( + JSON.stringify(Object.keys(kept[0].validation).sort()) === + JSON.stringify(['flags', 'pattern', 'probeVersion', 'runtime', 'status']), + `only the five known fields survive (${Object.keys(kept[0].validation).join(', ')})` + ) + + const shaped = (await roundTrip({ highlightRules: [rule({ validation: proof({ junk: 1 }) })] })).highlightRules + ok(shaped[0].validation?.junk === undefined, 'extra fields are dropped from the proof') + + for (const [label, bad] of [ + ['a string', 'passed'], + ['an unknown status', proof({ status: 'trusted' })], + ['a non-string pattern', proof({ pattern: 7 })], + ['a non-numeric probe version', proof({ probeVersion: '1' })], + ['a missing runtime', proof({ runtime: undefined })], + ['an empty runtime', proof({ runtime: '' })], + ['a missing status', { pattern: '\\bERROR\\b', flags: 'g', probeVersion: PROBE_VERSION, runtime }] + ]) { + const out = (await roundTrip({ highlightRules: [rule({ validation: bad })] })).highlightRules + ok(out.length === 1, `a ${label} proof does not cost the user the rule`) + ok(out[0].validation === undefined, `a ${label} proof is dropped`) + } + + // Stale verdicts: an upgrade (new corpus or new build) must not leave a rule + // claiming to have passed something it never ran against. + const olderCorpus = (await roundTrip({ + highlightRules: [rule({ validation: proof({ probeVersion: PROBE_VERSION - 1 }) })] + })).highlightRules + ok(olderCorpus[0].validation?.status === 'pending', 'a proof from an older probe set becomes pending') + ok(olderCorpus[0].validation?.probeVersion === PROBE_VERSION, 'the pending record names the current probe set') + + const otherBuild = (await roundTrip({ + highlightRules: [rule({ validation: proof({ runtime: 'other|other|other' }) })] + })).highlightRules + ok(otherBuild[0].validation?.status === 'pending', 'a proof from another build becomes pending') + ok(otherBuild[0].validation?.runtime === runtime, 'the pending record names the running build') + + const otherFlags = (await roundTrip({ + highlightRules: [rule({ caseInsensitive: true, validation: proof() })] + })).highlightRules + ok(otherFlags[0].validation?.status === 'pending', 'a proof for other flags becomes pending') + ok(otherFlags[0].validation?.flags === 'gi', 'the pending record carries the flags the rule now compiles with') + + // A quarantined verdict that still applies is evidence, not a gap: it stays. + const quarantined = (await roundTrip({ + highlightRules: [rule({ validation: proof({ status: 'quarantined' }) })] + })).highlightRules + ok(quarantined[0].validation?.status === 'quarantined', 'a current quarantine is kept as it stands') + + // Same contract for link rules, which go through the sanitizer instead. + const linkKept = (await roundTrip({ + linkRules: [{ id: 'l1', pattern: 'BUG-(\\d+)', template: 'https://x/$1', validation: proof({ pattern: 'BUG-(\\d+)' }) }] + })).linkRules + ok(linkKept[0].validation?.status === 'passed', 'a well-formed link-rule proof survives the round trip') + const linkStale = (await roundTrip({ + linkRules: [ + { + id: 'l1', + pattern: 'BUG-(\\d+)', + template: 'https://x/$1', + validation: proof({ pattern: 'BUG-(\\d+)', runtime: 'other|other|other' }) + } + ] + })).linkRules + ok(linkStale[0].validation?.status === 'pending', 'a link-rule proof from another build becomes pending') + + { + // The hand-edited case is the one that needs a trace: the record has to be + // ON DISK when the store reads it (a save sanitizes on the way out and would + // leave nothing to warn about), so write the file directly. + writeFileSync( + join(userData, 'settings.json'), + JSON.stringify({ highlightRules: [rule({ validation: { status: 'trusted' } })] }), + 'utf8' + ) + const loaded = store.loadSettings().highlightRules + ok( + loaded.length === 1 && loaded[0].validation === undefined, + 'a malformed proof in a hand-edited file is dropped on load' + ) + const proofLog = existsSync(join(userData, 'settings-warnings.log')) + ? readFileSync(join(userData, 'settings-warnings.log'), 'utf8') + : '' + ok(proofLog.includes('.validation'), 'the drop is recorded in settings-warnings.log') + } +} + +// ---- 2c. the pattern length bound ------------------------------------------- +// The engine refuses a pattern over MAX_PATTERN_LEN anyway; refusing it at load +// keeps the paste out of the settings file and leaves a trace. +console.log('[pattern length]') +{ + const over = (await roundTrip({ highlightRules: [rule({ pattern: 'a'.repeat(MAX_PATTERN_LEN + 1) })] })).highlightRules + ok(over.length === 0, `a pattern over ${MAX_PATTERN_LEN} characters is refused at load`) + const atLimit = (await roundTrip({ highlightRules: [rule({ pattern: 'a'.repeat(MAX_PATTERN_LEN) })] })).highlightRules + ok(atLimit.length === 1, `a pattern of exactly ${MAX_PATTERN_LEN} characters is kept`) +} + // ---- 3. value bands --------------------------------------------------------- console.log('[value bands]') { diff --git a/tests/worker-artifact.mjs b/tests/worker-artifact.mjs new file mode 100644 index 0000000..c6f678c --- /dev/null +++ b/tests/worker-artifact.mjs @@ -0,0 +1,172 @@ +/** + * Probe-worker build artifact smoke test (worker-artifact.mjs). + * + * The ReDoS gate is only real if the SHIPPED worker chunk is real. The pool's + * factory is the `?nodeWorker` import in `main/index.ts`, which electron-vite + * emits into `out/main` — and nothing else in the suite ever loads that file: + * `tests/redos-validator.mjs` spawns its own esbuild bundle of the same source, + * so a build that stopped emitting the worker (a renamed entry, a plugin that + * no longer fires, a broken chunk) would leave every unit test green while the + * packaged app reported every custom rule as "pending" forever. + * + * So this is a BUILD test, not a unit test: it runs against `out/main` and is + * wired into `npm run build` / `npm run dist` / `npm run dist:dir` (after the + * build, before electron-builder). It is deliberately NOT in `npm test`: CI runs + * `npm test` before `npm run build`, so a test that requires build output would + * fail a clean checkout. + * + * What it proves, in a real thread: the artifact loads, answers a benign pattern + * within budget with the corpus size and probe version the source declares, and + * reports a pattern that does not compile as `invalid-regex` instead of crashing. + * + * Run: node tests/worker-artifact.mjs (must exit 0; needs a completed build) + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Worker } from 'node:worker_threads' +import { PROBE_BUDGET_MS, PROBE_VERSION, REDOS_PROBES } from '../src/shared/redosProbes.ts' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const OUT_MAIN = join(__dirname, '..', 'out', 'main') + +/** How long the artifact gets to answer one request before it counts as broken. */ +const REPLY_TIMEOUT_MS = 10_000 + +/** + * Content fallback for the case where the emitted chunk no longer looks like the + * worker by name (a build-tool rename must not silently turn this test into a + * no-op). `parentPort` is the worker ENTRY marker — the corpus literal is not + * usable here, because electron-vite emits it as its own shared data chunk that + * is `require`d by both the worker and the main bundle. + */ +const WORKER_MARKERS = ['parentPort', 'worker_threads'] + +let failed = 0 +let passed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed += 1 + else passed += 1 +} + +/** Every `.js`/`.cjs`/`.mjs` file under a directory, recursively. */ +function listScripts(dir) { + const out = [] + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return out + } + for (const entry of entries) { + const full = join(dir, entry.name) + if (entry.isDirectory()) out.push(...listScripts(full)) + else if (/\.(js|cjs|mjs)$/.test(entry.name)) out.push(full) + } + return out +} + +/** + * The emitted worker chunk: by name when the build still names it after the + * source file, otherwise by the content markers a worker entry point must carry. + */ +function findWorkerArtifact(scripts) { + const byName = scripts.filter((file) => /redosProbeWorker/i.test(relative(OUT_MAIN, file))) + if (byName.length > 0) return { files: byName, how: 'file name' } + const byContent = scripts.filter((file) => { + try { + if (statSync(file).size >= 5_000_000) return false + const text = readFileSync(file, 'utf8') + return WORKER_MARKERS.every((marker) => text.includes(marker)) + } catch { + return false + } + }) + return { files: byContent, how: 'worker markers' } +} + +/** One request/answer round trip against the artifact, in a real thread. */ +function ask(artifact, request, timeoutMs = REPLY_TIMEOUT_MS) { + const worker = new Worker(artifact) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + void worker.terminate() + reject(new Error(`no reply within ${timeoutMs}ms`)) + }, timeoutMs) + const done = (fn, value) => { + clearTimeout(timer) + fn(value) + } + worker.on('message', (message) => done(resolve, message)) + worker.on('error', (err) => done(reject, err)) + worker.on('exit', (code) => done(reject, new Error(`worker exited (code ${code}) before answering`))) + worker.postMessage(request) + }).finally(() => { + void worker.terminate() + }) +} + +console.log('[worker artifact]') +{ + const scripts = listScripts(OUT_MAIN) + if (scripts.length === 0) { + console.error(`\n[worker-artifact] FAILED: no build output under ${OUT_MAIN} — run "npm run build" first`) + process.exit(1) + } + const { files, how } = findWorkerArtifact(scripts) + ok(files.length > 0, `the probe worker chunk is present in out/main (found by ${how})`) + if (files.length === 0) { + console.error(`\n[worker-artifact] FAILED: ${scripts.length} build script(s) scanned, none is the probe worker`) + process.exit(1) + } + console.log(` info: ${files.map((file) => relative(OUT_MAIN, file)).join(', ')}`) + + const artifact = files[0] + + // 1. a benign rule: the answer carries the corpus it actually ran. + let reply + try { + reply = await ask(artifact, { id: 7, pattern: '\\bOK\\b', flags: 'g' }) + } catch (err) { + ok(false, `the artifact answers a benign probe (${err instanceof Error ? err.message : String(err)})`) + reply = null + } + if (reply !== null) { + ok(reply.id === 7, `the answer echoes the request id (${reply.id})`) + ok(reply.ok === true, `a benign pattern is reported as ok (${JSON.stringify(reply)})`) + ok(reply.probeVersion === PROBE_VERSION, `the answer carries the source probe version (${reply.probeVersion})`) + ok( + reply.probes === REDOS_PROBES.length, + `the shipped corpus is what ran (${reply.probes} of ${REDOS_PROBES.length} probes)` + ) + ok( + typeof reply.worstMs === 'number' && Number.isFinite(reply.worstMs) && reply.worstMs >= 0, + `the slowest probe is a real measurement (${reply.worstMs}ms)` + ) + ok( + typeof reply.worstMs === 'number' && reply.worstMs < PROBE_BUDGET_MS, + `a benign rule stays inside the budget (${reply.worstMs}ms < ${PROBE_BUDGET_MS}ms)` + ) + } + + // 2. a pattern that does not compile: a verdict, not a crash. + let invalid + try { + invalid = await ask(artifact, { id: 8, pattern: '(', flags: 'g' }) + } catch (err) { + ok(false, `the artifact survives an uncompilable pattern (${err instanceof Error ? err.message : String(err)})`) + invalid = null + } + if (invalid !== null) { + ok(invalid.ok === false && invalid.reason === 'invalid-regex', `an uncompilable pattern is reported as such (${JSON.stringify(invalid)})`) + ok(invalid.id === 8, 'the failure answer echoes the request id too') + } +} + +console.log(`\n[worker-artifact] ${passed} passed, ${failed} failed`) +if (failed > 0) { + console.error(`\n[worker-artifact] ${failed} check(s) FAILED`) + process.exit(1) +} +console.log('\n[worker-artifact] ALL CHECKS PASSED') diff --git a/tests/write-batcher.mjs b/tests/write-batcher.mjs index 1f6155c..42a9525 100644 --- a/tests/write-batcher.mjs +++ b/tests/write-batcher.mjs @@ -98,6 +98,45 @@ console.log('an empty flush never reaches the sink') b.dispose() } +// `pendingChars` is the diagnostic view of the backlog the batcher is holding — +// the one number a stall report needs to tell "output is stuck here" apart from +// "xterm is behind". It is a JS string length (UTF-16 code units), deliberately +// NOT a byte count: the highlight path hands over decoded text, and one CJK +// ideograph is 1 here but 3 bytes on the wire. +console.log('pendingChars tracks the buffer as a string length') +{ + const { calls, sink } = makeSink() + const b = new WriteBatcher(sink, DELAY) + ok(b.pendingChars === 0, 'an empty batcher reports 0') + b.push('abc') + ok(b.pendingChars === 3, 'three ASCII chars count 3') + b.push('中文') + ok(b.pendingChars === 5, 'two CJK chars add 2 code units, not 6 bytes') + ok(b.hasPending() === (b.pendingChars > 0), 'hasPending agrees with pendingChars') + b.push('') + ok(b.pendingChars === 5, 'an empty push changes nothing') + await sleep(DELAY * 4) + ok(b.pendingChars === 0, 'pendingChars clears on flush') + ok(calls.length === 1, 'and the flush still happened once') + b.dispose() +} + +console.log('pendingChars clears on flushNow and dispose') +{ + const { calls, sink } = makeSink() + const b = new WriteBatcher(sink, DELAY) + b.push('tail') + b.flushNow() + ok(b.pendingChars === 0, 'flushNow empties the count synchronously') + await sleep(DELAY * 4) + b.push('dropped') + ok(b.pendingChars === 7, 'the rebuffered count is visible') + b.dispose() + ok(b.pendingChars === 0, 'dispose clears the count') + await sleep(DELAY * 4) + ok(calls.length === 1, 'the disposed batcher never wrote the dropped text') +} + if (failed > 0) { console.error(`\n[write-batcher] ${failed} check(s) FAILED`) process.exit(1)