Files
new-markdown/docs/11-code-audit.md

11 KiB
Raw Permalink Blame History

代码审计报告(阶段 6 后全量只读审计)

审计日期:2026-11-13。方式:三路并行只读审计(编辑器内核 / App 状态与服务层 / Rust+样式)+ 主会话逐条复核关键项。未改任何代码。标注「已复核」的为主会话亲自对代码(及 ProseMirror 源码)验证过的结论;行号为审计时点位置。

A. 逻辑错误——高危(丢数据 / 崩溃 / 功能失效)

  1. 保存竞态会误标未保存内容并删恢复记录(已复核)— stores/tabs.ts 的 saved reducer 写 savedContent: t.content(分发时刻的当前正文),而非实际写盘的那份快照。App.tsx saveTab 在 await 写盘期间用户继续输入 → 新输入被标「已保存」、标签变干净、恢复记录被删、后续自动保存跳过、关窗不提示。慢写(大文件)时窗口更宽。可真实丢字。
  2. 外部修改自动重载被旧编辑器卸载回写覆盖(已复核机制)— reloadTabFromDisk 先 dispatch loadContent 再 bump editorEpoch,旧 EditorPane 卸载 cleanup 无条件用旧编辑器内容 onMarkdown 回写 → 刚载入的磁盘新内容被旧正文盖掉且标脏。自动重载实际上从未生效过,看到的仍是旧内容。
  3. 外部重载的检查—执行竞态 — 轮询判「不脏」后 await readTextFile 期间用户开始输入,loadContent 整表覆盖,数个击键被静默吞掉。与第 2 条独立。
  4. 多窗口 ?file= 在无恢复记录时永远不执行(已复核)— 启动 effect 在 records.length === 0 时提前 return(App.tsx:365),读 ?file= 的代码在 return 之后(:377)。即正常退出后的新窗口打开只剩欢迎页——这是最常见的情形,阶段 6 多窗口主路径实际是坏的(此前报告登记为已实现,属误报,一并更正)。
  5. UI 偏好启动竞态(已复核结构)— [ui] effect 挂载即以初始值 saveUiPref 写库,初始化 effect 要先 await loadRecent() 才读库。桌面端偏好(主题/自动保存间隔/快捷键覆盖等)大概率被默认值覆盖后才读回,表现为设置不持久。
  6. 全选 / 点击公式块等顶层原子节点必抛 RangeError(已复核,含 PM 源码)— editor.ts cursorFromSelection 对 $from.before($from.depth),depth=0(AllSelection、NodeSelection)时 PM 直接抛 There is no position before the top-level node,异常从 listener 同步回调冒出 dispatch,该次选区变更失败。
  7. 查找命中不随编辑失效,替换会打在过期位置(已复核设计)— EditorPane/SourceEditor 的命中表只在改查询词/选项/切标签时重算,文档变化从不失效。开着查找条改文档前部再按替换 → 在偏移后的错误文本上替换,写坏正文。
  8. 自动保存不避让外部冲突弹窗(已复核)— autosave 只判 path && dirty,ConflictModal 打开期间 30 秒一到先把本地版本写盘,外部修改被覆盖,弹窗「重新加载」选项失效一半。
  9. 全文搜索跟随符号链接无限递归 — search.rs 用 is_dir()(跟随链接)无深度/总量上限;工作区里有指向父目录的 junction(Windows 常见)→ 无限递归栈溢出、进程崩溃;工作区开在用户主目录时同步全量扫描不可取消。
  10. 所有 Rust 命令同步执行 + 整文件无上限读入 — 命令均为非 async 函数(Tauri 语义下跑主线程);read_text_file/read_file_base64 全量 fs::read、base64 再放大 4/3 经 IPC。误开数百 MB 文本 → 界面冻结数秒至 OOM;全文搜索同理冻结主线程。

