feat(highlight): 主进程 ReDoS 校验闸门与 renderer 性能时间线
This commit is contained in:
1 parent
68fa46b2e3
commit
fc0d1afdce
43 files changed
+5711
-179
No files matched your search
@@ -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)。
|
||||
|
||||
- 正式版落盘的诊断日志在 `<userData>/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 条目
|
||||
Reference in new issue
Block a user