B. 逻辑错误——中危(节选)

  • 替换被 App 与 EditorPane 两层各重查一遍全量索引且序号口径不一致,最终停留命中会错位一个(App.tsx onReplace + EditorPane.replaceCurrent 内部重查)。
  • 关窗时未命名草稿的恢复写入是 fire-and-forget(void saveRecovery 后直接放行关窗),IPC 未落盘进程已退出,草稿可能丢。
  • 另存为后旧恢复键(untitled:<id>/旧路径)残留,下次启动误报「未保存草稿」(重命名流程处理了、保存流程漏了)。
  • 全局快捷键不感知弹窗打开:PromptModal 输入框里按 Ctrl+Shift+Q 会给背后编辑器插引用块;LinkModal 里 Ctrl+W 会关掉背后标签。
  • 打印在源码模式下靠固定 60ms 等编辑器重建,大文档必赛跑,打印内容不可控。
  • 临时文件名只有进程号成分(.tmp-{pid}),同进程并发保存同一文件(自动保存 × 手动保存)共用同一 tmp 互相踩。
  • 删除到回收站失败时对文件静默退回永久删除(阶段 4 的设计决定,但用户无感知——网络盘/回收站禁用时以为可恢复、实际不可)。
  • 编码:非 UTF-8 一律猜 GBK(Big5/Shift_JIS/UTF-16 无 BOM 必乱码);带 BOM 的 UTF-8 用 lossy 解码吞坏字节,保存后 <20> 被永久写入。工作区搜索对 GBK 文档用 lossy 解码,中文永远搜不到。
  • assets 重名去重是先查后写 TOCTOU,两窗口同粘同名图可互相覆盖;循环兜底还会返回已存在路径被 fs::write 截断覆盖。
  • 快捷键可录入「无修饰键」组合但分发层先拦截(无 ctrl/alt 直接 return),绑了永不触发且无提示;Ctrl+Shift+= 归一化与 zoomIn 的 ctrl+= 对不上。
  • 源码模式切 UI 主题整组件重挂,光标回退到进入时的旧偏移且 CM 撤销史清空。
  • 导出图片解析:扩展名/MIME 推导对带查询串、大写扩展名、%20 路径失效,内嵌缺图且静默无提示。
  • 整词匹配在正则模式下静默失效(两处实现一致地跳过,用户无提示)。

C. 性能问题(大文档为主)

  1. 每次击键的 O(n) 叠加(已复核):状态栏字数/字符数/行数在 render 里对全文现算;大纲 extractOutline 以整个 tab 对象为 useMemo 依赖、每击键全文逐行正则;菜单数组同样以 tab 为依赖每击键重建;Sidebar/TabBar/MenuBar 均未 memo,App 整树随每次 updateContent 重渲染。
  2. Milkdown listener 的 200ms 全量序列化:打字期间每 200ms 对整篇文档序列化一次并附带全文档深比较(prevDoc.eq),626K 字符单次序列化 jsdom 口径 2.6s——序列化本身是架构必需(正文以 Markdown 字符串为状态源),但频率与深比较在大文档上是持续占用。
  3. 选区回调每击键全文档扫描:cursorFromSelection 每次选区变化跑一遍 doc.descendants 数文本块,另加 domAtPos + closest('table')。
  4. 专注模式每击键全量重建装饰集:当前块未变也不复用,数千个 Decoration.node 每击键重建 + PM 全文档装饰 diff;CSS 的 opacity transition 又让每批块持续做动画重算。
  5. 查找逐字输入全量重建索引(无防抖):1MB 文档每敲一个查询字符都重建双数组 + 整串 toLowerCase + 全表扫描;替换时再叠两遍(见 B 条)。
  6. 图片粘贴走 number[] 过 IPC:5MB 截图 = 500 万元素数组经 JSON 序列化传 Rust,内存/耗时放大一个量级,粘贴瞬间冻结可感。
  7. 恢复写入每 6 秒写全文:持续输入时每个脏标签每 6 秒把整篇正文经 IPC 写 SQLite(有内容比对挡无变化轮次);外部检测 4 秒轮询对多标签串行 await stat,轮次可交叠。
  8. 搜索不区分大小写时逐行 to_lowercase() 全量分配(Rust 侧,小)。

D. 样式问题

高

  1. --panel 变量从未定义却被用 3 处(已复核)— 实际变量名是 --bg-panel。FindBar 背景、查找按钮、快捷键行按钮的背景全部失效变透明:查找条直接透出文档文字叠在一起。
  2. .input 基础样式根本不存在(已复核)— 全仓只有一处布局规则(.ws-search-box .input)。FindBar、侧边栏搜索、阶段 4/5 弹窗的输入框全是浏览器原生外观,深色 UI 下白底黑字原生框——正是明确要求避免的样式。
  3. 文档主题变量覆盖不对称,深色 UI 下不可读(已复核)— notion 只设 --doc-paper:#fbfbfa 不设 --doc-ink(回落为 UI 的 --text),深色 UI(--text 近白)+ Notion 主题 = 近白字压近白纸;github 同理且代码块底色/标题边框写死浅色。dark-eye/paper 是成对设的、没问题。

中 4. 打印隐藏清单漏了 .findbar 与 Mermaid 的「预览/源码」按钮——查找条开着打印会印进 PDF。 5. 源码模式明暗只跟 UI 主题、不跟文档主题:浅色 UI + 深色护眼文档主题时源码是深纸面配深色字,低对比。 6. CSS 重复/冲突定义靠书写顺序兜底(.findbar/.editor-scroll/.modal-note 各两处,字号还不一致),维护雷区;全应用无滚动条与选区样式,深色下默认浅色粗滚动条割裂;Ctrl+= 缩放只作用渲染模式、源码模式字号写死 14px 不跟随;FindBar min-width:380px 无窄宽兜底;菜单下拉无右边缘防溢出(暂未触发)。 7. themes.ts 的 darkPaper 字段定义了但全仓无人消费,是误导性死代码。

E. 待核实(有嫌疑、无运行证据,需真机或补测)

  1. Tab 键可能被 Milkdown 自带 indent 插件抢先(优先级最高):@milkdown/plugin-indent 对 Tab 无条件插两空格,其 keymap 与自研表格 Tab/代码块 Tab 的合并顺序未验;现有测试都是直调函数、从未向组装好的编辑器派发真实 Tab 事件。若成立,表格跳格与代码块缩进在真机上是坏的。
  2. CSP 为 null + 全部文件命令接受任意绝对路径:若某条原始 HTML 渲染路径可执行脚本,则等于任意文件读写删,需专项核实 Milkdown 对原始 HTML 的处理。
  3. 导出时非法公式可能整篇失败:rehype-katex 未传 throwOnError:false,KaTeX 默认抛错。
  4. Mermaid 导出 id 按内容 hash,同文档两段相同图表代码 id 重复,产物是否互相污染未验。
  5. 图片落盘失败(onImageFile reject)时 upload 插件的占位节点是否残留未验;双进程同开一个 SQLite(双击图标二次启动)无 WAL/busy_timeout,写恢复记录时可能 SQLITE_BUSY 上抛(同进程多窗口共用 State 连接、无此问题)。

F. 确认没问题的部分

  • Markdown 双向往返保真与全量回归(61 例)覆盖扎实;原子写有临时文件 + sync_all + rename;CRLF 由前端 toDiskContent 写回时保持(Rust 层无换行参数的疑虑已排除);语法显隐插件的 composition 监听在 destroy 中成对移除;list_item/表格等 NodeView 无外部监听泄漏;恢复记录的磁盘比对划分(planRecovery)逻辑有单测且正确;快捷键注册表本身无默认冲突。

G. 建议修复顺序

  1. 先修 A1–A8(丢数据/失效类,改动都不大:saved 带快照、重载回写闸门、?file 位置、偏好读写顺序、depth=0 守卫、命中失效钩子、autosave 避让冲突)。
  2. 样式 D1–D3(一处变量名、一份 .input 基础样式、两套主题补 --doc-ink,半小时级)。
  3. 性能 C1(状态栏/大纲/菜单改「内容变化才重算」+ 关键组件 memo)、C4/C5(专注装饰复用、查找防抖与去重计算)。
  4. E1 补一条真实派发 Tab 的集成测试定性,再决定是否调整插件顺序。