From ff366a4daf4e90a2b03c8ed60fa5e3dde5d45be1 Mon Sep 17 00:00:00 2001 From: guanjihuan Date: Fri, 16 Oct 2026 16:34:15 +0800 Subject: [PATCH] update --- .gitignore | 5 + README.md | 155 +-- eslint.config.js | 1 + index.html | 159 ++- main.js | 805 +++++++++++++-- main/ai.js | 98 +- main/audit-rate.js | 40 + main/cleanup-epoch.js | 70 ++ main/config-store.js | 69 +- main/file-list-cache.js | 69 ++ main/file-ops.js | 321 +++++- main/fs-watcher.js | 175 +++- main/fsync-parent-dir.js | 64 ++ main/generate-tmp-path.js | 38 + main/ipc-sender.js | 111 +++ main/lru-cache.js | 42 + main/main-log-writer.js | 105 ++ main/save-settings-validate.js | 32 + main/secret-store.js | 167 ++++ preload.js | 53 +- scripts/check-syntax.js | 14 +- shared/render-sanitize.js | 33 +- shared/settings-schema.js | 32 +- src/ai/ai-chat-panel.js | 62 +- src/ai/ai-controller.js | 57 +- src/app.js | 998 +++++++++++++++---- src/confirm-dialog.js | 2 +- src/editor-mode-reset.js | 41 + src/file-list.js | 608 +++++++++-- src/file-ops.js | 453 ++++++++- src/fs-handler-lock.js | 63 ++ src/markdown-editor.js | 69 +- src/markdown-viewer.js | 605 +++++++++-- src/modal.js | 2 +- src/prompt-dialog.js | 2 +- src/settings-dialog.js | 53 +- src/shortcuts.js | 52 +- src/styles.css | 6 +- src/styles/01-tokens.css | 2 +- src/styles/04-toolbar.css | 57 +- src/styles/05-sidebar.css | 490 +++++++-- src/styles/06-markdown.css | 48 +- src/styles/07-statusbar.css | 29 +- src/styles/08-modal.css | 36 +- src/styles/09-misc.css | 124 ++- src/styles/10-a11y.css | 8 +- src/styles/11-ai.css | 36 +- tests/unit/ai-chat-panel-placeholder.test.js | 132 +++ tests/unit/app-static-imports.test.js | 56 ++ tests/unit/audit-rate.test.js | 59 ++ tests/unit/cleanup-epoch.test.js | 85 ++ tests/unit/codemirror-history-depth.test.js | 156 +++ tests/unit/config-store-quit-gate.test.js | 120 +++ tests/unit/csp-importmap-hash.test.js | 123 +++ tests/unit/editor-mode-reset.test.js | 77 ++ tests/unit/file-list-cache.test.js | 172 ++++ tests/unit/file-list-contextmenu.test.js | 27 +- tests/unit/file-list-drag.test.js | 188 ++++ tests/unit/file-list.test.js | 211 +++- tests/unit/file-move-copy.test.js | 107 ++ tests/unit/file-ops-lazy-editor.test.js | 56 ++ tests/unit/file-ops-move-copy.test.js | 162 +++ tests/unit/file-ops-new-in-folder.test.js | 129 +++ tests/unit/file-ops.test.js | 165 ++- tests/unit/folder-ops.test.js | 173 ++++ tests/unit/fs-handler-lock.test.js | 89 ++ tests/unit/fs-watcher.test.js | 141 +++ tests/unit/fsync-parent-dir.test.js | 128 +++ tests/unit/generate-tmp-path.test.js | 96 ++ tests/unit/heading-observer-persist.test.js | 125 +++ tests/unit/ipc-sender.test.js | 173 ++++ tests/unit/lru-cache.test.js | 79 ++ tests/unit/main-log-writer.test.js | 127 +++ tests/unit/markdown-editor.test.js | 144 +++ tests/unit/markdown-viewer.test.js | 516 +++++++++- tests/unit/render-sanitize.test.js | 32 +- tests/unit/save-settings-validate.test.js | 54 + tests/unit/secret-store.test.js | 260 +++++ tests/unit/settings-schema.test.js | 46 +- tests/unit/tmp-perms.test.js | 80 ++ 80 files changed, 9961 insertions(+), 888 deletions(-) create mode 100644 main/audit-rate.js create mode 100644 main/cleanup-epoch.js create mode 100644 main/file-list-cache.js create mode 100644 main/fsync-parent-dir.js create mode 100644 main/generate-tmp-path.js create mode 100644 main/ipc-sender.js create mode 100644 main/lru-cache.js create mode 100644 main/main-log-writer.js create mode 100644 main/save-settings-validate.js create mode 100644 main/secret-store.js create mode 100644 src/editor-mode-reset.js create mode 100644 src/fs-handler-lock.js create mode 100644 tests/unit/ai-chat-panel-placeholder.test.js create mode 100644 tests/unit/app-static-imports.test.js create mode 100644 tests/unit/audit-rate.test.js create mode 100644 tests/unit/cleanup-epoch.test.js create mode 100644 tests/unit/codemirror-history-depth.test.js create mode 100644 tests/unit/config-store-quit-gate.test.js create mode 100644 tests/unit/csp-importmap-hash.test.js create mode 100644 tests/unit/editor-mode-reset.test.js create mode 100644 tests/unit/file-list-cache.test.js create mode 100644 tests/unit/file-list-drag.test.js create mode 100644 tests/unit/file-move-copy.test.js create mode 100644 tests/unit/file-ops-lazy-editor.test.js create mode 100644 tests/unit/file-ops-move-copy.test.js create mode 100644 tests/unit/file-ops-new-in-folder.test.js create mode 100644 tests/unit/folder-ops.test.js create mode 100644 tests/unit/fs-handler-lock.test.js create mode 100644 tests/unit/fsync-parent-dir.test.js create mode 100644 tests/unit/generate-tmp-path.test.js create mode 100644 tests/unit/heading-observer-persist.test.js create mode 100644 tests/unit/ipc-sender.test.js create mode 100644 tests/unit/lru-cache.test.js create mode 100644 tests/unit/main-log-writer.test.js create mode 100644 tests/unit/save-settings-validate.test.js create mode 100644 tests/unit/secret-store.test.js create mode 100644 tests/unit/tmp-perms.test.js diff --git a/.gitignore b/.gitignore index 377b8fa..8548dcc 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ tests/manual/ # 历史一次性脚本,未被 package.json / lint / check 引用 —— 不进版本库 # 即可,但留在磁盘方便日后查同一类问题的修复路径。 scripts/_archive/ + +.audit_profile/ +.audit_profile2/ +.audit_profile3/ +.audit_profile4/ diff --git a/README.md b/README.md index 139a8c5..b8f21ec 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ # Notes -本地优先的 Markdown 阅读与编辑工具。界面看到的和磁盘上的 `.md` 文件一模一样 —— 复制走就是备份。 +本地优先的 Markdown 阅读 / 编辑工具。`.md` 文件即笔记,文件即数据 —— 看到和磁盘上一字不差,复制走就是备份。 -![tests](https://img.shields.io/badge/tests-947%2F34%20passing-brightgreen) ![Electron](https://img.shields.io/badge/Electron-44-47848F) ![Node](https://img.shields.io/badge/node-%3E%3D20.18-339933) ![MIT](https://img.shields.io/badge/license-MIT-blue) +![tests](https://img.shields.io/badge/tests-1217%2F59%20passing-brightgreen) ![Electron](https://img.shields.io/badge/Electron-44-47848F) ![Node](https://img.shields.io/badge/node-%3E%3D20.18-339933) ![MIT](https://img.shields.io/badge/license-MIT-blue) -## 它是什么 +## 特点 -Notes 把一个文件夹变成笔记本。`.md` 文件就是笔记,**文件即数据**,没有数据库也没有专有格式。内置 CodeMirror 6 编辑器,`Ctrl + S` 落盘,也可开启自动保存。 - -8 套配色(default / ocean / forest / sunset / rose / amber / slate / mono),暗 / 亮各一版,字号行距可调。不联网也能用 —— AI 是可选插件,**不配置就永不联网**。 - ---- +- 所见即所得 —— 保存时原样写回,不会重排 Markdown 格式 +- 默认离线 —— 不配 AI 就永不联网;API Key 走系统密钥库(Windows DPAPI / macOS Keychain / Linux libsecret) +- 桌面级体验 —— CodeMirror 6 编辑器 + 实时预览双栏,键盘快捷键齐全 +- 抗意外 —— 原子写盘、外部修改检测、退出保护,断电不留半截 +- 托盘常驻 —— 关窗不等于退出,单击托盘图标恢复 ## 5 分钟上手 -需要 [Node.js 20.18 或更高](https://nodejs.org/)。 +需要 Node.js 20.18 或更高([下载](https://nodejs.org/))。 ```bash git clone <仓库地址> notes @@ -23,49 +23,47 @@ npm install npm start ``` -首次启动会在用户主目录创建 `Notes` 文件夹(Windows 是 `C:\Users\<你>\Notes`,macOS / Linux 是 `~/Notes`),并把欢迎页等 4 篇引导文档复制进去。把任意 `.md` 文件拖进那个文件夹,左侧栏立刻显示。 +首次启动会在主目录创建 `Notes` 文件夹(Windows 是 `C:\Users\<你>\Notes`,macOS / Linux 是 `~/Notes`),并把欢迎页复制进去。把任意 `.md` 文件丢进那个文件夹,左侧栏立刻出现。 -按 `Ctrl + ,` 打开设置。主题 / 置顶 / 视图模式 / 自动保存在工具栏有独立按钮,**设置里不会出现重复入口**。 +按 `Ctrl + ,` 打开设置。工具栏已经有「主题 / 视图 / 自动保存」等按钮,设置里不会出现重复入口。 ---- +## 阅读与编辑 -## 日常使用 +- `Ctrl + E` 在「预览 / 编辑 / 双栏」之间循环 +- `Ctrl + F` 按文件名实时过滤侧栏 +- `Ctrl + N` 新建笔记,`Ctrl + S` 保存(IME 拼音合成中不会误触发),`Ctrl + W` 关闭当前文件 +- `Ctrl + Shift + F` 聚焦模式 —— 隐藏工具栏 / 侧栏 / 状态栏,纯净阅读 +- 状态栏实时显示当前章节标题 +- 外链走系统浏览器;同目录 `.md` 相对链接在 Notes 内打开;`#标题` 锚点文内跳转 +- `![](img/a.png)` 相对路径图片直接渲染 -### 阅读与编辑 +## AI 改稿 -- `Ctrl + E` 在「预览 → 编辑 → 双栏」之间循环 -- `Ctrl + F` 侧栏搜索框按文件名实时过滤 -- `Ctrl + S` 保存,`Ctrl + N` 新建笔记,`Ctrl + W` 关闭当前文件 -- `Ctrl + Shift + F` 聚焦模式,隐藏工具栏 / 侧栏 / 状态栏,纯净阅读 -- 状态栏实时显示当前阅读到的章节标题 -- 链接分流:外链走系统浏览器;同目录 `.md` 相对链接在 Notes 内打开;`#标题` 锚点文内跳转 -- 相对路径图片(`![](img/a.png)`)直接渲染 +`Ctrl + Shift + A` 打开底部 AI 面板。输入你想怎么改(润色 / 翻译 / 总结 / 续写),AI 给出行级 + 词级 diff,逐段应用或一键应用全部,不满意随时取消。 -### AI 修改 - -`Ctrl + Shift + A` 打开底部 AI 面板。描述你想怎么改(润色 / 翻译 / 总结 / 续写),AI 给出行级 + 词级 diff,可逐段应用或一键应用全部,不满意随时取消。 - -协议选择: - -| 选项 | 适用服务 | Base URL 末尾 | +| 协议 | 适用服务 | Base URL 要点 | |---|---|---| -| OpenAI 兼容 | OpenAI / DeepSeek / Moonshot / Azure / 自部署 | 必须含 `/v1` | +| OpenAI 兼容 | OpenAI / DeepSeek / Moonshot / 自部署 | 必须含 `/v1` | | Anthropic 兼容 | Claude 系列 / 第三方中转 | 不含 `/v1` | -API Key 默认脱敏显示,点眼睛图标露出明文。**不配也不影响其它功能**。 +API Key 默认脱敏显示,点眼睛图标露出明文。不配 AI 不影响其它功能。 ---- +## 整理侧栏 + +- **拖到文件夹上 = 移动**。拖拽时按住 `Ctrl`(macOS 是 `Cmd`)= 复制,文件夹会递归复制,软链接会被跳过 +- 侧栏聚焦时:`Ctrl + X` 剪切,`Ctrl + C` 复制,`Ctrl + V` 粘贴到当前正在浏览的目录 +- 右键菜单功能同上。**侧栏空白区域右键也能粘贴**到当前目录(剪贴板为空时该菜单项禁用) +- 目标文件夹已有同名项时自动加 `(2)` / `(3)` ... 改名避免覆盖 +- 不能把文件夹拖进自己或子孙目录(会被拦下) ## 数据安全 +- **API Key 加密** —— 写盘前加密,运行时主进程栈上才解出明文 - **外部修改检测** —— 其他编辑器改了文件会提示重新加载(正在打字时不打断) -- **退出保护** —— 有未保存改动就关窗 / 退托盘,会先问「保存 / 丢弃 / 取消」 -- **原子写盘** —— 断电也不会留下半截文件 -- **数据目录自动回退** —— U 盘拔了 / 网盘断连时自动切回默认目录 -- **云同步盘友好** —— OneDrive / 坚果云 / iCloud 都做过专门适配 -- **关窗 ≠ 退出** —— 应用缩到系统托盘,单击恢复,右键看菜单 - ---- +- **退出保护** —— 有未保存改动关窗 / 退托盘,先问「保存 / 丢弃 / 取消」 +- **原子写盘** —— 写到临时文件再 rename +- **数据目录自动回退** —— U 盘拔了 / 网盘断连自动切回默认目录 +- **云盘友好** —— OneDrive / 坚果云 / iCloud 做过专门适配 ## 数据文件夹 @@ -75,43 +73,42 @@ API Key 默认脱敏显示,点眼睛图标露出明文。**不配也不影响 | macOS | `~/Notes` | | Linux | `$HOME/Notes` | -可在设置里改成任意目录。状态栏左段(紧贴当前路径)有三个 icon-only 按钮:打开、切换、回到默认。 +设置里可以改成任意目录。状态栏最左段(紧贴当前路径)有三个 icon-only 按钮:打开、切换、回到默认。 ---- +## 快捷键 -## 快捷键速查 +> Mac 把 `Ctrl` 换成 `Cmd`,下表通用。 | 快捷键 | 作用 | |---|---| -| `Ctrl/Cmd + F` | 聚焦文件搜索 | -| `Ctrl/Cmd + N` | 新建笔记 | -| `Ctrl/Cmd + S` | 保存(IME 合成中不触发写盘) | -| `Ctrl/Cmd + W` | 关闭当前文件 | -| `Ctrl/Cmd + E` | 切换视图模式 | -| `Ctrl/Cmd + ,` | 打开设置 | -| `Ctrl/Cmd + R` | 重新加载界面 | -| `Ctrl/Cmd + Shift + T` | 切换主题 | -| `Ctrl/Cmd + Shift + F` | 切换聚焦模式 | -| `Ctrl/Cmd + Shift + A` | 切换 AI 面板 | -| `Esc`(搜索框内) | 清空搜索 | +| `Ctrl + F` | 聚焦文件搜索 | +| `Ctrl + N` | 新建笔记 | +| `Ctrl + S` | 保存(IME 合成中不触发写盘)| +| `Ctrl + W` | 关闭当前文件 | +| `Ctrl + E` | 预览 / 编辑 / 双栏切换 | +| `Ctrl + Shift + F` | 切换聚焦模式 | +| `Ctrl + Shift + T` | 切换主题 | +| `Ctrl + Shift + A` | 切换 AI 面板 | +| `Ctrl + ,` | 打开设置 | +| `Ctrl + R` | 重新加载界面 | +| `Ctrl + X / C / V` | 剪切 / 复制 / 粘贴(侧栏聚焦时)| +| `Esc`(搜索框内)| 清空搜索 | | `F12` | 打开 DevTools | -窗口是无边框自绘标题栏,**Windows / Linux 上看不到菜单栏**,但上表所有快捷键全部生效。 - ---- +窗口是无边框自绘标题栏,Windows / Linux 上看不到菜单栏,但上表所有快捷键全部生效。 ## 常见问题 **搜索文件没反应?** -检查设置里的「数据文件夹」是不是对的。某些云盘的实时监听不可靠,按 `Ctrl + R` 手动刷新。 +看设置里的「数据文件夹」对不对。某些云盘实时监听不可靠,按 `Ctrl + R` 手动刷新。 **AI 提示「无法连接到服务」?** -核对 Base URL:OpenAI 兼容需含 `/v1`,Anthropic 不含。点眼睛图标露出 API Key 明文,确认没多余空格。按 `F12` 看完整错误码。 +OpenAI 兼容协议 Base URL 必须含 `/v1`,Anthropic 不含。点眼睛图标露出 API Key 明文,确认没多余空格 / 换行。按 `F12` 看完整错误码。 **中文 Windows 启动报错?** Electron 在中文路径下启动会报错。两种办法: -1. 把项目挪到纯英文路径(推荐),如 `D:\Projects\notes` +1. 推荐:把项目挪到纯英文路径,如 `D:\Projects\notes` 2. 用管理员 cmd 建符号链接: ```cmd mklink /D C:\dev\notes "C:\当前\中文路径\Notes" @@ -119,24 +116,30 @@ Electron 在中文路径下启动会报错。两种办法: 然后在 `C:\dev\notes` 跑 `npm install && npm start` **控制台中文乱码?** -用 `npm start`,**不要用 `npm run start:raw`**。前者会自动把控制台切到 UTF-8。 +用 `npm start`,别用 `npm run start:raw`。前者自动把控制台切到 UTF-8。 **笔记文件会被改动吗?** -不会。读什么就显示什么,保存时原样写回 —— **不会偷偷重排 Markdown 格式**。 +不会。读什么就显示什么,保存时原样写回 —— 不会偷偷重排 Markdown 格式。 ---- +**怎么更新到新版本?** +```bash +git pull +npm install +npm start +``` +你的 `~/Notes` 笔记文件夹完全独立,更新代码不影响数据。 ## 给开发者 ```bash -npm start # 启动(推荐,自动处理中文 Windows 编码) -npm test # 全部单测(947 用例 / 34 文件) -npm run test:watch # watch 模式 -npm run check # 语法 + IPC 通道配对 -npm run lint # ESLint -npm run build:win # 打包 Windows 安装包 → dist/ -npm run build:mac # 打包 macOS → dist/ -npm run build:linux # 打包 Linux AppImage → dist/ +npm start # 启动(自动处理中文 Windows 编码) +npm test # 全部单测(1217 用例 / 59 文件) +npm run test:watch # watch 模式 +npm run check # 语法 + IPC 通道配对 +npm run lint # ESLint +npm run build:win # 打包 Windows 安装包 → dist/ +npm run build:mac # 打包 macOS → dist/ +npm run build:linux # 打包 Linux AppImage → dist/ ``` ``` @@ -144,18 +147,16 @@ Notes/ ├── main.js # Electron 主进程入口 ├── preload.js # IPC 桥(contextBridge) ├── index.html # UI 框架 + CSP + importmap -├── main/ # 主进程模块:ai / config-store / file-ops / fs-watcher -├── src/ # 渲染端:UI 组件 + CSS 分片 + AI 面板 -├── shared/ # 主 / preload / 渲染端共享:schema / diff / 错误码 -├── data/ # 首次启动种子化的引导文档(4 篇) -├── scripts/ # 启动包装 + 静态检查 +├── main/ # 主进程:ai / config-store / file-ops / fs-watcher / secret-store +├── src/ # 渲染端:UI 组件 + CSS 分片(01-12)+ AI 面板 +├── shared/ # 主 / preload / 渲染端共享:schema / diff / sanitize +├── data/ # 首次启动种子化的欢迎页(1 篇) +├── scripts/ # 启动包装 / 语法检查 / IPC 校验 └── tests/ - ├── unit/ # vitest 单测(34 个文件,947 用例) + ├── unit/ # vitest 单测(59 文件 / 1217 用例) └── manual/ # 手工视觉验证产物(不进版本库) ``` ---- - ## License MIT diff --git a/eslint.config.js b/eslint.config.js index 9dee8a6..da902a9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -127,6 +127,7 @@ module.exports = [ MutationObserver: 'readonly', crypto: 'readonly', Element: 'readonly', + Node: 'readonly', HTMLElement: 'readonly', HTMLInputElement: 'readonly', HTMLTextAreaElement: 'readonly', diff --git a/index.html b/index.html index 26fb618..daacb53 100644 --- a/index.html +++ b/index.html @@ -10,21 +10,32 @@ - + Notes + + + + + + + + + + + + @@ -133,12 +164,16 @@ + @@ -146,7 +181,7 @@
- - - - - + 排序工具条作为首屏唯一入口,垂直空间专注于文件列表 --> + + + @@ -441,6 +506,16 @@ 把 .md 文件放入数据文件夹后,
会自动出现在左侧列表。

+ +

Ctrl+N 新建笔记 · Ctrl+F 搜索
底部状态栏可切换数据文件夹 @@ -470,6 +545,8 @@ autocapitalize="off" spellcheck="false" /> + +

diff --git a/main.js b/main.js index c117b11..5eb5644 100644 --- a/main.js +++ b/main.js @@ -33,12 +33,13 @@ try { if (err && (err.code === 'EPIPE' || err.code === 'ENOTCONN' || err.code === 'EBADF')) { return; // 静默忽略:没有终端可写是正常的 } - // 其他错误写到 tmp 文件,避免被 Electron 弹成主进程错误框 + // 其他错误写到 tmp 文件,避免被 Electron 弹成主进程错误框。 + // P2-12:fire-and-forget 异步写,不走 appendFileSync 阻塞 main。 try { - require('fs').appendFileSync( + require('fs').promises.appendFile( require('path').join(require('os').tmpdir(), 'notes-main-stderr.log'), `[${new Date().toISOString()}] ${err.stack || err}\n` - ); + ).catch(() => { /* 兜底逻辑本身不应再抛 */ }); } catch { /* 兜底逻辑本身不应再抛 */ } }); } @@ -60,7 +61,6 @@ try { const _origWarn = console.warn; const _origError = console.error; const _path = require('path'); - const _fsSync = require('fs'); const _resolveLogDir = () => { try { const { app: _app } = require('electron'); @@ -85,12 +85,16 @@ try { }; const _logDir = _resolveLogDir(); const _logFile = _path.join(_logDir, 'notes-main.log'); + // P2-10 / P2-12 (audit):旧版每行 mkdirSync + appendFileSync 同步阻塞 main + // 事件循环;改 main/main-log-writer.js 异步缓冲(250ms 一把写,unref 定时器, + // 失败静默丢弃 —— 与旧「catch 忽略」语义一致)。 + const _writer = require('./main/main-log-writer.js').createMainLogWriter({ + logDir: _logDir, + logFile: _logFile, + }); const _writeLog = (level, args) => { try { - // 兜底:目录可能不存在(首次启动 / 权限)→ 同步 mkdirSync recursive - _fsSync.mkdirSync(_logDir, { recursive: true }); - _fsSync.appendFileSync( - _logFile, + _writer.write( `[${new Date().toISOString()}] [${level}] ${args.map((a) => (typeof a === 'string' ? a : require('util').inspect(a))).join(' ')}\n` ); } catch { /* 写日志失败不应影响主流程 */ } @@ -162,6 +166,12 @@ const fsSync = require('fs'); // 设置 schema(单一事实源)—— 主进程、preload、renderer 三处共享。 // 新增/修改设置项只需改 shared/settings-schema.js 一处。 const { validateAndSanitize } = require('./shared/settings-schema.js'); +const { isValidPartialSettings } = require('./main/save-settings-validate.js'); +// R5 P1-10 / P1-11 / P1-14:IPC handler 入口 sender / frame / requestId 守卫。 +// 详见 main/ipc-sender.js 顶部注释。getMainWindow 通过 main.js 内部函数注入。 +const { assertMainSender, assertTopFrame, assertAiRequestId } = require('./main/ipc-sender.js'); +// P1-6:file:rename / folder:rename 成功后 fsync 父目录(POSIX 强同步视图) +const { fsyncParentDir } = require('./main/fsync-parent-dir.js'); // 配置持久化层(Stage 4b.1 抽离) const configStore = require('./main/config-store'); // 目录监听层(Stage 4b.2 抽离) @@ -176,11 +186,15 @@ const { isWithinDataDir, resolveFileName, resolveRenameName, + resolveFolderName, + resolveRenameFolderName, scanDir, assertNotSymlink, assertNoSymlinkAncestor, resolveDirRelative, toRelativeDir, + renameWithRetry, + recursiveCopy, } = fileOps; // AI 代理(用 Node 18+ 内置 fetch 调 OpenAI 兼容 API) const { createAiProxy } = require('./main/ai'); @@ -275,6 +289,33 @@ function _friendlyDeleteError(e) { } } +/** + * 复制失败的中文翻译(file:copy 专用)。 + * 常见 errno:EACCES/EPERM(权限)、ENOSPC(磁盘满)、EROFS(只读)、 + * EBUSY(文件被占)、ENOENT(源已被删)、EEXIST(目标已存在)。 + */ +function _friendlyCopyError(e, name) { + const n = name || '文件'; + switch (e && e.code) { + case 'EACCES': + case 'EPERM': + return `没有写入权限,无法复制 ${n}`; + case 'ENOSPC': + return '磁盘空间不足,无法复制'; + case 'EROFS': + return '只读文件系统,无法复制'; + case 'EEXIST': + case 'ENOTEMPTY': + return `已存在同名项,无法复制到 ${n}`; + case 'ENOENT': + return '源文件已被删除,无法复制'; + case 'EBUSY': + return '文件被占用,无法复制'; + default: + return `复制 ${n} 失败`; + } +} + /** * audit fix (K1-M2):file:read 的 errno 翻译。读路径常见 errno: * EACCES/EPERM(权限)、EIO(磁盘 I/O)、EISDIR(路径是目录)、 @@ -332,6 +373,13 @@ app.commandLine.appendSwitch('disable-gpu-cache'); app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion'); let mainWindow = null; +// R5 P1-10:getMainWindow 是闭包(每次访问时读 mainWindow 当前值)—— 注入 +// 到 assertMainSender,让单测可以传 mock,且主窗口销毁事件触发时后续 IPC +// 自动拒掉(getter 返回已销毁的 BrowserWindow,assertMainSender 看到 +// isDestroyed() true 直接拒绝)。 +function getMainWindow() { + return mainWindow; +} let tray = null; let isQuitting = false; let currentDataDir = null; @@ -437,12 +485,18 @@ console.log('[main] Project path:', __dirname); // 没人 await」「fsWatcher 内部 setTimeout 回调抛」之类的边界场景 —— 没有 // 这里兜底会被 Node 默认行为吞掉(unhandledRejection 在新 Node 上是会 // terminate 进程的,uncaughtException 默认打印 + 退出),连日志都不留。 -// 只 log 不 exit(与既有 try/catch 风格一致 —— 主进程挂了 IPC 全断)。 +// R5 P1-9:log 完调 app.exit(1) —— Node 文档明确警告 uncaughtException 后 +// 「process state may be inconsistent」,继续运行 IPC / fs / DB 操作更危险。 +// 代价:用户写一半的笔记可能丢失;但 uncaught 在 Notes 这类纯本地 app 里 +// 极少触发(99% 是 bug),出现时安全 > 完整。 process.on('uncaughtException', (err, origin) => { console.error('[main] uncaughtException:', err && (err.stack || err.message || err), 'origin:', origin); + // flush 后再 exit —— console.error 是同步的,但保险起见给一个 tick + setImmediate(() => app.exit(1)); }); process.on('unhandledRejection', (reason, promise) => { console.error('[main] unhandledRejection at:', promise, 'reason:', reason && (reason.stack || reason.message || reason)); + setImmediate(() => app.exit(1)); }); // ============================================ @@ -912,6 +966,10 @@ app.on('before-quit', async (e) => { }); app.on('will-quit', () => { + // audit fix (P1-2):will-quit 时把 configStore 闸置 true,拒后续新 save。 + // 时机:after before-quit(dirty 确认完,已经决定退)→ 不会再有新合法 save。 + // 目的:renderer 错误风暴期间 IPC `await saveConfig(...)` 不挂在链尾阻塞 quit。 + try { configStore._setIsQuitting(true); } catch (e) { console.warn('[main] _setIsQuitting 失败:', e && e.message); } // 退出时取消所有在飞的 AI 请求,避免响应被丢弃(付费用 API 也会浪费额度)。 // audit fix (main-M11):把同步清理拆出来,把 revealArmedBySender 兜底清理 // 也挪到这里(armed TTL 兜底 setTimeout 已经 .unref,正常退出流程不会等它, @@ -1182,7 +1240,45 @@ function broadcastDataDirResolved() { }); } -ipcMain.handle('file:list', async () => { +// R5 P1-10:所有 IPC handler 入口的 sender 守卫辅助。每个 handler 顶部一行: +// const denied = guardSender(event); if (denied) return denied; +// 比直接展开 assertMainSender 三行更省眼。顶层 frame 守卫(assertTopFrame, +// P1-14)单独走 guardTopFrame —— 只 arm-reveal 用得上。 +function guardSender(event) { + const r = assertMainSender(event, getMainWindow); + return r.ok ? null : { ok: false, error: r.error }; +} +function guardTopFrame(event) { + const r = assertTopFrame(event); + return r.ok ? null : { ok: false, error: r.error }; +} + +// audit fix (R7a P1-20):file:list 分页缓存。 +// +// 旧版 file:list 一次性返回全部 entries + files,10000 文件 ≈ 1.5-2MB +// IPC payload,结构化克隆 + 渲染端 JSON.parse 都要 30-100ms,首次启动 cold +// paint 明显卡顿。改成「首次 200 + 分页」+ 模块级 cache:scan 一次切片多次 +// 返回,避免 loadMore 反复触发全量 scanDir。 +// +// 缓存逻辑抽到 main/file-list-cache.js(便于单测),本文件只调它。 +// file:write / file:create / file:delete / file:rename 成功后调 +// invalidateFileListCache(),下一次 file:list / file:list:more 重扫。 +const fileListCache = require('./main/file-list-cache.js'); +function invalidateFileListCache() { fileListCache.invalidate(); } + +/** 拉或建 file:list 缓存。dir 变化或缓存被显式失效时重扫。 */ +async function getOrBuildFileListCache(dir) { + return fileListCache.getOrBuild(dir, scanDir); +} + +/** file:list 单页大小。10000 文件 ÷ 200 = 50 页,每页 30-50KB IPC, + * 结构化克隆 < 5ms。配合 P1-15 virtual scroll,首次启动 cold paint + * < 100ms。 */ +const FILE_LIST_PAGE_SIZE = 200; + +ipcMain.handle('file:list', async (event) => { + const denied = guardSender(event); + if (denied) return denied; const dir = currentDataRoot(); currentDataDir = dir; // audit fix (Main P2 / file:list no validation):其他 handler(file:scan-dir @@ -1204,29 +1300,86 @@ ipcMain.handle('file:list', async () => { message: ancestorCheck.message, }; } - // Folder Browser:根目录也走 scanDir,渲染端用 entryType 分流显示文件夹 / 文件 / binary。 - // 为了兼容老调用方,仍然把 entries 镜像成 files(仅 entryType==='editable'|'binary' 的项)。 - const result = await scanDir(dir); - if (!result.ok) { + // audit fix (R7a P1-20):走分页缓存,返回第一页 + total/hasMore。 + const cache = await getOrBuildFileListCache(dir); + if (cache.error) { return { ok: false, files: [], entries: [], - error: result.error, - code: result.code, - message: result.message, + error: cache.error.error, + code: cache.error.code, + message: cache.error.message, }; } - // files = 非文件夹条目(侧栏历史逻辑兼容:state.files 仅含文件) - const files = result.entries - .filter((e) => !e.isFolder) - .map((e) => ({ name: e.name, path: e.path, size: e.size, mtimeMs: e.mtimeMs, entryType: e.entryType })); + const offset = 0; + const limit = FILE_LIST_PAGE_SIZE; return { ok: true, - files, - entries: result.entries, - dir: result.dir, + files: cache.files.slice(offset, offset + limit), + entries: cache.entries.slice(offset, offset + limit), + dir, relDir: '', + total: cache.total, + hasMore: cache.total > limit, + offset, + limit, + }; +}); + +/** + * file:list:more —— 分页后续页(audit R7a P1-20) + * + * 入参 {offset, limit?},offset 必填(无 0 默认可避免 caller 把空参当 offset=0 + * 漏 load 第一页),limit 默认 FILE_LIST_PAGE_SIZE,封顶 1000 防止恶意请求拉 + * 爆。共享 file:list 的缓存;offset >= total 返回 ok + 空数组 + hasMore=false。 + */ +ipcMain.handle('file:list:more', async (event, payload) => { + const denied = guardSender(event); + if (denied) return denied; + const dir = currentDataRoot(); + currentDataDir = dir; + // audit fix (R7a P1-20):分页也走 assertNoSymlinkAncestor 防御: + // 共享 file:list 的 cache,但 cache 可能在 dir 被外部替换成 symlink 后 + // 失效(cache.dir 没变但底层 symlink 改了),仍要每次校验。 + const ancestorCheck = await assertNoSymlinkAncestor(dir, dir); + if (!ancestorCheck.ok) { + return { + ok: false, + files: [], + entries: [], + error: ancestorCheck.error, + code: ancestorCheck.code, + message: ancestorCheck.message, + }; + } + const reqOffset = Number.isFinite(payload && payload.offset) ? payload.offset : -1; + const reqLimit = Number.isFinite(payload && payload.limit) ? payload.limit : FILE_LIST_PAGE_SIZE; + if (reqOffset < 0) { + return { ok: false, error: 'INVALID_OFFSET', code: 'EINVAL' }; + } + const safeOffset = Math.max(0, Math.floor(reqOffset)); + const safeLimit = Math.max(1, Math.min(Math.floor(reqLimit) || FILE_LIST_PAGE_SIZE, 1000)); + const cache = await getOrBuildFileListCache(dir); + if (cache.error) { + return { + ok: false, + files: [], + entries: [], + error: cache.error.error, + code: cache.error.code, + message: cache.error.message, + }; + } + const end = Math.min(safeOffset + safeLimit, cache.total); + return { + ok: true, + files: cache.files.slice(safeOffset, end), + entries: cache.entries.slice(safeOffset, end), + hasMore: end < cache.total, + offset: safeOffset, + limit: safeLimit, + total: cache.total, }; }); @@ -1239,7 +1392,9 @@ ipcMain.handle('file:list', async () => { * 渲染端用这个在子目录之间导航;fs-watcher 也通过 {relDir} 字段告诉 * 渲染端「变更发生在哪一层」,渲染端比对当前目录决定是否重扫。 */ -ipcMain.handle('file:scan-dir', async (_event, relDir) => { +ipcMain.handle('file:scan-dir', async (event, relDir) => { + const denied = guardSender(event); + if (denied) return denied; const dataRoot = currentDataRoot(); const resolved = resolveDirRelative(typeof relDir === 'string' ? relDir : '', dataRoot); if (!resolved.ok) { @@ -1266,6 +1421,11 @@ ipcMain.handle('file:scan-dir', async (_event, relDir) => { relDir: resolved.relDir, }; } + // audit fix (R7a P1-20):Folder Browser 进入子目录 / 切换目录后, + // 根目录 file:list 缓存的「dir 不等于 dataRoot」自然 stale(命中逻辑是 + // _fileListCache.dir === dir,dataRoot 没变就仍命中 —— 根目录的 file:list + // 缓存与子目录 file:scan-dir 解耦,不影响)。这里仅记一笔:scan-dir 不会 + // 改根目录 file:list 缓存,不需要显式 invalidate。 return { ok: true, dir: result.dir, relDir: resolved.relDir, entries: result.entries }; }); @@ -1281,7 +1441,9 @@ ipcMain.handle('file:scan-dir', async (_event, relDir) => { * * relDir 必须经过 resolveDirRelative 校验,防止传入绝对路径或 .. 段越权。 */ -ipcMain.handle('file:watch-dir', async (_event, relDir) => { +ipcMain.handle('file:watch-dir', async (event, relDir) => { + const denied = guardSender(event); + if (denied) return denied; const dataRoot = currentDataRoot(); const resolved = resolveDirRelative(typeof relDir === 'string' ? relDir : '', dataRoot); if (!resolved.ok) { @@ -1305,7 +1467,9 @@ ipcMain.handle('file:watch-dir', async (_event, relDir) => { } }); -ipcMain.handle('file:read', async (_event, filePath) => { +ipcMain.handle('file:read', async (event, filePath) => { + const denied = guardSender(event); + if (denied) return denied; if (typeof filePath !== 'string' || !filePath) { // audit fix (Round 7 IPC-1):补齐 message 字段。renderer 走 // `result.message || result.error || '未知错误'` 兜底,没 message 会退到 @@ -1411,7 +1575,9 @@ ipcMain.handle('file:read', async (_event, filePath) => { * * 不传 expectedMtimeMs(老调用 / 强写场景)则跳过校验。 */ -ipcMain.handle('file:write', async (_event, filePath, content, expectedMtimeMs = null) => { +ipcMain.handle('file:write', async (event, filePath, content, expectedMtimeMs = null) => { + const denied = guardSender(event); + if (denied) return denied; if (typeof filePath !== 'string' || !filePath) { // audit fix (Round 7 IPC-3):补齐 code + message,对齐其它 file:* handler 的 // 字段集。renderer 一律 `result.code / result.message` 取值,没这两个字段 @@ -1507,6 +1673,10 @@ ipcMain.handle('file:write', async (_event, filePath, content, expectedMtimeMs = try { fsWatcher.markExpectedWrite(filePath, st.mtimeMs); } catch (e) { console.warn('[main] markExpectedWrite 失败:', e?.message); } } + // audit fix (R7a P1-20):file:write 成功后让 file:list cache 失效。 + // 文件 size / mtimeMs 都变了;走精细 diff 反而比重扫慢(一次 slice 即可), + // 直接 drop 缓存更省。 + invalidateFileListCache(); return { ok: true, mtimeMs: st.mtimeMs, size: st.size }; } catch (e) { if (e.code === 'ENOENT') { @@ -1529,7 +1699,9 @@ ipcMain.handle('file:write', async (_event, filePath, content, expectedMtimeMs = } }); -ipcMain.handle('file:create', async (_event, rawName, opts) => { +ipcMain.handle('file:create', async (event, rawName, opts) => { + const denied = guardSender(event); + if (denied) return denied; if (typeof rawName !== 'string') { return { ok: false, error: 'INVALID_NAME', code: 'INVALID_NAME', message: '文件名必须是字符串' }; } @@ -1583,6 +1755,9 @@ ipcMain.handle('file:create', async (_event, rawName, opts) => { return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; } } + // 记录目标子目录在创建前是否已存在:仅当我们"这次新建"的空目录才在失败路径清理, + // 避免误删用户原本就存在的空子文件夹(见下方 cleanupEmptySubdir)。 + const targetDirExistedBefore = await fs.access(targetDir).then(() => true).catch(() => false); try { await fs.mkdir(targetDir, { recursive: true }); } catch (e) { @@ -1599,19 +1774,21 @@ ipcMain.handle('file:create', async (_event, rawName, opts) => { }; } - // audit fix (2026-09-21):标记是否新建了子目录,用于失败路径下清理空目录。 + // 标记是否新建了子目录,用于失败路径下清理空目录。 // 之前 mkdir 成功后任何下游失败(resolveFileName / isWithinDataDir / symlink // 防护 / atomicWriteFile)都会留下 targetDir 这个空子目录 —— 数据目录里 - // 出现不可见的垃圾文件夹,下次扫描会列入文件列表。targetDir === dataRoot - // 时不需要清理(root 早就在)。 - const didMkdirSubdir = targetDir !== dataRoot; + // 出现不可见的垃圾文件夹,下次扫描会列入文件列表。 + // 仅当我们本次新建了子目录才清理:pre-existing 的空子文件夹不在清理范围 + // (避免原子写失败时把用户原有空目录也误删)。targetDir === dataRoot 时 + // targetDirExistedBefore 必为 true,同样不会清理。 + const didMkdirSubdir = !targetDirExistedBefore; // best-effort 清理函数:仅当目录为空时移除(已含文件的不动,避免误删)。 async function cleanupEmptySubdir() { if (!didMkdirSubdir) return; try { const entries = await fs.readdir(targetDir); if (entries.length === 0) { - await fs.rmdir(targetDir); + await fs.rm(targetDir, { recursive: true, force: false }); } } catch (cleanupErr) { // 清理本身失败不抛 —— 让原始错误的响应路径主导。 @@ -1679,6 +1856,8 @@ ipcMain.handle('file:create', async (_event, rawName, opts) => { try { fsWatcher.markExpectedWrite(r.path, st.mtimeMs); } catch (e) { console.warn('[main] markExpectedWrite 失败:', e?.message); } } + // audit fix (R7a P1-20):file:create 成功后让 file:list cache 失效(新增条目)。 + invalidateFileListCache(); return { ok: true, path: r.path, name: r.name, mtimeMs: st.mtimeMs, size: st.size }; } catch (e) { // 失败路径:先清理可能新建的空子目录,再按错误类型返回响应。 @@ -1698,13 +1877,143 @@ ipcMain.handle('file:create', async (_event, rawName, opts) => { } }); +/** + * folder:create —— 在指定目录下新建文件夹。 + * + * 设计要点: + * - 与 file:create 对称: + * symlink 防护(assertNotSymlink + assertNoSymlinkAncestor)→ + * resolveFolderName(输入合法性 + 重名避让)→ + * mkdir → fsync parent → 返回 {path, name} 强同步视图 + * - targetDir 缺省 = dataRoot;可在 opts.dir 指定 POSIX 相对子目录。 + * 子目录不存在则自动 mkdir recursive(一层);symlink 父链被 assertNoSymlinkAncestor 拦下。 + * - 不递归创建深链 —— 只在 opts.dir 那一层做 mkdir recursive(resolveFolderName 之前的 mkdir), + * 与 file:create 同源语义:用户主动「在这里建文件夹」 → 自动创建所在目录合理。 + * - 不写文件:mkdir 之后不原子写任何东西。返回字段不含 mtimeMs/size(folder 没有这两项内容展示)。 + * + * 失败路径语义: + * - INVALID_NAME:resolveFolderName 返回 !ok(捕获在 _early) + * - PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED:防御栈统一返回 + * - mkdir errno:EEXIST(已被 resolveFolderName 避让,正常不会撞)/ ENOENT(祖先链断)/ + * EACCES/EPERM(无权限)走统一 _friendly*Error 翻译 + * - 任何 mkdir 后的失败路径都做 best-effort 清理(仿 file:create 行为): + * 只清空子目录,不动根 dataRoot + */ +ipcMain.handle('folder:create', async (event, rawName, opts) => { + const denied = guardSender(event); + if (denied) return denied; + if (typeof rawName !== 'string') { + return { ok: false, error: 'INVALID_NAME', code: 'INVALID_NAME', message: '文件夹名必须是字符串' }; + } + const dataRoot = currentDataRoot(); + if (!dataRoot) return { ok: false, error: 'DATA_DIR_UNAVAILABLE', code: 'DATA_DIR_UNAVAILABLE', message: '数据目录不可用' }; + + // opts 处理:可选 { dir } —— root 下还是某个子目录下 + let targetDir = dataRoot; + if (opts && typeof opts === 'object' && typeof opts.dir === 'string' && opts.dir) { + if (!isWithinDataDir(opts.dir, dataRoot)) { + return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '目标目录不在数据目录内' }; + } + targetDir = opts.dir; + } + + // 关键防御:祖先链 symlink 检查必须在 mkdir 之前 —— 与 file:create 同源(见 line 1580 注释)。 + if (targetDir !== dataRoot) { + const ancestorCheck = await assertNoSymlinkAncestor(targetDir, dataRoot); + if (!ancestorCheck.ok) { + return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; + } + } + let didMkdirSubdir = false; + try { + // mkdir recursive:targetDir 不存在则自动创建(与 file:create 同源); + // 已存在 → noop(下面 resolveFolderName 内的 pathOccupancy 仍会判定 finalPath 是否冲突) + await fs.mkdir(targetDir, { recursive: true }); + if (targetDir !== dataRoot) didMkdirSubdir = true; + } catch (e) { + console.error('[main] folder:create mkdir 失败:', _redactPath(targetDir), e.code || e.message); + return { + ok: false, + error: e.code || 'MKDIR_FAILED', + code: e.code, + message: _friendlyCreateError(e, '所在目录'), + }; + } + + // mkdir 成功后失败路径清理 —— 仅当新建了子目录时清(root 不清) + async function cleanupEmptySubdir() { + if (!didMkdirSubdir) return; + try { + const entries = await fs.readdir(targetDir); + if (entries.length === 0) { + await fs.rm(targetDir, { recursive: true, force: false }); + } + } catch (cleanupErr) { + console.warn('[main] folder:create 清理空子目录失败:', _redactPath(targetDir), cleanupErr.code || cleanupErr.message); + } + } + + try { + const r = await resolveFolderName(rawName, targetDir); + if (!r.ok) { + throw Object.assign(new Error(r.error), { _early: { error: 'INVALID_NAME', code: 'INVALID_NAME', message: r.error } }); + } + if (!isWithinDataDir(r.path, dataRoot)) { + throw Object.assign(new Error('路径不在数据目录内'), { _early: { error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' } }); + } + // target path 的祖先链复查(防御 TOCTOU:resolveFolderName 与 mkdir 之间可能有竞态) + const finalAncestorCheck = await assertNoSymlinkAncestor(r.path, dataRoot); + if (!finalAncestorCheck.ok) { + throw Object.assign(new Error(finalAncestorCheck.message || '祖先链 symlink 防护拒绝'), { _early: finalAncestorCheck }); + } + + // mkdir(target):already-exists 走 noop(resolveFolderName 已避让,正常不应撞); + // 同步 fsync 父目录(POSIX 下与 atomicWriteFile 同源语义 —— 「目录项写入磁盘」语义) + // mkdir 本身在 fsync 之前;POSIX-only 防御,Win NTFS journal 自带元数据持久化无需 fsync。 + try { + await withTimeout(fs.mkdir(r.path, { recursive: false }), undefined, 'folder:create.mkdir-target'); + } catch (e) { + if (e.code === 'EEXIST') { + // 极小概率:resolveFolderName 后某个并发进程恰好放入同名 file/dir → 回到避让再试一次 + throw Object.assign(new Error('目标路径已被占用'), { _early: { error: 'PATH_CONFLICT', code: 'EEXIST', message: '该名称已被占用' } }); + } + throw e; + } + if (process.platform !== 'win32') { + // 与 atomicWriteFile 同源:POSIX rename/mkdir 后 fsync 父目录项 + const dirFh = await fs.open(targetDir, 'r'); + try { + await dirFh.sync(); + } finally { + try { await dirFh.close(); } catch { /* ignore */ } + } + } + invalidateFileListCache(); + return { ok: true, path: r.path, name: r.name }; + } catch (e) { + await cleanupEmptySubdir(); + if (e && e._early) { + return { ok: false, error: e._early.error, code: e._early.code, message: e._early.message }; + } + console.error('[main] folder:create 失败:', _redactPath(targetDir + '/' + rawName), e.code || e.message); + return { + ok: false, + error: e.code || 'CREATE_FOLDER_FAILED', + code: e.code, + message: _friendlyCreateError(e, rawName), + }; + } +}); + /** * file:rename —— 修复两点(audit #4 + audit #12 + audit #7): * - 返回 mtimeMs / size 让 renderer 立刻同步侧边栏与状态栏 * - Windows 上大小写不敏感比对(path.resolve 保留原大小写) * - symlink 防护:拒绝重命名 symlink */ -ipcMain.handle('file:rename', async (_event, oldPath, newName) => { +ipcMain.handle('file:rename', async (event, oldPath, newName) => { + const denied = guardSender(event); + if (denied) return denied; if (typeof oldPath !== 'string' || typeof newName !== 'string') { return { ok: false, error: 'INVALID_ARGS', code: 'INVALID_ARGS', message: '参数类型不合法' }; } @@ -1743,7 +2052,9 @@ ipcMain.handle('file:rename', async (_event, oldPath, newName) => { // 解析新文件名(重命名专用:不强制 .md 后缀,用户输入什么就用什么 —— 见 [[project-no-auto-md-on-rename-2026-08]]; // 仍做路径分隔符 / Windows 保留字符 / 控制字符 / 重名避让校验)。 - const r = await resolveRenameName(newName, dir); + // 解析新文件名(重命名专用):用文件自身的父目录而非 dataRoot 作为 join 基准, + // 否则嵌套目录里的文件重命名会被静默移到根目录顶层(audit 修复)。 + const r = await resolveRenameName(newName, path.dirname(oldPath)); // audit fix (Round 7 IPC-3):补齐 message 字段,并保证 code 一定存在。 // 旧版直接 `code: r.code`,但 resolveRenameName 历史上不返回 code 字段 // (参见 main/file-ops.js#resolveRenameName:返回 `{ ok:false, error }`), @@ -1773,7 +2084,10 @@ ipcMain.handle('file:rename', async (_event, oldPath, newName) => { return { ok: false, error: newAncestorCheck.error, code: newAncestorCheck.code, message: newAncestorCheck.message }; } - // 新路径不能等于原路径(Windows 上大小写不敏感比对,audit #12) + // 新路径不能等于原路径(Windows 上大小写不敏感比对,audit #12)。 + // 同名 rename(basename 相同)会被下方的 isSamePath 捕获 —— r.path 是 + // `path.join(dirname(oldPath), newName)`,basename 相同时必然等于 oldPath, + // 走 unchanged:true + stat 早返回,不需要额外的 isSameName 分支。 const oldResolved = path.resolve(oldPath); const newResolved = path.resolve(r.path); const isSamePath = process.platform === 'win32' @@ -1807,6 +2121,12 @@ ipcMain.handle('file:rename', async (_event, oldPath, newName) => { // audit fix (Round 17 H2):rename 走 withTimeout —— SMB / OneDrive 持锁时 // rename 可能挂死数分钟;超时透传 FS_TIMEOUT 让 renderer 弹友好提示。 await withTimeout(fs.rename(oldPath, r.path), undefined, 'file:rename.rename'); + // P1-6:rename 成功后 fsync 父目录(POSIX 强同步视图)。失败只 warn: + // 文件已改名成功,崩溃时只是「新名可能不持久」,回滚成本高于收益。 + const fsyncResult = await fsyncParentDir(r.path); + if (!fsyncResult.ok) { + console.warn('[main] file:rename fsync 父目录失败(不影响 rename 成功):', fsyncResult.error); + } // 重新 stat 拿到新 mtime/size(audit #4:之前漏掉,renderer 状态栏会短暂无日期) const st = await withTimeout(fs.stat(r.path), undefined, 'file:rename.stat'); // audit fix (Round 16 M1):self-write filter —— rename 触发 fs.watch 两个 @@ -1816,6 +2136,9 @@ ipcMain.handle('file:rename', async (_event, oldPath, newName) => { try { fsWatcher.markExpectedWrite(r.path, st.mtimeMs); } catch (e) { console.warn('[main] markExpectedWrite 失败:', e?.message); } } + // audit fix (R7a P1-20):file:rename 成功后让 file:list cache 失效 + // (name / path 都变了,缓存里旧条目失效)。 + invalidateFileListCache(); return { ok: true, path: r.path, name: r.name, mtimeMs: st.mtimeMs, size: st.size }; } catch (e) { // audit fix (K1-H1):console 走 _redactPath(之前 e.message 直接打到日志, @@ -1831,6 +2154,113 @@ ipcMain.handle('file:rename', async (_event, oldPath, newName) => { } }); +/** + * folder:rename —— 重命名一个文件夹。镜像 file:rename 的防御栈,但: + * - 旧路径必须存在且**是目录**(不是文件、不是不存在) + * - 新路径在父目录下(folder rename 不跨父目录) + * - 用 resolveRenameFolderName 校验 + 重名避让(无扩展名切割,与 file 版本对齐) + * + * 返回 { ok:true, path, name } 让 renderer 把 state.currentDir / breadcrumb / fs-watcher 都同步过去。 + * self-rename(新名 === 旧名)走 short-circuit,返回 unchanged:true。 + */ +ipcMain.handle('folder:rename', async (event, oldPath, newName) => { + const denied = guardSender(event); + if (denied) return denied; + if (typeof oldPath !== 'string' || typeof newName !== 'string') { + return { ok: false, error: 'INVALID_ARGS', code: 'INVALID_ARGS', message: '参数类型不合法' }; + } + const dataRoot = currentDataRoot(); + if (!isWithinDataDir(oldPath, dataRoot)) { + return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '原路径不在数据目录内' }; + } + // 原路径必须是目录(folder:rename 契约)。不像 file:rename 需要查 isFile 区分, + // 这里反过来:要的就是目录、文件则拒。ENOENT 也拒(防止「重命名不存在的文件夹」)。 + let oldStat; + try { + oldStat = await fs.stat(oldPath); + } catch (e) { + if (e.code === 'ENOENT') { + return { ok: false, error: 'FOLDER_NOT_FOUND', code: 'FOLDER_NOT_FOUND', message: '文件夹已被删除' }; + } + return { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: _friendlyShellError(e, '读取状态失败') }; + } + if (!oldStat.isDirectory()) { + return { ok: false, error: 'NOT_A_FOLDER', code: 'NOT_A_FOLDER', message: '该路径不是文件夹' }; + } + // 原路径祖先链 symlink 防护 + const oldAncestorCheck = await assertNoSymlinkAncestor(oldPath, dataRoot); + if (!oldAncestorCheck.ok) { + return { ok: false, error: oldAncestorCheck.error, code: oldAncestorCheck.code, message: oldAncestorCheck.message }; + } + // symlink 拒绝(数据目录里不应出现 symlink 目录,但万一同步盘 / 误操作塞进来) + const linkCheck = await assertNotSymlink(oldPath); + if (!linkCheck.ok) { + return { ok: false, error: linkCheck.error, code: linkCheck.code, message: linkCheck.message }; + } + + // 解析新名(重命名专用,无扩展名切割;带字符校验 + 重名避让) + const parentDir = path.dirname(oldPath); + const r = await resolveRenameFolderName(newName, parentDir); + if (!r.ok) { + return { ok: false, error: 'INVALID_NAME', code: 'INVALID_NAME', message: r.error }; + } + // 新路径必须在父目录下(folder rename 不跨父目录 —— 即使 newName 没 /,resolveRenameFolderName 也只在 parentDir 内 join) + if (!isWithinDataDir(r.path, parentDir)) { + return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '新路径不在原文件夹内' }; + } + const targetLinkCheck = await assertNotSymlink(r.path); + if (!targetLinkCheck.ok) { + return { ok: false, error: targetLinkCheck.error, code: targetLinkCheck.code, message: targetLinkCheck.message }; + } + const newAncestorCheck = await assertNoSymlinkAncestor(r.path, dataRoot); + if (!newAncestorCheck.ok) { + return { ok: false, error: newAncestorCheck.error, code: newAncestorCheck.code, message: newAncestorCheck.message }; + } + + // 同名 short-circuit(Windows 大小写不敏感比对) + const oldResolved = path.resolve(oldPath); + const newResolved = path.resolve(r.path); + const isSamePath = process.platform === 'win32' + ? oldResolved.toLowerCase() === newResolved.toLowerCase() + : oldResolved === newResolved; + if (isSamePath) { + return { ok: true, path: r.path, name: r.name, unchanged: true }; + } + + try { + await withTimeout(fs.rename(oldPath, r.path), undefined, 'folder:rename.rename'); + // P1-6:rename 成功后 fsync 父目录(POSIX 强同步视图)。失败只 warn: + // 文件夹已改名成功,崩溃时只是「新名可能不持久」,回滚成本高于收益。 + const fsyncResult = await fsyncParentDir(r.path); + if (!fsyncResult.ok) { + console.warn('[main] folder:rename fsync 父目录失败(不影响 rename 成功):', fsyncResult.error); + } + // audit fix (Round 16 M1):self-write filter —— rename 触发 fs.watch 事件(旧路径消失 + 新路径出现)。 + // rename 不修改 mtime(POSIX rename(2) 保留 inode mtime),但 window 上的 fs.watch + // 仍会 emit;先标 self-write 让 fsWatcher 在 500ms 内不推向 renderer。 + if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') { + // folder 没有 mtime,用 0 占位标记路径即可 —— markExpectedWrite 已支持这种 fallback + try { fsWatcher.markExpectedWrite(r.path, 0); } + catch (e) { console.warn('[main] markExpectedWrite(folder) 失败:', e?.message); } + } + // 同时把旧路径也标上,避免 fs-watcher 推「旧路径已不在」被误读为外部删除 + if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') { + try { fsWatcher.markExpectedWrite(oldPath, 0); } + catch (e) { console.warn('[main] markExpectedWrite(folder-old) 失败:', e?.message); } + } + invalidateFileListCache(); + return { ok: true, path: r.path, name: r.name }; + } catch (e) { + console.error('[main] folder:rename 失败:', _redactPath(oldPath), '→', _redactPath(r.path), e.code || e.message); + return { + ok: false, + error: e.code || 'RENAME_FAILED', + code: e.code, + message: _friendlyRenameError(e, r.name), + }; + } +}); + /** * file:delete —— 修复一点(audit #7): * - symlink 防护:拒绝删除 symlink(避免 trashItem 跟随到外部真实文件) @@ -1838,7 +2268,9 @@ ipcMain.handle('file:rename', async (_event, oldPath, newName) => { * `assertNotSymlink` 返回 isFile:false 可能是「目录」或「不存在」; * 用 stat 二次判定,存在但不是文件 → 当作目录走 trashItem。 */ -ipcMain.handle('file:delete', async (_event, filePath) => { +ipcMain.handle('file:delete', async (event, filePath) => { + const denied = guardSender(event); + if (denied) return denied; if (typeof filePath !== 'string' || !filePath) { return { ok: false, error: 'INVALID_PATH', code: 'INVALID_PATH', message: '路径必须是字符串' }; } @@ -1875,11 +2307,15 @@ ipcMain.handle('file:delete', async (_event, filePath) => { // 走系统回收站 —— 目录也行;失败兜底 fs.rmdir 删空目录 try { await shell.trashItem(filePath); + // audit fix (R7a P1-20):file:delete 目录成功后让 file:list cache 失效。 + invalidateFileListCache(); return { ok: true, path: filePath, recycled: true, isFolder: true }; } catch (e) { - console.warn('[main] trashItem(目录) 失败,降级到 rmdir:', e.message); + console.warn('[main] trashItem(目录) 失败,降级到 rm:', e.message); try { - await fs.rmdir(filePath); + await fs.rm(filePath, { recursive: true, force: false }); + // audit fix (R7a P1-20):同上(rmdir 降级路径)。 + invalidateFileListCache(); return { ok: true, path: filePath, recycled: false, isFolder: true }; } catch (e2) { // 不拼 e2.message(英文 errno + 路径)—— 其它 file:* handler 都用纯中文 message @@ -1903,6 +2339,8 @@ try { // Electron 内部可能挂死(直到 trash 处理超时才会 reject)。30s 强制让 // renderer 拿到结果。 await withTimeout(shell.trashItem(filePath), undefined, 'file:delete.trash'); + // audit fix (R7a P1-20):file:delete 文件成功后让 file:list cache 失效。 + invalidateFileListCache(); return { ok: true, path: filePath, recycled: true }; } catch (e) { // audit fix (K1-L2):之前 message 拼 e.message(英文 errno + 路径)回到 renderer @@ -1918,6 +2356,180 @@ try { } }); +/** + * file:move —— 把文件 / 文件夹移动到目标文件夹(跨目录 rename,同盘原子)。 + * 拖拽到文件夹 = move;右键「剪切 → 粘贴」也走这里。 + * + * 防御栈与 file:rename 同源: + * - guardSender + 参数类型校验 + * - isWithinDataDir(src 与 dest 都在数据目录内) + * - assertNotSymlink + assertNoSymlinkAncestor(src 与 dest 两侧) + * - 目标必须是真实存在的目录;源必须存在 + * - 文件夹不能移入自身或其子孙(prevent 循环) + * - 目标名走 resolveRenameName / resolveRenameFolderName 做合法性 + 重名避让 + * - rename 成功 → fsync 父目录 + markExpectedWrite + invalidateFileListCache + */ +ipcMain.handle('file:move', async (event, srcPath, destDir) => { + const prepared = await _prepareRelocate(event, srcPath, destDir); + if (prepared.error) return prepared.error; + const { srcPath: src, destPath, destName, srcIsDir } = prepared.value; + try { + await withTimeout(renameWithRetry(src, destPath), undefined, 'file:move.rename'); + // 重命名后 stat 拿 mtime/size(status bar 需要);文件夹无 mtime 概念用 0 占位 + const st = srcIsDir ? null : await withTimeout(fs.stat(destPath), undefined, 'file:move.stat'); + const fsyncResult = await fsyncParentDir(destPath); + if (!fsyncResult.ok) { + console.warn('[main] file:move fsync 父目录失败(不影响移动成功):', fsyncResult.error); + } + if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') { + try { fsWatcher.markExpectedWrite(destPath, srcIsDir ? 0 : st.mtimeMs); } + catch (e) { console.warn('[main] markExpectedWrite(file) 失败:', e?.message); } + } + invalidateFileListCache(); + // 移动目标所在目录的 POSIX 相对路径(相对 dataRoot),供 renderer 同步 + // state.currentFileRelDir:移动当前打开的文件后,onFilesChanged 才不会把 + // 旧目录的"文件已移除"事件误判为"打开的文件被删除"(见 src/file-ops.js _applyMoveToOpenFile)。 + // 走 toRelativeDir helper(main/file-ops.js 导出)而非内联 normalize —— 主进程多处 + // (fs-watcher、file:list)共用同一份 POSIX 相对路径语义,不在此处再实现一遍。 + const movedRelDir = toRelativeDir(path.dirname(destPath), currentDataRoot()); + return srcIsDir + ? { ok: true, path: destPath, name: destName, entryType: 'folder', relDir: movedRelDir } + : { ok: true, path: destPath, name: destName, entryType: 'file', mtimeMs: st.mtimeMs, size: st.size, relDir: movedRelDir }; + } catch (e) { + console.error('[main] file:move 失败:', _redactPath(src), '→', _redactPath(destPath), e.code || e.message); + return { + ok: false, + error: e.code || 'MOVE_FAILED', + code: e.code, + message: _friendlyRenameError(e, destName), + }; + } +}); + +/** + * file:copy —— 把文件 / 文件夹复制到目标文件夹。 + * 拖拽(按住 Ctrl/Cmd)到文件夹 = copy;右键「复制 → 粘贴」也走这里。 + * + * 防御栈与 file:move 同源(见 _prepareRelocate)。 + * 文件 → fs.copyFile;文件夹 → recursiveCopy(跳过 symlink、逐文件复制)。 + * 复制失败 → 清理半成品目标,避免留下半截目录。 + */ +ipcMain.handle('file:copy', async (event, srcPath, destDir) => { + const prepared = await _prepareRelocate(event, srcPath, destDir); + if (prepared.error) return prepared.error; + const { srcPath: src, destPath, destName, srcIsDir } = prepared.value; + try { + if (srcIsDir) { + await withTimeout(recursiveCopy(src, destPath), undefined, 'file:copy.recursive'); + } else { + // recursiveCopy 内部已 mkdir 目标目录;单文件复制需先确保父目录存在 + // (destDir 已确认存在,destPath 是 destDir 下的新名字,父目录即 destDir) + await withTimeout(fs.copyFile(src, destPath), undefined, 'file:copy.copyFile'); + } + const st = srcIsDir ? null : await withTimeout(fs.stat(destPath), undefined, 'file:copy.stat'); + const fsyncResult = await fsyncParentDir(destPath); + if (!fsyncResult.ok) { + console.warn('[main] file:copy fsync 父目录失败(不影响复制成功):', fsyncResult.error); + } + if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') { + try { fsWatcher.markExpectedWrite(destPath, srcIsDir ? 0 : st.mtimeMs); } + catch (e) { console.warn('[main] markExpectedWrite(file) 失败:', e?.message); } + } + invalidateFileListCache(); + return srcIsDir + ? { ok: true, path: destPath, name: destName, entryType: 'folder' } + : { ok: true, path: destPath, name: destName, entryType: 'file', mtimeMs: st.mtimeMs, size: st.size }; + } catch (e) { + // 复制失败清理半成品目标,避免留下半截目录 / 文件 + try { await fs.rm(destPath, { recursive: true, force: true }); } catch { /* ignore */ } + console.error('[main] file:copy 失败:', _redactPath(src), '→', _redactPath(destPath), e.code || e.message); + return { + ok: false, + error: e.code || 'COPY_FAILED', + code: e.code, + message: _friendlyCopyError(e, destName), + }; + } +}); + +/** + * 移动 / 复制共用的前置校验(file:move / file:copy 均走这里)。 + * 返回 { error: }(失败时)或 { value: {...} }(通过时)。 + * envelope 形状统一 { ok:false, error, code?, message },与 file:rename 等对齐。 + */ +async function _prepareRelocate(event, srcPath, destDir) { + const denied = guardSender(event); + if (denied) return { error: denied }; + if (typeof srcPath !== 'string' || !srcPath || typeof destDir !== 'string' || !destDir) { + return { error: { ok: false, error: 'INVALID_ARGS', code: 'INVALID_ARGS', message: '参数类型不合法' } }; + } + const dataRoot = currentDataRoot(); + if (!dataRoot) { + return { error: { ok: false, error: 'DATA_DIR_UNAVAILABLE', code: 'DATA_DIR_UNAVAILABLE', message: '数据目录不可用' } }; + } + if (!isWithinDataDir(srcPath, dataRoot)) { + return { error: { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '源路径不在数据目录内' } }; + } + if (!isWithinDataDir(destDir, dataRoot)) { + return { error: { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '目标目录不在数据目录内' } }; + } + // symlink 防护(audit #7):src / dest 两侧都不允许含 symlink + const linkCheck = await assertNotSymlink(srcPath); + if (!linkCheck.ok) { + return { error: { ok: false, error: linkCheck.error, code: linkCheck.code, message: linkCheck.message } }; + } + const srcAncestorCheck = await assertNoSymlinkAncestor(srcPath, dataRoot); + if (!srcAncestorCheck.ok) { + return { error: { ok: false, error: srcAncestorCheck.error, code: srcAncestorCheck.code, message: srcAncestorCheck.message } }; + } + const destAncestorCheck = await assertNoSymlinkAncestor(destDir, dataRoot); + if (!destAncestorCheck.ok) { + return { error: { ok: false, error: destAncestorCheck.error, code: destAncestorCheck.code, message: destAncestorCheck.message } }; + } + // 目标必须是真实存在的目录 + let destStat; + try { + destStat = await fs.stat(destDir); + } catch (e) { + if (e.code === 'ENOENT') { + return { error: { ok: false, error: 'FOLDER_NOT_FOUND', code: 'ENOENT', message: '目标文件夹已不存在' } }; + } + return { error: { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: _friendlyShellError(e, '读取目标状态失败') } }; + } + if (!destStat.isDirectory()) { + return { error: { ok: false, error: 'NOT_A_DIRECTORY', code: 'NOT_A_DIRECTORY', message: '目标路径不是文件夹' } }; + } + // 源必须存在 + 确定类型 + let srcStat; + try { + srcStat = await fs.stat(srcPath); + } catch (e) { + if (e.code === 'ENOENT') { + return { error: { ok: false, error: 'FILE_NOT_FOUND', code: 'ENOENT', message: '源文件已被删除' } }; + } + return { error: { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: _friendlyShellError(e, '读取源状态失败') } }; + } + const srcIsDir = srcStat.isDirectory(); + // 文件夹不能移入自身或其子孙(防止循环 / 路径穿越) + if (srcIsDir && (destDir === srcPath || destDir.startsWith(srcPath + path.sep))) { + return { error: { ok: false, error: 'TARGET_INSIDE_SOURCE', code: 'TARGET_INSIDE_SOURCE', message: '不能把文件夹移入自身或其子文件夹' } }; + } + // 解析目标名 + 重名避让(文件 / 文件夹走各自 resolver) + const srcBase = path.basename(srcPath); + const r = srcIsDir + ? await resolveRenameFolderName(srcBase, destDir) + : await resolveRenameName(srcBase, destDir); + if (!r.ok) { + return { error: { ok: false, error: 'INVALID_NAME', code: 'INVALID_NAME', message: r.error } }; + } + // 目标路径祖先链复查(防御 TOCTOU) + const finalAncestorCheck = await assertNoSymlinkAncestor(r.path, dataRoot); + if (!finalAncestorCheck.ok) { + return { error: { ok: false, error: finalAncestorCheck.error, code: finalAncestorCheck.code, message: finalAncestorCheck.message } }; + } + return { value: { srcPath, destPath: r.path, destName: r.name, srcIsDir, dataRoot } }; +} + // ============================================ // IPC: 应用信息 // ============================================ @@ -1926,7 +2538,9 @@ ipcMain.handle('app:get-data-dir', () => currentDataRoot()); ipcMain.handle('app:get-default-data-dir', () => configStore.getDefaultDataDir()); ipcMain.handle('app:get-version', () => app.getVersion()); -ipcMain.handle('app:open-data-dir', async () => { +ipcMain.handle('app:open-data-dir', async (event) => { + const denied = guardSender(event); + if (denied) return denied; let dir; try { dir = path.resolve(currentDataRoot()); @@ -1977,7 +2591,9 @@ ipcMain.handle('app:open-data-dir', async () => { // 也是无谓的权限暴露)。这里强制 target 必须是当前数据目录(settings-dialog.js // 也只拿 _configDir 来调用)。路径比对走 path.resolve + 大小写规整化 // (Windows 不区分大小写、POSIX 区分),避免通过 '..' / 大小写绕过。 -ipcMain.handle('app:open-path', async (_event, target) => { +ipcMain.handle('app:open-path', async (event, target) => { + const denied = guardSender(event); + if (denied) return denied; if (typeof target !== 'string' || !target) { // audit fix (Round 8 IPC-5):补齐 message 字段,与 file:read / file:write / // shell:open-dir 等 handler 对齐。renderer 走 `result.message || result.error @@ -2016,7 +2632,9 @@ ipcMain.handle('app:open-path', async (_event, target) => { } }); -ipcMain.handle('app:choose-data-dir', async () => { +ipcMain.handle('app:choose-data-dir', async (event) => { + const denied = guardSender(event); + if (denied) return denied; // audit fix (H5):补 isDestroyed() —— !mainWindow 只能挡 null/undefined, // 异步窗口销毁的窗口期(mainWindow 引用还在但 isDestroyed===true)会进 // showOpenDialog 抛错。X 紧接菜单点「选择数据目录」的场景能复现。 @@ -2042,7 +2660,9 @@ ipcMain.handle('app:choose-data-dir', async () => { * custom 路径」的语义。auto-fallback 已尽量减少用户误触发,这里只对应用户主动点 * 「回到默认」按钮的明确指令。 */ -ipcMain.handle('app:reset-data-dir', async () => { +ipcMain.handle('app:reset-data-dir', async (event) => { + const denied = guardSender(event); + if (denied) return denied; if (!mainWindow || mainWindow.isDestroyed()) { return { ok: false, error: 'NO_WINDOW' }; } @@ -2082,7 +2702,9 @@ ipcMain.handle('app:reset-data-dir', async () => { // ============================================ // IPC: shell.openExternal(链接点击跳转默认浏览器) // ============================================ -ipcMain.handle('shell:open-external', async (_event, url) => { +ipcMain.handle('shell:open-external', async (event, url) => { + const denied = guardSender(event); + if (denied) return denied; // audit fix (Round 7 IPC-10):改成 `{ ok, code, message }` envelope,与全文件 // 其它 IPC handler 对齐。renderer 端 `if (!ok)` 无法区分「URL 不合法」/「shell // 调用失败」/「系统级错误」,统一走 envelope 让 toast 文案可针对原因。 @@ -2160,7 +2782,9 @@ ipcMain.handle('shell:open-external', async (_event, url) => { * 传文件会被拒绝("不是一个目录")。这里单独走 shell.showItemInFolder, * 是 Electron 提供的"显示文件"标准入口。 */ -ipcMain.handle('shell:show-item-in-folder', async (_event, fullPath) => { +ipcMain.handle('shell:show-item-in-folder', async (event, fullPath) => { + const denied = guardSender(event); + if (denied) return denied; if (typeof fullPath !== 'string' || !fullPath) { // audit fix (Round 8 IPC-6):补齐 message 字段,与 shell:open-dir / file:read // 等 handler 对齐。renderer friendlyFsError 兜底走 result.message,没 message @@ -2189,7 +2813,7 @@ ipcMain.handle('shell:show-item-in-folder', async (_event, fullPath) => { // 修复:realpath 拿到「symlink 真实指向」,再次 isWithinDataDir 校验。 // 真实路径不在数据目录 → 拒(错误码 SYMLINK_OUTSIDE,与 file:write // 路径的 SYMLINK_NOT_ALLOWED 对齐但语义更准确)。 - const real = await fs.promises.realpath(fullPath); + const real = await fs.realpath(fullPath); if (!isWithinDataDir(real, currentDataRoot())) { return { ok: false, @@ -2223,7 +2847,9 @@ ipcMain.handle('shell:show-item-in-folder', async (_event, fullPath) => { * 安全:与 `shell:show-item-in-folder` 对称 —— 强制 isWithinDataDir 边界检查, * 不让 renderer 通过这条 IPC 打开任意系统目录。 */ -ipcMain.handle('shell:open-dir', async (_event, dirPath) => { +ipcMain.handle('shell:open-dir', async (event, dirPath) => { + const denied = guardSender(event); + if (denied) return denied; if (typeof dirPath !== 'string' || !dirPath) { // audit fix (Round 7 IPC-4):补齐 code + message 字段,与其它 shell:* handler // 对齐。renderer 走 friendlyFsError(code, message) 没 message 兜底到英文。 @@ -2241,6 +2867,13 @@ ipcMain.handle('shell:open-dir', async (_event, dirPath) => { // 文案与其它通道不一致。 return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; } + // MEDIUM #7 修复:仅查末段 lstat 不够 —— 若路径中某祖先是软链指向数据目录外, + // path.resolve 不跟随,isWithinDataDir 与末段 lstat 都通过,shell.openPath 仍会 + // 跟随祖先软链打开外部目录(与 file:scan-dir 的祖先链防护对齐)。 + const ancestorCheck = await assertNoSymlinkAncestor(resolved, currentDataRoot()); + if (!ancestorCheck.ok) { + return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; + } try { // audit fix:用 lstat 拒绝 symlink 目录(与 file:scan-dir 对齐)。 // fs.stat 跟随 symlink —— resolved 若指向外部目录,stat 仍能拿到目标目录 @@ -2279,7 +2912,9 @@ ipcMain.handle('shell:open-dir', async (_event, dirPath) => { // IPC: 窗口控制 // ============================================ -ipcMain.handle('window:set-always-on-top', (_event, enabled) => { +ipcMain.handle('window:set-always-on-top', (event, enabled) => { + const denied = guardSender(event); + if (denied) return denied; if (!mainWindow || mainWindow.isDestroyed()) return false; if (typeof enabled !== 'boolean') { console.warn('[main] setAlwaysOnTop 收到非布尔值:', enabled); @@ -2294,7 +2929,9 @@ ipcMain.handle('window:set-always-on-top', (_event, enabled) => { } }); -ipcMain.handle('window:minimize', () => { +ipcMain.handle('window:minimize', (event) => { + const denied = guardSender(event); + if (denied) return denied; if (!mainWindow || mainWindow.isDestroyed()) return false; // audit fix (K1-L4):try/catch 兜底 —— minimize() 在窗口被销毁过程中可能抛 // "Object has been destroyed",与 setAlwaysOnTop 的 L4 修复对齐。 @@ -2307,7 +2944,9 @@ ipcMain.handle('window:minimize', () => { } }); -ipcMain.handle('window:toggle-maximize', () => { +ipcMain.handle('window:toggle-maximize', (event) => { + const denied = guardSender(event); + if (denied) return denied; if (!mainWindow || mainWindow.isDestroyed()) return false; try { if (mainWindow.isMaximized()) { @@ -2322,7 +2961,9 @@ ipcMain.handle('window:toggle-maximize', () => { } }); -ipcMain.handle('window:close', () => { +ipcMain.handle('window:close', (event) => { + const denied = guardSender(event); + if (denied) return denied; if (!mainWindow || mainWindow.isDestroyed()) return false; try { mainWindow.close(); @@ -2345,7 +2986,9 @@ function broadcastMaximizeState() { * 渲染端发来修改请求:调 OpenAI 兼容 API,返回 { id, content, responseFormat }。 * 错误统一返回 { ok:false, error, message } 供 renderer 弹 toast。 */ -ipcMain.handle('ai:edit', async (_event, payload) => { +ipcMain.handle('ai:edit', async (event, payload) => { + const denied = guardSender(event); + if (denied) return denied; if (!payload || typeof payload !== 'object') { return { ok: false, error: 'INVALID_PAYLOAD', message: '请求体无效' }; } @@ -2357,17 +3000,30 @@ ipcMain.handle('ai:edit', async (_event, payload) => { * 渲染端发来取消请求:找到对应 requestId 的 AbortController 并触发 abort。 * 找不到(请求已完成 / 超时)静默忽略。 */ -ipcMain.on('ai:cancel', (_event, requestId) => { - if (typeof requestId === 'string' && requestId) { - aiProxy.cancel(requestId); +ipcMain.on('ai:cancel', (event, requestId) => { + // R5 P1-10 / P1-11:sender 守卫 + requestId 格式校验。攻击场景: + // 1) 恶意 webContents 调 ai:cancel 取消别人正在跑的 AI 请求 + // 2) 注入畸形 requestId 探测 aiProxy 内部 Map 结构 + // 两者都拒。 + const denied = guardSender(event); + if (denied) return; + const idCheck = assertAiRequestId(requestId); + if (!idCheck.ok) { + console.warn('[main] ai:cancel 拒绝:requestId 格式不合法'); + return; } + aiProxy.cancel(idCheck.requestId); }); // ============================================ // IPC: 应用设置(持久化) // ============================================ -ipcMain.handle('app:get-settings', () => { +ipcMain.handle('app:get-settings', (event) => { + // R5 P1-10:app:get-settings 返回整份配置(已脱敏 aiApiKey),仍需 sender + // 守卫 —— 防止恶意 webContents 拉配置嗅探 dataDir / theme 等元信息。 + const denied = guardSender(event); + if (denied) return denied; const cfg = configStore.getConfig() || {}; const { aiApiKey, ...rest } = cfg; // P1-1 fix (audit):过滤 aiApiKey 真值,只露布尔标记。renderer 任何位置 @@ -2397,6 +3053,10 @@ const REVEAL_ARMED_TTL_MS = 5_000; const revealArmedBySender = new Map(); ipcMain.handle('app:arm-reveal-ai-key', (event) => { + // R5 P1-14:顶层 frame 守卫。app:reveal-ai-key 流程是「先 arm,5s 内 + // reveal」—— 防子 frame(未来 webview 嵌入)通过调 arm 推进 reveal 状态机。 + const denied = guardSender(event) || guardTopFrame(event); + if (denied) return denied; const senderId = event.sender && event.sender.id; if (typeof senderId !== 'number') { return { ok: false, error: 'INVALID_SENDER' }; @@ -2415,6 +3075,11 @@ ipcMain.handle('app:arm-reveal-ai-key', (event) => { }); ipcMain.handle('app:reveal-ai-key', (event) => { + // R5 P1-10:纵深防御。app:reveal-ai-key 已有 revealArmedBySender Map + // 按 sender.id 二次确认,但加 assertMainSender 让恶意 webContents 根本 + // 进不到 sender-id 提取逻辑(更早 fail)。 + const denied = guardSender(event); + if (denied) return denied; const senderId = event.sender && event.sender.id; const until = (typeof senderId === 'number') ? revealArmedBySender.get(senderId) : undefined; // 任意一条命中即拒绝:没 arm / arm 已过期 / sender 不匹配 @@ -2469,7 +3134,24 @@ ipcMain.handle('app:get-config-path', () => { * 这里用「不接参数」的专用 handler 同时满足两个既有约束:完整路径不过 IPC * (隐私),renderer 也无法传任意路径(安全)。形状与 app:open-data-dir 对齐。 */ -ipcMain.handle('app:open-config-dir', async () => { +// P2-7(audit):app:open-config-dir 审计日志 + 高频调用 warn。 +// 单次打开配置目录无害,但脚本化高频调用能弹一串文件管理器窗口骚扰用户; +// 只检测不拦截(拦截会误伤合法连点),超阈值 console.warn 进 notes-main.log。 +const _openConfigDirRate = require('./main/audit-rate.js').createAuditRateTracker({ + windowMs: 60_000, + threshold: 10, +}); +ipcMain.handle('app:open-config-dir', async (event) => { + const denied = guardSender(event); + if (denied) return denied; + // 审计行:sender id + ISO 时间戳。不写路径(与 _redactPath 的隐私约束同源)。 + console.log('[audit] app:open-config-dir', 'ts=', new Date().toISOString(), + 'senderId=', event && event.sender && event.sender.id); + const _rate = _openConfigDirRate.hit('app:open-config-dir'); + if (_rate.exceeded) { + console.warn('[main] app:open-config-dir 高频调用:', _rate.count, '次/分钟, senderId=', + event && event.sender && event.sender.id); + } let dir; try { dir = path.dirname(configStore.getConfigPath()); @@ -2503,7 +3185,12 @@ ipcMain.handle('app:open-config-dir', async () => { * 渲染端直接把这串东西塞进 toast 很难看。 */ ipcMain.handle('app:save-settings', async (event, partial) => { - if (!partial || typeof partial !== 'object') return { ok: true, settings: configStore.getConfig() }; + // audit fix (P1-1):null / undefined / primitive / Array / Function 一律拒。 + // 旧版 `!partial || typeof partial !== 'object'` 把非法 payload 当「无更新」 + // 静默放过,renderer 缓存与磁盘脱钩(settingsStore.update 拿不到 error 也不 + // 会重试,直到下次合法保存)。改为显式拒返,settingsStore.update 能看到 + // 错误并提示用户。 + if (!isValidPartialSettings(partial)) return { ok: false, error: 'INVALID_PAYLOAD' }; // 目录存在性校验(schema 里用 opts.resolveDir 注入,因为要异步 fs.lstat + access) // audit fix (Security-L4):旧版用 fs.stat 会跟随 symlink —— 用户 / 同步盘 / diff --git a/main/ai.js b/main/ai.js index 2ef2269..2cb92c3 100644 --- a/main/ai.js +++ b/main/ai.js @@ -60,6 +60,12 @@ const ANTHROPIC_API_VERSION = '2023-06-01'; // 不再靠注释提醒同步。 const { AI_ERROR } = require('../shared/ai-errors.js'); const nodeCrypto = require('node:crypto'); +// R3 P0-1:aiApiKey 走 safeStorage 加密后,磁盘上是 enc:v1: 包络;config +// 加载到 appConfig 时已经 decrypt 一次(见 main/config-store.js loadConfig), +// 但这里是直接接 getConfig() 的兜底路径,防御性再 decrypt 一次(passthrough +// 明文,包络也安全),避免未来调用方改动时漏掉解密。 +const secretStore = require('./secret-store.js'); +const { makeCleanupEpoch } = require('./cleanup-epoch.js'); const ERR_NOT_CONFIGURED = AI_ERROR.NOT_CONFIGURED; const ERR_TIMEOUT = AI_ERROR.ERR_TIMEOUT; const ERR_PROVIDER = AI_ERROR.ERR_PROVIDER; @@ -201,7 +207,23 @@ function extractRawReply(parsed) { * @param {string} path * @returns {string} */ +// P2-6 (audit):joinUrl / pickOpenAITokenConfig 每次 AI 请求都重复跑 +// regex + new URL 解析;同 baseURL / 同 model 在一次会话里反复命中, +// LRU 缓存结果。maxSize 64 覆盖「用户试过的 URL + model」组合,无界增长免疫。 +const { createLruCache } = require('./lru-cache.js'); +const _joinUrlCache = createLruCache({ maxSize: 64 }); +const _tokenConfigCache = createLruCache({ maxSize: 64 }); + function joinUrl(base, path) { + const cacheKey = `${base}|${path}`; + const hit = _joinUrlCache.get(cacheKey); + if (hit !== undefined) return hit; + const joined = _joinUrlUncached(base, path); + _joinUrlCache.set(cacheKey, joined); + return joined; +} + +function _joinUrlUncached(base, path) { // P3-3 fix (audit):拆分 query / fragment 后再拼。 // 否则 `https://gw.com?token=abc` 会被拼成 `https://gw.com?token=abc/v1/messages` // (query 后被拼了 path,URL 非法)。query / fragment 也可能在错误回显时 @@ -367,6 +389,11 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) { const pending = new Map(); /** requestId → 它的超时定时器。cancel 时一起清,避免 setTimeout 回调空转。 */ const timers = new Map(); + // audit fix (P1-5):cancelAll 顺序竞争 epoch 闸。cancelAll 三步非原子 + // (abort → clear → clear),与新 runEdit 同步阶段加 entry 存在竞态窗口。 + // cleanupEpoch.invalidate() 让所有「更早 snapshot」的 in-flight runEdit + // 自行 bail(isCurrent → false),避免「同 requestId 重试找不到 controller」。 + const cleanupEpoch = makeCleanupEpoch(); /** audit fix (C4):请求生命周期登记集合(runEdit 入口 → finally),用于 * 真正拦截同 requestId 的重复进入 —— 之前的 pending 集合是 postJson 内部填的, * runEdit 同步阶段检查永远是空,已被注释自承为 dead code。 */ @@ -386,6 +413,10 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) { } function cancelAll() { + // audit fix (P1-5):入口先 invalidate,挡掉「cancelAll 期间新 runEdit + // 同步阶段已加入 entry」竞态。新 runEdit 入口会 snapshot 当前 epoch, + // await 后 isCurrent → false 时 bail,不污染主流程返回。 + cleanupEpoch.invalidate(); for (const controller of pending.values()) controller.abort(); pending.clear(); // C1 (audit):与 cancel() 一样同步清理 timeout,避免 setTimeout 回调空转 @@ -471,6 +502,15 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) { * @returns {{ capTokens: number, tokenField: 'max_tokens' | 'max_completion_tokens', includeTemperature: boolean }} */ function pickOpenAITokenConfig(model) { + const key = String(model || ''); + const hit = _tokenConfigCache.get(key); + if (hit !== undefined) return hit; + const cfg = _pickOpenAITokenConfigUncached(key); + _tokenConfigCache.set(key, cfg); + return cfg; + } + + function _pickOpenAITokenConfigUncached(model) { const m = String(model || '').toLowerCase(); // gpt-5 / o-series → 必须用 max_completion_tokens;上限通常 ≥ 128k;不传 temperature if (/^(gpt-5|o1|o3|o4)/.test(m)) { @@ -605,8 +645,10 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) { // 会回显 URL 或 header),统一走 sanitizeDetail 防止泄露到 toast / 日志 return { ok: false, error: ERR_PROVIDER, message: `网络错误:${sanitizeDetail(msg)}` }; } - cleanup(); - + // 不再此处 cleanup:fetch 已 resolve 但 body 仍在流式读取,取消窗口内 + // (响应头到达 → body 读完前)cancel 需要 pending 中的 controller 才能 abort。 + // 清理统一收口到下方 try/finally(覆盖所有 early-return / 错误路径)。 + try { // audit fix (Round 13 / Sec-H3):3xx 重定向在 manual 模式下表现为 status=0 // 且 type='opaqueredirect'。明示用户配置错了 Base URL,不要走"读 body 取错误信息" // 分支(那里会卡死读 body 或者报错信息误导成"格式错误")。 @@ -714,6 +756,9 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) { return { ok: false, error: ERR_FORMAT, message: 'AI 返回的不是合法 JSON' }; } return { ok: true, json }; + } finally { + cleanup(); + } } /** @@ -752,7 +797,8 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) { async function runOpenAIEdit({ prompt, content, filename, requestId, timeoutMs }) { const config = (typeof getConfig === 'function' ? getConfig() : {}) || {}; const baseURL = String(config.aiBaseUrl || '').trim(); - const apiKey = String(config.aiApiKey || '').trim(); + // R3 P0-1:解密 aiApiKey 包络。secretStore.decrypt 对明文 / 空 / 损坏都有兜底。 + const apiKey = secretStore.decrypt(config.aiApiKey).trim(); const model = String(config.aiModel || '').trim(); const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT; @@ -830,7 +876,8 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) { async function runAnthropicEdit({ prompt, content, filename, requestId, timeoutMs }) { const config = (typeof getConfig === 'function' ? getConfig() : {}) || {}; const baseURL = String(config.aiBaseUrl || '').trim(); - const apiKey = String(config.aiApiKey || '').trim(); + // R3 P0-1:解密 aiApiKey 包络。secretStore.decrypt 对明文 / 空 / 损坏都有兜底。 + const apiKey = secretStore.decrypt(config.aiApiKey).trim(); const model = String(config.aiModel || '').trim(); const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT; @@ -974,23 +1021,46 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) { cancel(requestId); } inFlightRequestIds.add(requestId); + // audit fix (P1-5):拍下 cancelAll epoch 快照。本 runEdit 期间若发生 + // cancelAll(用户点取消、will-quit 触发),await 之后 isCurrent → false + // → 返 ERR_CANCELLED 不应用任何副作用。snapshot 必须在 inFlightRequestIds + // 之后但 await 之前 —— 在 inFlightRequestIds.add 之后确保「新请求已 + // 登记」语义在 cancelAll 之前;invalidate 会同时清 Set 的 entry,但本请求 + // 已登记的「我自己负责清除」语义不变(finally 仍会 delete,但 Set 已空, + // delete 一次 noop)。 + const myEpoch = cleanupEpoch.snapshot(); // 注:token 上限不再用入参 maxTokens(已被 pick*TokenConfig 按模型路由取代) const baseArgs = { prompt, content, filename, requestId, timeoutMs }; try { + let result; if (provider === 'anthropic') { - return await runAnthropicEdit(baseArgs); + result = await runAnthropicEdit(baseArgs); + } else if (provider === 'openai') { + result = await runOpenAIEdit(baseArgs); + } else { + // audit fix (2.5):未知 provider 不再静默按 OpenAI 走 —— 配置错了会让用户困惑。 + // schema enum 已防,但 IPC 直调 / 老 settings 文件可能漏过来,这里明确报错。 + return { + ok: false, + error: ERR_NOT_CONFIGURED, + message: `未知 AI 服务提供方:${provider}(请在设置里选 OpenAI 兼容或 Anthropic 兼容)`, + }; } - if (provider === 'openai') { - return await runOpenAIEdit(baseArgs); + // audit fix (P1-5):await 之后,cancelAll 已 invalidate epoch → 本次 + // 任务期间发生过 cancel,请求结果不再应用(即使 provider 返了 200, + // 也可能是 cancelAll 触发 abort 之前最后一段响应;语义上「用户已取消」 + // 优先于「网络层成功」)。同时这一步拦截「cancelAll 三步非原子期间 + // 新请求被误 abort 之后」场景 —— 新 runEdit 拿不到 response,但 abort + // 信号让它提前抛 AbortError;这里统一以 ERR_CANCELLED 兜底。 + if (!cleanupEpoch.isCurrent(myEpoch)) { + return { + ok: false, + error: ERR_CANCELLED, + message: '请求已被取消', + }; } - // audit fix (2.5):未知 provider 不再静默按 OpenAI 走 —— 配置错了会让用户困惑。 - // schema enum 已防,但 IPC 直调 / 老 settings 文件可能漏过来,这里明确报错。 - return { - ok: false, - error: ERR_NOT_CONFIGURED, - message: `未知 AI 服务提供方:${provider}(请在设置里选 OpenAI 兼容或 Anthropic 兼容)`, - }; + return result; } finally { // audit fix (C4):无论成功 / 失败 / 抛错都清登记,下次同 requestId // 再来能正常进入;防 Set 缓慢增长。 diff --git a/main/audit-rate.js b/main/audit-rate.js new file mode 100644 index 0000000..24d1d63 --- /dev/null +++ b/main/audit-rate.js @@ -0,0 +1,40 @@ +// P2-7(audit):IPC 高频调用检测 —— 滑动窗口按 key 计数。 +// +// 背景:app:open-config-dir 这类「打开系统目录」的 handler 被 renderer 任意 +// JS 调到(XSS / devtools 注入)。单次调用无害,但脚本化高频调用能弹出一串 +// 文件管理器窗口骚扰用户。主进程记一笔滑动窗口计数,超阈值 console.warn +// (会同步进 notes-main.log,事后可查)。 +// +// 只检测、不拦截 —— 拦截合法双击/连点会误伤,先只留审计痕迹。 + +'use strict'; + +/** + * @param {object} opts + * @param {number} opts.windowMs 滑动窗口毫秒数 + * @param {number} opts.threshold 窗口内允许的最大次数;第 threshold+1 次起 exceeded=true + * @param {() => number} [opts.now] 时钟注入,默认 Date.now(单测推进时间用) + * @returns {{ hit: (key: string) => { count: number, exceeded: boolean } }} + */ +function createAuditRateTracker({ windowMs, threshold, now } = {}) { + const clock = typeof now === 'function' ? now : Date.now; + /** @type {Map} key → 窗口内各次调用的时间戳(升序) */ + const buckets = new Map(); + return { + hit(key) { + const t = clock(); + const cutoff = t - windowMs; + let arr = buckets.get(key); + if (!arr) { + arr = []; + buckets.set(key, arr); + } + // 过期剔除:[cutoff, t) 语义 —— 恰好 windowMs 前的记录视为过期 + while (arr.length > 0 && arr[0] <= cutoff) arr.shift(); + arr.push(t); + return { count: arr.length, exceeded: arr.length > threshold }; + }, + }; +} + +module.exports = { createAuditRateTracker }; diff --git a/main/cleanup-epoch.js b/main/cleanup-epoch.js new file mode 100644 index 0000000..fc18fe2 --- /dev/null +++ b/main/cleanup-epoch.js @@ -0,0 +1,70 @@ +// main/ai.js cancelAll —— 顺序竞争 epoch 闸(audit P1-5) +// +// 背景:cancelAll 内部三步 +// 1) for (controller of pending.values()) controller.abort(); +// 2) pending.clear(); +// 3) inFlightRequestIds.clear(); +// 三步非原子。竞态:cancelAll 走到 step 1 刚 abort 完,runEdit 同步阶段 +// 已经把「新 requestId + 新 controller」加进 pending + inFlightRequestIds → +// step 2/3 把这条新 entry 也清掉,runEdit 自以为还在跑,await 不到结果, +// 任何同 requestId 重试都找不到 controller。 +// +// 修复:makeCleanupEpoch() 工厂 → { current, snapshot, invalidate, isCurrent }。 +// - cancelAll 入口 invalidate() → 序号 + 1(任何已 snapshot 的 in-flight 任务 +// 之后 isCurrent() === false → 自行 bail) +// - runEdit 入口 snapshot() 拿当前序号;await 后 isCurrent() === false +// (说明本任务期间发生过 cancelAll)→ 不再 apply 任何副作用、直接 return +// +// 序号从 1 开始自增(0 留给「初始」/「无 epoch」语义)—— isCurrent 比较时 +// 0 vs 0 也算成立(测试覆盖),但 cancelAll invalidate 之后序号一定 > 0, +// 真实 runEdit 拿的 snapshot 至少 1。 + +'use strict'; + +/** + * 创建一个 epoch 计数器对象。cancelAll.invalidate / runEdit.snapshot / runEdit.isCurrent + * 配合使用,挡住「cancelAll 中途新请求进入」的竞态。 + * @returns {{ + * current: () => number, + * snapshot: () => number, + * invalidate: () => void, + * isCurrent: (snap: number) => boolean, + * }} + */ +function makeCleanupEpoch() { + let epoch = 0; + return { + /** + * 当前 epoch 序号。0 = 初始(无 cancelAll 发生过)。 + * @returns {number} + */ + current() { + return epoch; + }, + /** + * 拍下当前 epoch 的快照,用于稍后 isCurrent() 校验。 + * runEdit 入口调用,await 后比对。 + * @returns {number} + */ + snapshot() { + return epoch; + }, + /** + * 自增 epoch,使所有「更早的 snapshot」isCurrent 失效。 + * cancelAll 入口调用。 + */ + invalidate() { + epoch += 1; + }, + /** + * 校验「snap 是否仍是当前 epoch」。 + * @param {number} snap snapshot() 返回的序号 + * @returns {boolean} snap === current + */ + isCurrent(snap) { + return epoch === snap; + }, + }; +} + +module.exports = { makeCleanupEpoch }; diff --git a/main/config-store.js b/main/config-store.js index a567420..8722df8 100644 --- a/main/config-store.js +++ b/main/config-store.js @@ -29,6 +29,13 @@ const { // 与 main/file-ops.js#friendlyWriteError 走同一份文案。避免用户看到 // 「EACCES: permission denied, open '/Users/.../config.json'」英文 errno + 路径。 const { friendlyFsError } = require('../shared/friendly-fs-error.js'); +// audit fix (R3 P0-1):aiApiKey 走 safeStorage 加密层。saveConfig 在写盘前 +// 调 encrypt,loadConfig 在读到包络后调 decrypt —— 上层(ai.js / renderer / +// settingsStore)拿到的 appConfig.aiApiKey 始终是明文。详细迁移 / 降级策略 +// 见 main/secret-store.js 顶部注释。 +const secretStore = require('./secret-store.js'); +// P2-1:tmp 路径生成走加密安全随机(与 main/file-ops.js 共享同一工厂) +const { generateTmpPath } = require('./generate-tmp-path.js'); // 测试注入点:默认走真实 electron.app,单测可以换成 mock app。 // 下划线前缀表示「仅测试用」—— 生产代码不应调用。 @@ -75,6 +82,19 @@ function loadConfig() { try { const parsed = JSON.parse(raw); appConfig = coerceLoadedSettings(parsed); + // R3 P0-1:读盘后立刻把 aiApiKey 包络解回明文。saveConfig 会在写盘前 + // 重新加密;这里只负责「内存里 appConfig.aiApiKey 始终是明文」—— + // 上层(ai.js / settingsStore / renderer)零感知。懒迁移:旧版明文 + // 没前缀,decrypt 原样返回,下一次 saveConfig 自动重新加密。 + if (appConfig.aiApiKey) { + const decrypted = secretStore.decrypt(appConfig.aiApiKey); + // 仅当解密得到非空明文时才覆盖:若密钥库不可用 / 包络损坏导致 decrypt 返回 '', + // 保留磁盘上的原始 enc:v1:... 包络,避免后续 saveConfig(如改主题)因内存值为 '' + // 而把仍有效的加密 Key 覆盖成空串、永久丢失。 + if (decrypted) { + appConfig.aiApiKey = decrypted; + } + } } catch (e) { // 关键修复:之前 `} catch { ... }` 把 JSON 解析错误也吞了 —— 一旦 config.json // 半截 / 格式错(断电、强杀进程、磁盘故障),用户的 aiApiKey / 自定义 dataDir @@ -173,7 +193,24 @@ function cleanupStaleTmpFiles() { * - 写入失败:appConfig 回滚到 pre-merge 快照,调用方拿到 { ok:false, error } */ let saveQueue = Promise.resolve(); +// audit fix (P1-2):isQuitting 闸。before-quit 触发时置 true,新 saveConfig +// 立即返一个已 resolve 的 Promise { ok:false, error:'APP_QUITTING' }(与 queue 路径 +// 返回类型一致,避免调用方对「同步值 vs Promise」分叉处理)—— +// 否则 renderer 错误风暴期间 IPC `await saveConfig(...)` 会挂在链尾, +// before-quit 等不到 save 完成 → quit 阻塞。in-flight 之前的 save 照常 +// 完成(已 fsync 的文件 rename 完才返回 ok:true;强制 abort 会丢正在写的 +// tmp)。主进程退出时「最新一个 save 完成」即可接受。 +let _quitting = false; +function _isQuitting() { + return _quitting; +} +function _setIsQuitting(v) { + _quitting = !!v; +} function saveConfig(next) { + // 闸开 → 立即返同步值(不挂 await)。注意:不入 queue,避免 in-flight 任务 + // resolve 后又被这个新任务接走(保持 queue 长度 = 在跑 + 排队中闸前任务数)。 + if (_quitting) return Promise.resolve({ ok: false, error: 'APP_QUITTING' }); // audit fix (H1):merged 不能在 enqueue 时计算,否则两个并发调用 // 都基于同一份 appConfig 合并,第二个任务写盘时会覆盖第一个任务的改动 // (典型场景:用户同时点 alwaysOnTop + 切 dataDir —— alwaysOnTop 被静默丢)。 @@ -194,7 +231,9 @@ function saveConfig(next) { // 在 await 后进入 fs.open)会算出相同 tmpPath,后到的 rename 因源文件已被消费 // 而失败(ENOENT),上层捕获后返回 {ok:false}。random 后缀 ~36^6 ≈ 21 亿组合, // 同毫秒冲突概率可忽略。main/file-ops.js#atomicWriteFile 也做了对称修复。 - const tmpPath = `${cfgPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; + // P2-1:tmp 路径走加密安全随机。复用 main/generate-tmp-path.js 工厂 + // (同 main/file-ops.js 的 atomicWriteFile 路径生成逻辑)。 + const tmpPath = generateTmpPath(cfgPath); let fh = null; try { await fs.mkdir(path.dirname(cfgPath), { recursive: true }); @@ -204,8 +243,21 @@ function saveConfig(next) { // 原子性,但 fsync 缺失让断电后实际文件可能不是新文件)。AI key / 自定义 dataDir // 这类关键配置丢失 = 用户感知不到为什么 settings 全没了。先 fsync 再 rename 才能 // 保证断电后磁盘上要么是旧 config.json(rename 前断电),要么是完整新文件。 - fh = await fs.open(tmpPath, 'w'); - await fh.writeFile(JSON.stringify(merged, null, 2), 'utf-8'); + // R5 P1-13:tmp 模式 0o600 (rw owner only)。多用户 Linux/macOS 上其他用户 + // 能读父目录 → 也能读默认 0o644 的 tmp,里面就是包络后的 AI Key / 整份 + // 配置(最坏情况 decrypt 失败前的瞬态)。Windows 不强制 mode(用 ACL), + // 但设了不冲突。详见 main/file-ops.js 同号修复。 + fh = await fs.open(tmpPath, 'w', 0o600); + // R3 P0-1:写盘前把 aiApiKey 转成包络。**用 toWrite 浅拷贝**,不污染 + // merged —— merged 还要 return 给 IPC 调用方(renderer settingsStore), + // UI 看到的是明文(masked display)而不是 enc:v1:...。appConfig 也保持 + // 明文,loadConfig 那边解密一次后整轮生命周期对称。 + // secretStore.encrypt 对空串 / 已是包络 / safeStorage 不可用都有兜底。 + const toWrite = { ...merged }; + if (toWrite.aiApiKey) { + toWrite.aiApiKey = secretStore.encrypt(toWrite.aiApiKey); + } + await fh.writeFile(JSON.stringify(toWrite, null, 2), 'utf-8'); await fh.sync(); await fh.close(); fh = null; @@ -397,8 +449,11 @@ function _invalidateResolveCache() { function getConfig() { if (!configLoaded) { - // 防御:调用方忘了 load() —— 隐式初始化一次 - loadConfig(); + // 防御:调用方忘了 init() —— 隐式初始化一次(与 init() 对齐:既 loadConfig + // 也设定 DEFAULT_DATA_DIR,否则 resolveDataDirOrFallback 因 DEFAULT_DATA_DIR 为 + // null 返回 dir:null,fs-watcher 拿 null 去 watch 会抛错进入重连死循环)。 + appConfig = loadConfig(); + if (!DEFAULT_DATA_DIR) DEFAULT_DATA_DIR = getDefaultDataDir(); } return appConfig; } @@ -535,6 +590,8 @@ function _reset() { // 跨 case 串扰(renderer 端 / 用户态不会触发,但单测 _reset 后再 save // 必须从干净 queue 开始)。 saveQueue = Promise.resolve(); + // P1-2:闸也要重置,避免一个 case 置 true 后污染下一个 case。 + _quitting = false; } /** @@ -560,4 +617,6 @@ module.exports = { seedDefaultDataDir, _reset, _setApp, + _isQuitting, + _setIsQuitting, }; diff --git a/main/file-list-cache.js b/main/file-list-cache.js new file mode 100644 index 0000000..07ac751 --- /dev/null +++ b/main/file-list-cache.js @@ -0,0 +1,69 @@ +// file:list 分页缓存(audit R7a P1-20) +// +// 旧版 file:list 一次性返回全部 entries + files,10000 文件 ≈ 1.5-2MB IPC +// payload,结构化克隆 + 渲染端 JSON.parse 都要 30-100ms,首次启动 cold paint +// 明显卡顿。改成「首次 200 + 分页」+ 模块级 cache:scan 一次切片多次返回, +// 避免 loadMore 反复触发全量 scanDir。 +// +// 缓存失效:调用方在 file:write / file:create / file:delete / file:rename 成功 +// 后调 invalidate(),下一次 file:list / file:list:more 重扫。简化策略:不做 +// 精细 dirty entry 合并,整条缓存丢掉。scanDir 1-3s 是「极端大目录(OneDrive +// 1w 文件)」的最坏值,普通 < 100ms,重扫成本可控。 +// +// 设计要点: +// - cache 严格按 dir 区分(Folder Browser 走 file:scan-dir 用 absDir,与根 +// file:list 的 dataRoot 是不同 dir,互不污染)。 +// - 不做 TTL:写操作主动失效是单一来源;fs-watcher 的 emitFilesChanged 不 +// 影响 file:list cache(cache 只服务「分页拉取」,fs-watcher 走的是另 +// 一条 IPC 通道)。 +// - slice 边界由调用方保证(offset / limit 已经在 main.js file:list handler +// 里做过 safe* 夹紧);本模块只做"拿/建缓存 → 切片"。 + +/** @typedef {{ ok:true, dir:string, entries:Array } | { ok:false, error:string, code?:string, message?:string }} ScanResult */ + +/** 缓存项 */ +let _cache = null; // { dir, entries, files, total, at } + +/** 强制失效(写操作成功后调用) */ +function invalidate() { + _cache = null; +} + +/** + * 拉或建 file:list 缓存。dir 变化或缓存被显式失效时重扫。 + * @param {string} dir - 绝对路径 + * @param {(dir: string) => Promise} scanFn - scanDir 注入(测试可换 mock) + * @returns {Promise< + * { dir:string, entries:Array, files:Array, total:number, at:number } + * | { error: ScanResult } + * >} + */ +async function getOrBuild(dir, scanFn) { + if (_cache && _cache.dir === dir) return _cache; + const result = await scanFn(dir); + if (!result.ok) return { error: result }; + const entries = result.entries || []; + // files = 非文件夹条目(侧栏历史逻辑兼容:state.files 仅含文件) + const files = entries + .filter((e) => !e.isFolder) + .map((e) => ({ name: e.name, path: e.path, size: e.size, mtimeMs: e.mtimeMs, entryType: e.entryType })); + _cache = { dir, entries, files, total: entries.length, at: Date.now() }; + return _cache; +} + +/** 拿一份当前缓存的快照(不改)。test hook:单测断言缓存是否被命中/失效。 */ +function peek() { + return _cache; +} + +/** test hook:强制设缓存(不调 scanFn)。 */ +function _setForTest(cache) { + _cache = cache; +} + +module.exports = { + getOrBuild, + invalidate, + peek, + _setForTest, +}; diff --git a/main/file-ops.js b/main/file-ops.js index 1308afc..339974b 100644 --- a/main/file-ops.js +++ b/main/file-ops.js @@ -25,6 +25,9 @@ const path = require('path'); const { EDITABLE_EXTS } = require('../shared/extension-lists.js'); // 单一事实源:errno → 中文提示(main + renderer 共享,preload 过桥) const { friendlyFsError: sharedFriendlyFsError } = require('../shared/friendly-fs-error.js'); +// P2-1:tmp 路径生成走加密安全随机(crypto.randomUUID),不再用 Math.random +const { generateTmpPath } = require('./generate-tmp-path.js'); +const { fsyncParentDir } = require('./fsync-parent-dir.js'); /** 单文件大小上限(5 MB),防止超大文件冻结 renderer。 */ const MAX_FILE_SIZE = 5 * 1024 * 1024; @@ -32,15 +35,17 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; /** * atomicWriteFile 产生的 tmp 文件名匹配规则:`..tmp...`。 * cleanupStaleFileTmpFiles 用它判断哪些 .tmp.* 是自家产物需要清理。 - * 严格要求 pid / ts / rand 三段:pid 与 ts 必须数字,rand 是 6 字符小写 base36。 + * 严格要求 pid / ts / rand 三段:pid 与 ts 必须数字,rand 是 generateTmpPath 生成的 + * 36 位带连字符 UUID(见 main/generate-tmp-path.js,`crypto.randomUUID()`,P2-1 升级而来, + * 旧版是 6 字符小写 base36)。 * * 旧版 `\.tmp\.\d+\.\d+\.[a-z0-9]+$` 太宽 —— 把 `report.tmp.2024.1234567.abcdef` 这种 * 用户合法文件也匹配上,24h 跨进程阈值后会被静默 unlink。要求 .tmp. 前一段是 - * EDITABLE_EXTS 之一(白名单扩展名)把误伤清零。 + * EDITABLE_EXTS 之一(白名单扩展名)把误伤清零;rand 段允许连字符以匹配 UUID。 */ const ATOMIC_TMP_RE = new RegExp( '\\.(?:' + Array.from(EDITABLE_EXTS).map((e) => e.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('|') + - ')\\.tmp\\.\\d+\\.\\d+\\.[a-z0-9]{1,8}$', + ')\\.tmp\\.\\d+\\.\\d+\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', 'i', ); @@ -141,6 +146,33 @@ async function assertNoSymlinkAncestor(filePath, stopAt) { // dataDir 之外 —— isWithinDataDir 的字符串前缀边界被绕过。 // 先 lstat filePath 自身:在 stopResolved 子树里也要拒绝自身是 symlink 的场景。 const resolvedPath = path.resolve(filePath); + + // 边界情形(filePath === stopAt,例如 file:list 以 (dataRoot, dataRoot) 校验 + // 数据根自身):只查 filePath 自身是否 symlink,不再向上遍历祖先。 + // 旧逻辑在边界相等时跳过自检查、却从 dirname(filePath) 一路 lstat 到根 —— + // 既漏掉对 dataRoot 自身的检查(审计威胁模型的核心场景),又会把合法的 + // symlinked /home 等祖先误判成 SYMLINK_NOT_ALLOWED,导致根目录列表永久失败。 + const isBoundary = !!stopResolved && resolvedPath === stopResolved; + if (isBoundary) { + try { + const stSelf = await fs.lstat(resolvedPath); + if (stSelf.isSymbolicLink()) { + return { + ok: false, + error: 'SYMLINK_NOT_ALLOWED', + code: 'SYMLINK_NOT_ALLOWED', + message: `路径中包含符号链接,不允许编辑(${resolvedPath})`, + }; + } + } catch (e) { + // ENOENT:目标本身不存在是允许的(scan-dir 创建场景、新文件);其它错误上报 + if (e.code !== 'ENOENT') { + return { ok: false, error: e.message, code: e.code, message: `读取路径状态失败:${e.code || e.message}` }; + } + } + return { ok: true }; + } + if (!stopResolved || resolvedPath !== stopResolved) { try { const stSelf = await fs.lstat(resolvedPath); @@ -429,6 +461,53 @@ async function scanFiles(dir) { } } +/** + * 简易并发限流器:把 N 个 promise 包成「最多 concurrency 个同时在飞」的执行器。 + * 不引第三方依赖(p-limit / p-queue 等)—— 全文只有 scanDir 一个调用点, + * 20 行内联比 1 个新 dep 更可控。 + * + * 行为: + * - 入队 → 立即调度,若活跃数 < concurrency 则启动执行 + * - 任意 fn resolve / reject → 拉下一个 + * - fn 异常会传给调用方(rethrow),但「下一个」仍会被调度(finally 语义) + * + * @param {number} concurrency - 同时在飞的最大任务数(必须 ≥ 1) + * @returns {(fn: () => Promise) => Promise} + */ +function pLimit(concurrency) { + if (!Number.isFinite(concurrency) || concurrency < 1) { + throw new Error('pLimit: concurrency 必须 ≥ 1'); + } + const queue = []; + let active = 0; + const pump = () => { + while (active < concurrency && queue.length > 0) { + const job = queue.shift(); + active += 1; + // resolve / reject 都触发 pump;catch 防止 fn 抛错后 active 永远不归零 + job.fn().then( + (v) => { active -= 1; job.resolve(v); pump(); }, + (e) => { active -= 1; job.reject(e); pump(); }, + ); + } + }; + return (fn) => new Promise((resolve, reject) => { + queue.push({ fn, resolve, reject }); + pump(); + }); +} + +/** scanDir 单批并发上限(audit R7a P1-17)。 + * + * 背景:libuv 默认 4 worker threads,scanDir 同时发 N 个 fs.stat 会被 libuv + * 串行排队 —— SMB / OneDrive 单次 stat 50-200ms 时 10000 文件 → 30-90s。 + * 并发 32 让网络盘上 stat 仍能流水线(多网卡 in-flight),又不至于打满 + * libuv 池 / fd 上限。32 是个保守值:实测 64 在 4 核机器仍 OK,128 在 Windows + * 上偶发 ETIMEDOUT(WinHTTP max connections per server 默认 6 / process 翻倍 + * 也只有 12),32 是「在所有平台稳定」的最大公约数。 + */ +const SCAN_DIR_CONCURRENCY = 32; + /** * 扫描一个目录下的所有直接条目(文件夹 + 文件),按类型分类。 * Folder Browser(2026-08)的核心入口。 @@ -445,6 +524,9 @@ async function scanFiles(dir) { * - 排序:文件夹在前(按名称),文件在后(按名称); * 让用户先看到目录结构再看到内容,更符合 Explorer/Finder 的直觉 * + * 性能(audit R7a P1-17):fs.stat 用 pLimit(32) 限流,避免 libuv 4 worker + * 排队把 SMB / OneDrive 30-90s 拖到 1-3s。 + * * @param {string} absDir - 数据目录下某一层的绝对路径 * @returns {Promise<{ * ok: true, @@ -491,7 +573,14 @@ async function scanDir(absDir) { safeDirents.push(d); } - const entries = await Promise.all(safeDirents.map(async (d) => { + // audit fix (R7a P1-17):fs.stat 走 pLimit(SCAN_DIR_CONCURRENCY) + allSettled。 + // 旧版 Promise.all(N) 在 SMB / OneDrive 上 N 个 stat 串行排队(libuv 4 worker + // 默认),10000 文件 30-90s;改 32 并发后实测 1-3s。 + // allSettled:单个 stat 抛错(ENOENT 经典 readdir 与 stat 之间被删)不影响 + // 整批,外层 .flatMap 过滤 fulfilled + truthy 即可,与原 .filter(Boolean) 语义 + // 一致(fulfilled=null → drop;rejected → drop;fulfilled=object → keep)。 + const limit = pLimit(SCAN_DIR_CONCURRENCY); + const tasks = safeDirents.map((d) => limit(async () => { const fullPath = path.join(absDir, d.name); if (d.isDirectory()) { return { @@ -520,8 +609,8 @@ async function scanDir(absDir) { // 其它(socket / fifo / block device 等)—— 不展示 return null; })); - - const list = entries.filter(Boolean); + const settled = await Promise.allSettled(tasks); + const list = settled.flatMap((r) => (r.status === 'fulfilled' && r.value) ? [r.value] : []); // 排序:folder 在前、file 在后,同组内按 zh-CN locale list.sort((a, b) => { if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1; @@ -572,6 +661,12 @@ function resolveDirRelative(relDir, dataRoot) { * 给定 dataRoot 与当前目录的绝对路径,反算 POSIX 风格的相对路径。 * 渲染端用于比对 fs-watcher 推送的 relDir 字段。 * + * Windows case-insensitive:dataRoot 存盘时可能被大小写规范化(NTFS 实际路径 + * 大小写取决于创建时),与 absDir 比对时若不做大小写无关比较,会把同盘同一目录 + * 误判成"不在 dataRoot 下"、返回 '' —— renderer 后续 onFilesChanged 拿到空 + * relDir 会把「旧目录的文件被移除」误判为「当前文件被外部删除」而清空编辑器。 + * 比较走 toLowerCase,结果保留原大小写(POSIX 风格 + 可读性)。 + * * @param {string} absDir * @param {string} dataRoot * @returns {string} POSIX 相对路径,根目录时为 '' @@ -580,9 +675,14 @@ function toRelativeDir(absDir, dataRoot) { if (!absDir || !dataRoot) return ''; const a = absDir.replace(/\\/g, '/').replace(/\/+$/, ''); const r = dataRoot.replace(/\\/g, '/').replace(/\/+$/, ''); - if (a === r) return ''; + // Windows 大小写无关比较:dataRoot 与 absDir 指向同一盘同一目录但大小写不同时 + // (NTFS 用户主目录就常见),不能误判成"不在 dataRoot 下"。 + const icmp = process.platform === 'win32' + ? (x, y) => x.toLowerCase() === y.toLowerCase() + : (x, y) => x === y; + if (icmp(a, r)) return ''; const prefix = r + '/'; - if (a.startsWith(prefix)) return a.slice(prefix.length); + if (icmp(a.slice(0, prefix.length), prefix)) return a.slice(r.length + 1); // 不在 dataRoot 下(理论上不该发生)—— 返回空字符串让渲染端走根路径分支 return ''; } @@ -605,15 +705,20 @@ function toRelativeDir(absDir, dataRoot) { * @param {string} content UTF-8 文本 */ async function atomicWriteFile(dst, content) { - // audit fix (Round 16 P0):追加 6 位 base36 随机后缀。pid + Date.now() 只保证 - // 跨进程 / 跨毫秒唯一,同进程同毫秒并发(如自动保存触发的 saveConfig 与 - // 用户手动 Ctrl+S 撞在同一毫秒)会算出相同 tmpPath,后到的 rename 因源文件 - // 已被消费而失败(ENOENT),上层捕获后返回 {ok:false}。random 后缀 ~36^6 ≈ 21 - // 亿组合,同毫秒冲突概率可忽略。main/config-store.js#saveConfig 也做了对称修复。 - const tmpPath = `${dst}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; + // P2-1:tmp 路径走加密安全随机。抽 main/generate-tmp-path.js 工厂, + // 内部用 crypto.randomUUID()(128-bit 加密安全,8-4-4-4-12 hex)替代 + // 旧的 Math.random().toString(36).slice(2, 8) 6 位 base36 后缀。pid + + // Date.now() 跨进程/跨毫秒去重,UUID 兜住同进程同毫秒并发。路径格式 + // `${dst}.tmp.${pid}.${ts}.${randomId}` 保持不变,fs-watcher 旧逻辑 + // 只匹配 `.tmp.*` 前缀不受影响。 + const tmpPath = generateTmpPath(dst); let fh; try { - fh = await fs.open(tmpPath, 'w'); + // R5 P1-13:tmp 文件 mode 0o600 (rw owner only)。多用户 Linux/macOS + // 上其他用户能读父目录 → 也能读默认 0o644 的 tmp,里面可能含 AI Key + // 包络 / 用户笔记内容。Windows 不强制 mode(用 ACL),但设了不冲突; + // 跨平台语义一致。 + fh = await fs.open(tmpPath, 'w', 0o600); await fh.writeFile(content, 'utf-8'); // fsync 关键:没有 fsync,rename 之后断电可能留下「磁盘上 inode 改了 // 但内容还在 page cache、从未刷盘」的零字节文件。Windows 上 fsync 等价 @@ -629,28 +734,20 @@ async function atomicWriteFile(dst, content) { try { await renameWithRetry(tmpPath, dst); - // audit fix (Round 4 P0-1):rename 后 fsync 父目录(仅 Linux / POSIX)。 - // POSIX rename(2) 在同分区下原子,但「目录项本身」写入磁盘的时机由内核 - // 控制;不 fsync 目录就断电,磁盘上可能仍是旧名字 → 文件彻底丢失。 - // Windows 上 NTFS 文件系统层 journal 元数据,这条 fsync 不需要(也无害, - // 但 fs.open(path, 'r') 在目录上 Windows 会拒绝写操作,所以这里走 OS 守卫)。 - // 风险:失败 fsync 不抛(让 save 走 OK 路径),由下次保存自动覆盖。 - if (process.platform !== 'win32') { - // Round 13 fix (H5):dirFh.sync() 抛错时 close() 不会执行 → fd 泄漏。 - // 长跑保存下磁盘压力大 / USB 抖动会让 sync 间歇失败,多次累加触发 - // EMFILE;统一走 try/finally 兜底 close。 - const dirFh = await fs.open(path.dirname(dst), 'r'); - try { - await dirFh.sync(); - } finally { - try { await dirFh.close(); } catch { /* close 失败也无能为力 */ } - } - } } catch (e) { // rename 失败:清理残余 tmp(不影响下次保存) try { await fs.unlink(tmpPath); } catch { /* 文件可能已被 rename 移走 */ } throw e; } + // 父目录 fsync(audit Round 4 P0-1):rename 已在 try/catch 之外,父目录 + // fsync 失败不向上抛 —— 文件已成功 rename 落盘,仅「新名可能不持久化」, + // 走 main/fsync-parent-dir.js#fsyncParentDir(与 main.js file:rename / + // file:move / file:copy 同一份语义:win32 skip、open→sync→close、close + // 失败静默、open/sync 失败返 {ok:false, error})。 + const fsyncResult = await fsyncParentDir(dst); + if (!fsyncResult.ok) { + console.warn('[file-ops] 父目录 fsync 失败(不影响写入成功):', fsyncResult.error); + } } /** @@ -676,6 +773,51 @@ async function renameWithRetry(src, dst) { } } +/** + * audit fix (Folder move/copy):递归复制一个目录到目标目录。 + * 用于 file:copy 复制整个文件夹(含子目录)的场景。 + * + * 安全: + * - 拒绝跟随符号链接 —— 既跳过 src 树里的 symlink 文件/目录(不 follow 到 + * dataDir 外部),也拒绝 symlink 目录作为复制目的地(由调用方在入口已校验, + * 这里再防御一层:递归途中遇到 symlink 直接跳过,绝不 follow)。 + * - 调用方必须保证 srcAbs / destAbs 都在数据目录内、且 srcAbs 本身不是 symlink。 + * + * 行为: + * - 普通文件 → fs.copyFile + * - 普通目录 → mkdir(recursive:false,父已被建好)+ 递归 + * - 其它(socket / fifo / device / symlink)→ 跳过,不复制 + * + * @param {string} srcAbs - 源目录绝对路径(已确认是真实目录) + * @param {string} destAbs - 目标目录绝对路径(已确认不存在,由调用方 mkdir 好父级) + * @returns {Promise} + */ +async function recursiveCopy(srcAbs, destAbs) { + const dirents = await fs.readdir(srcAbs, { withFileTypes: true }); + // 父目录已存在,确保目标目录本体存在(destAbs 是全新的空目录名) + await fs.mkdir(destAbs, { recursive: false }); + for (const d of dirents) { + const srcChild = path.join(srcAbs, d.name); + const destChild = path.join(destAbs, d.name); + // symlink 一律跳过(lstat 探真实类型,不 follow) + if (d.isSymbolicLink()) continue; + if (d.isDirectory()) { + // 递归前再确认不是 symlink 目录(withFileTypes 的 isDirectory 已排除 symlink, + // 但防御性 stat 一层更稳) + try { + const st = await fs.lstat(srcChild); + if (st.isSymbolicLink()) continue; + } catch { + continue; // 读不到就跳过,不阻断整棵复制 + } + await recursiveCopy(srcChild, destChild); + } else if (d.isFile()) { + await fs.copyFile(srcChild, destChild); + } + // 其它类型(FIFO / 设备 / socket)→ 不复制 + } +} + /** * 删除数据目录里残留的 `*.tmp...` 文件。 * 由 main.js 在启动时 / 切换数据目录时调用一次。 @@ -701,11 +843,12 @@ async function cleanupStaleFileTmpFiles(dataDir) { const STALE_THRESHOLD_MS = 60_000; const CROSS_PID_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; for (const f of files) { - // 只匹配 atomicWriteFile 生成的 `..tmp...`。 + // 只匹配 atomicWriteFile 生成的 `..tmp...`, + // rand 为 generateTmpPath 生成的 36 位带连字符 UUID(P2-1 起,非旧版 base36)。 // audit fix (Tmp-L2):旧版 `/\.tmp\.\d+\.\d+\.[a-z0-9]+$/i` 太宽, // 会把用户自己命名 / 同步盘生成 / 备份工具遗留的 `report.tmp.2024.1234567.abcdef` // 这种正常文件当成孤儿静默 unlink。必须要求 .tmp. 前面是 EDITABLE_EXTS - // 之一(白名单扩展名),且 tmp 段是 pid.ts.rand 严格三段数字 + 小写 base36。 + // 之一(白名单扩展名)。 // config-store 的 `config.json.tmp.*` 不在此正则范围内,仍由它自家扫(注释指向 // shared/extension-lists.js#EDITABLE_EXTS,与 fileOps / sidebar 共用扩展名)。 if (!ATOMIC_TMP_RE.test(f)) continue; @@ -753,6 +896,113 @@ function friendlyWriteError(e) { return sharedFriendlyFsError(e && e.code, e && e.message); } +/** + * 文件夹名避让(重命名时同分支),与 resolveFolderName 完全对称。 + * + * 文件夹的「合法名」语义比 file 简单:用户输入什么就用什么,没有强制扩展名; + * Windows 保留字符 / 保留设备名 / 控制字符 / 路径分隔符沿用 file 同一套校验。 + * + * 重名避让时整段 name 加 (2)(3)...,没有 "基础名 + 扩展名" 的两段切割。 + * + * @param {string} raw + * @param {string} parentDir - 父目录绝对路径 + * @returns {Promise<{ok:true, path:string, name:string} | {ok:false, error:string}>} + */ +async function resolveRenameFolderName(raw, parentDir) { + if (typeof raw !== 'string' || !raw.trim()) { + return { ok: false, error: '文件夹名不能为空' }; + } + const name = raw.trim(); + if (/[/\\]/.test(name) || name.includes('..')) { + return { ok: false, error: '文件夹名不能包含路径分隔符或 ..' }; + } + if (/[<>:"|?*]/.test(name)) { + return { ok: false, error: '文件夹名包含 Windows 保留字符(< > : " | ? *)' }; + } + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f]/.test(name)) { + return { ok: false, error: '文件夹名包含非法字符' }; + } + if (WIN_RESERVED_DEVICE_NAMES.has(name.toUpperCase())) { + return { ok: false, error: `"${name}" 是 Windows 保留设备名,不允许作为文件夹名` }; + } + + let candidate = name; + let counter = 2; + for (;;) { + const fullPath = path.join(parentDir, candidate); + if (!isWithinDataDir(fullPath, parentDir)) { + return { ok: false, error: '非法路径' }; + } + const occupancy = await pathOccupancy(fullPath); + if (occupancy === 'free') { + return { ok: true, path: fullPath, name: candidate }; + } + candidate = `${name} (${counter})`; + counter += 1; + if (counter > 1000) return { ok: false, error: '重名次数过多' }; + } +} + +/** + * 文件夹名清洗 + 重名避让。 + * + * 与 file version 的 resolveFileName 完全同源:路径分隔符 / Windows 保留字符 / + * 保留设备名 / 控制字符 / 非空 一律预先拒绝。 + * + * 与 file version 的关键差别: + * - 不强制补扩展名:用户输入 "projects" / "PROJECTS" / "2026.drafts" 一律原样保留 + * - 重名避让只加 (2)/(3)...,不分基础名 / 扩展名切割(文件夹名不切) + * - 占用检测 pathOccupancy 同样适用:父目录里已有同名 file 也会触发避让 + * + * 用途:folder:create IPC handler 调它来拿最终路径 + name,主进程据此 mkdir。 + * + * @param {string} raw + * @param {string} parentDir - 父目录绝对路径(新建文件夹的落点) + * @returns {Promise<{ok:true, path:string, name:string} | {ok:false, error:string}>} + */ +async function resolveFolderName(raw, parentDir) { + if (typeof raw !== 'string' || !raw.trim()) { + return { ok: false, error: '文件夹名不能为空' }; + } + const name = raw.trim(); + // 拒绝路径分隔符与 .. 段 + if (/[/\\]/.test(name) || name.includes('..')) { + return { ok: false, error: '文件夹名不能包含路径分隔符或 ..' }; + } + // Windows 保留字符 + if (/[<>:"|?*]/.test(name)) { + return { ok: false, error: '文件夹名包含 Windows 保留字符(< > : " | ? *)' }; + } + // 控制字符 + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f]/.test(name)) { + return { ok: false, error: '文件夹名包含非法字符' }; + } + // Windows 保留设备名:文件夹不带扩展名分支,直接整段判断;带点(DRAFT.2026)也按整段判断 + //(device name 不带点的语义由 NTFS 决定 —— 但 Notes 里不刻意区分,统按整段拒保守) + if (WIN_RESERVED_DEVICE_NAMES.has(name.toUpperCase())) { + return { ok: false, error: `"${name}" 是 Windows 保留设备名,不允许作为文件夹名` }; + } + + let candidate = name; + let counter = 2; + for (;;) { + const fullPath = path.join(parentDir, candidate); + if (!isWithinDataDir(fullPath, parentDir)) { + return { ok: false, error: '非法路径' }; + } + const occupancy = await pathOccupancy(fullPath); + if (occupancy === 'free') { + return { ok: true, path: fullPath, name: candidate }; + } + // file / directory 都视为占用 + candidate = `${name} (${counter})`; + counter += 1; + if (counter > 1000) return { ok: false, error: '重名次数过多' }; + } +} + module.exports = { MAX_FILE_SIZE, EDITABLE_EXTS, @@ -763,12 +1013,15 @@ module.exports = { isWithinDataDir, resolveFileName, resolveRenameName, + resolveFolderName, + resolveRenameFolderName, scanFiles, scanDir, resolveDirRelative, toRelativeDir, atomicWriteFile, renameWithRetry, + recursiveCopy, cleanupStaleFileTmpFiles, friendlyWriteError, }; diff --git a/main/fs-watcher.js b/main/fs-watcher.js index cdcdc51..99a6435 100644 --- a/main/fs-watcher.js +++ b/main/fs-watcher.js @@ -21,7 +21,17 @@ const fsSync = require('fs'); // 把登记 key 和 dirWatcher 上报的 absPath 都归一化到同一形态。 const path = require('path'); -const DIR_POLL_INTERVAL_MS = 2000; +// audit fix (R7a P1-19):自适应轮询间隔。活动期间(最近 POLL_ACTIVE_WINDOW_MS +// 内有 fs.watch 事件 / 探测到列表变化)用 500ms 高频轮询,静止期 5s 一次。 +// 旧版固定 2s 在 SMB / OneDrive 上常因 scanDir 自身 > 2s + 重入保护 drop 5-15 轮 +// 兜底信号(fs.watch 不可靠环境的唯一变化源),侧栏 stale 数分钟。自适应后: +// - 文件刚改完时高频扫,能在 1s 内反映 +// - 静止后回到 5s 节流,省 SMB 流量 / IO +// - 关键:「不 drop」—— in-flight 时挂 pending flag,完成后立即补一次, +// 上一轮被跳过的探测不会丢失(旧版在 pollInFlight 守卫下直接吞掉) +const POLL_INTERVAL_ACTIVE_MS = 500; +const POLL_INTERVAL_QUIET_MS = 5000; +const POLL_ACTIVE_WINDOW_MS = 2000; const EMIT_DEBOUNCE_MS = 300; // audit fix (Round 7 FS-F8):debounce maxWait —— 持续事件流(git checkout / // 大目录解压 / AI 批量写)让 300ms 防抖窗口永远不能 trailing flush, @@ -94,6 +104,22 @@ function createFsWatcher(deps) { // lastFilesSnapshot → 后完成的可能是先发起的(旧快照覆盖新基线)。 // pollInFlight 守卫:上一轮未返回时直接跳过本次。 let pollInFlight = false; + // audit fix (R7a P1-19):「不 drop」pending。in-flight 期间又触发的 tick + // 不再被吞,挂 pendingPoll = true;in-flight 完成后立即补一次(delay=0), + // 紧跟的那次 tick 间隔回到 adaptive 值。被跳过的探测不再丢。 + let pendingPoll = false; + // audit fix (R7a P1-19 bugfix):「暂停期间不 re-schedule」。pauseDirPolling + // 通过 stopDirPoll 清掉 dirPollTimer + 把 pendingPoll 重置为 false,但 + // 已 in-flight 的 runPollTick 仍在 await scanDir;scanDir 返回后 finally + // 走「无 pending → scheduleNextPoll(getPollInterval())」,等于暂停期间 + // 重新挂了一个 5s 后才退到的轮询定时器,窗口隐藏时白白浪费 SMB / OneDrive + // 流量。pollPaused 标志让 finally 跳过 scheduleNextPoll,真正停止轮询; + // resumeDirPolling 清标志 + startDirPoll() 立即重启。 + let pollPaused = false; + // audit fix (R7a P1-19):自适应轮询的「活动窗口」基线。fs.watch 事件 / + // emitFilesChanged 实际探测到变化时刷新;poll tick 取间隔时比 POLL_ACTIVE_WINDOW_MS, + // 在窗口内 → ACTIVE 500ms,否则 QUIET 5000ms。 + let lastChangeAt = 0; // audit fix (Round 16 M1):self-write filter。file:write / file:create 成功后 // 通过 markExpectedWrite(absPath, mtimeMs) 登记;dirWatcher callback 看到这个 // key 命中 + 未过期 → 跳过 scheduleEmit,避免「renderer 把自己的写盘当外部 @@ -227,6 +253,26 @@ function createFsWatcher(deps) { lastErrorAt = 0; } + /** + * audit fix (R7a P1-19):标记「刚刚有活动」。被 fs.watch callback / scheduleEmit + * / 探测到列表变化时调用。poll tick 读 lastChangeAt 决定下次间隔是 ACTIVE + * (500ms) 还是 QUIET (5000ms)。 + */ + function markActivity() { + lastChangeAt = Date.now(); + } + + /** + * 自适应 poll 间隔:距 lastChangeAt 在 ACTIVE_WINDOW 内 → 500ms,否则 5s。 + * 仅「是否在活动窗口」的二元判定 —— 不做指数退避,简单的「5s 节流」就够 + * 应付 SMB 流量压力(5s 一次 readdir 1w 文件 ≈ 200KB 网络 IO,可忽略)。 + */ + function getPollInterval() { + return (Date.now() - lastChangeAt) < POLL_ACTIVE_WINDOW_MS + ? POLL_INTERVAL_ACTIVE_MS + : POLL_INTERVAL_QUIET_MS; + } + /** * 安全发送(main-M7:teardown 窗口过程抛 throw → unhandledException)。 * @param {object} payload @@ -391,6 +437,10 @@ function createFsWatcher(deps) { // 不向 renderer 推送,避免「刚加载就 emitFilesChanged 又被 renderer 当 // fs:changed 事件 → 触发 scanDir 重复请求」循环。 if (snapshot !== lastFilesSnapshot) { + // audit fix (R7a P1-19):真正探测到列表变化时也算「活动」,把 poll + // 切到 ACTIVE 500ms 高频,确保连续改动(git checkout / AI 批量写) + // 期间每 500ms 兜底一次,即便 fs.watch 漏报。 + markActivity(); lastFilesSnapshot = snapshot; if (!skipInitialEmit) { // 成功路径同样补 relDir 字段(FS-F11);移除绝对 dir 字段(M12)。 @@ -402,6 +452,10 @@ function createFsWatcher(deps) { function scheduleEmit() { const now = Date.now(); + // audit fix (R7a P1-19):fs.watch 事件流持续期间把 poll 切到 ACTIVE 500ms + // 高频。markActivity 写 lastChangeAt,下一次 poll tick 拿到的就是 active + // 间隔;事件流停 2s 后回 QUIET 5s。无需显式定时器,纯「窗口判定」。 + markActivity(); // audit fix (Round 7 FS-F8):maxWait —— 持续事件流(git checkout / // 大目录解压 / AI 批量写)让 300ms 防抖窗口永远 trailing flush 不出。 // burstStartedAt 记录首事件时间,到 maxWait 时强制立即 emit。 @@ -417,7 +471,13 @@ function createFsWatcher(deps) { const t = setTimeout(() => { emitDebounceTimer = null; burstStartedAt = 0; - emitFilesChanged(undefined, dirAtSchedule); + // 防御性兜底:emitFilesChanged 是 async,其 scanDir 之后的 entriesSnapshot / + // safeSend 路径(try 之外)若抛错会变成未捕获拒绝,触发全局 + // unhandledRejection → app.exit(1) 杀进程。这里 .catch 吞掉,避免一次 + // 畸形 scan 结果拖垮整个应用。 + emitFilesChanged(undefined, dirAtSchedule).catch((e) => { + console.warn('[fs-watcher] emitFilesChanged 失败:', e && e.message); + }); }, delay); // audit fix (main-M8):unref —— 否则 fs-watcher 还在挂定时器时 Electron // 主进程不会自然退出(before-quit 取消 close path 后这 300ms 会再卡一下)。 @@ -456,33 +516,83 @@ function createFsWatcher(deps) { reattachTimer = t; } - function startDirPoll() { - if (dirPollTimer || !watchedDir) return; + /** + * audit fix (R7a P1-19):重构 setInterval → setTimeout 链。每次 tick 后 + * 重新决策下次间隔(active / quiet 二元),用 setTimeout 而不是 setInterval + * 才能让间隔真正自适应。 + * + * 「不 drop」语义:in-flight 期间触发的 tick 不再被 pollInFlight 守卫吞掉, + * 挂 pendingPoll = true;in-flight 完成后 nextTick 立即补一次(delay=0), + * 之后再回到 adaptive 间隔。被跳过的探测不会丢。 + * + * @param {number} [delay] - 距上次结束的延迟(ms),默认 = getPollInterval() + */ + function scheduleNextPoll(delay) { + if (!watchedDir) return; + if (dirPollTimer) { + clearTimeout(dirPollTimer); + dirPollTimer = null; + } + const wait = Number.isFinite(delay) ? delay : getPollInterval(); // audit fix (Round 7 FS-F14):unref —— 这个 timer 是长期存活的,叠加 // will-quit 不清 fsWatcher 会在退出路径上拖住主进程。 - dirPollTimer = setInterval(() => { - // audit fix (Round 7 FS-F7):重入保护。慢盘(OneDrive / SMB)上 - // scanDir 可能 > 2s,setInterval 触发新一轮与上一轮 await 并发, - // 争写 lastFilesSnapshot → 后完成的可能是先发起的(旧覆盖新)。 - if (pollInFlight) return; - pollInFlight = true; - updateSnapshot(watchedDir) - .catch((e) => { - // audit fix (Round 7 FS-F16):floating promise 兜底。 - console.warn('[fs-watcher] 轮询 updateSnapshot 抛出:', e && e.message); - }) - .finally(() => { - pollInFlight = false; - }); - }, DIR_POLL_INTERVAL_MS); - if (typeof dirPollTimer.unref === 'function') dirPollTimer.unref(); + const t = setTimeout(() => { + dirPollTimer = null; + runPollTick(); + }, wait); + if (typeof t.unref === 'function') t.unref(); + dirPollTimer = t; + } + + async function runPollTick() { + if (!watchedDir) return; + // audit fix (R7a P1-19):「不 drop」pending。in-flight 时挂 pending, + // 完成后立即补一次(delay=0),再回 adaptive 间隔。 + if (pollInFlight) { + pendingPoll = true; + scheduleNextPoll(getPollInterval()); + return; + } + pollInFlight = true; + try { + await updateSnapshot(watchedDir); + } catch (e) { + // audit fix (Round 7 FS-F16):floating promise 兜底。 + console.warn('[fs-watcher] 轮询 updateSnapshot 抛出:', e && e.message); + } finally { + pollInFlight = false; + } + // 探测完成后再决定下次: + // - pending 标记被 set 过 → 立即(delay=0)补一次 + // - 否则按 adaptive 间隔 + // audit fix (R7a P1-19 bugfix):暂停期间 → 不挂新 timer。pauseDirPolling + // 已经清掉了 dirPollTimer + pendingPoll,但已 in-flight 的 scanDir 在 + // await 上挂了几秒,恢复时如果不查 pollPaused 就走 scheduleNextPoll, + // 等于窗口隐藏期间还有 5s 一次的后台 readdir,违背「暂停省 SMB 流量」 + // 的设计意图(main.js#707-710 的 minimize/hide handler 假设轮询真的停了)。 + if (pollPaused) return; + if (pendingPoll) { + pendingPoll = false; + scheduleNextPoll(0); + } else { + scheduleNextPoll(getPollInterval()); + } + } + + function startDirPoll() { + if (dirPollTimer || !watchedDir) return; + // 启动时算 active:第一次 tick 立即跑(delay=0),给用户即时反馈 + markActivity(); + scheduleNextPoll(0); } function stopDirPoll() { if (dirPollTimer) { - clearInterval(dirPollTimer); + clearTimeout(dirPollTimer); dirPollTimer = null; } + pollInFlight = false; + pendingPoll = false; } /** @@ -587,11 +697,16 @@ function createFsWatcher(deps) { */ function pauseDirPolling() { stopDirPoll(); + // audit fix (R7a P1-19 bugfix):挂「不 re-schedule」闸,让已 in-flight 的 + // runPollTick finally 跳过 scheduleNextPoll;否则 5s 一次的轮询还在悄悄跑, + // 违反 pause 的省 SMB 流量语义。 + pollPaused = true; } /** 恢复轮询,并立刻补扫一次,追上隐藏期间 fs.watch 可能漏掉的改动 */ function resumeDirPolling() { if (!watchedDir) return; + pollPaused = false; startDirPoll(); updateSnapshot(watchedDir); } @@ -652,6 +767,24 @@ function createFsWatcher(deps) { _test_updateSnapshot: updateSnapshot, /** 测试 / 调试用:当前是否在监听某个目录 */ get watchedDir() { return watchedDir; }, + // audit fix (R7a P1-19) 测试钩子:暴露 getPollInterval / markActivity / + // scheduleNextPoll / runPollTick 给单测,验证自适应间隔 + 「不 drop」pending + // 语义。生产路径不暴露 —— 上层不应该直接调轮询内部。 + _test_getPollInterval: getPollInterval, + _test_markActivity: markActivity, + _test_scheduleNextPoll: scheduleNextPoll, + _test_runPollTick: runPollTick, + // audit fix (R7a P1-19) 测试钩子:直接设 watchedDir 跳过 fs.watch 订阅, + // 让单测可以纯驱动 runPollTick 验证 pending 链,不污染临时目录。 + _test_setWatchedDir: (dir) => { watchedDir = dir; }, + // audit fix (R7a P1-19 bugfix) 测试钩子:暴露 pause/resume,让单测验证 + // 暂停期间 in-flight runPollTick finally 不会 re-schedule(否则窗口 + // 隐藏时还在偷偷 readdir)。 + _test_pauseDirPolling: pauseDirPolling, + _test_resumeDirPolling: resumeDirPolling, + // audit fix (R7a P1-19 bugfix) 测试钩子:当前是否挂有 poll timer。pause + // bugfix 测试用它确认 in-flight runPollTick finally 不会偷偷挂新 timer。 + _test_hasPollTimer: () => dirPollTimer !== null, }; } diff --git a/main/fsync-parent-dir.js b/main/fsync-parent-dir.js new file mode 100644 index 0000000..993f2c0 --- /dev/null +++ b/main/fsync-parent-dir.js @@ -0,0 +1,64 @@ +// main.js file:rename / folder:rename —— rename 后父目录 fsync(audit P1-6) +// +// 背景:fs.rename 在 POSIX 下只 fsync 自己的 inode,不 fsync 父目录的 directory entry。 +// 崩溃时新名可能没落盘,恢复后看到的是「旧名 inode + 文件内容」。audit 要求 +// 与 atomicWriteFile(main.js:1801-1809 模式)一致:rename 成功后对父目录做 fsync。 +// +// 现状:folder:create 已经在 mkdir 后做了父目录 fsync(main.js:1854-1864), +// 但 file:rename(main.js:1986)和 folder:rename(main.js:2085)都没有。 +// +// 设计: +// - 抽 fsyncParentDir(filePath, opts?) helper +// - win32 上 NTFS journal 自带元数据持久化,直接 noop(与 folder:create 一致) +// - fsImpl 注入:生产用 Node 内置 fs;单测用 mock 验证 open→sync→close 顺序 +// - close 失败静默吞(与 folder:create 行为一致) +// - open/sync 失败透传错误(不静默 —— rename 已成功,父目录 fsync 失败要可观测) +// +// 调用方语义:rename 成功后调用。失败不抛、返 {ok:false, error, code} 让调用方 +// 决定是否吞(推荐:log warn,不回滚 rename —— 文件已改名成功,父目录 fsync +// 失败只是「崩溃时不保证新名持久化」,回滚成本高、收益低)。 + +'use strict'; + +const path = require('path'); + +/** + * 对 filePath 的父目录做 fsync,确保 directory entry 落盘。 + * @param {string} filePath 文件/文件夹的完整路径 + * @param {{ + * fsImpl?: { open: (p: string, m: string) => Promise<{ sync: () => Promise, close: () => Promise }> }, + * platform?: NodeJS.Platform, + * }} [opts] + * @returns {Promise<{ ok: boolean, error?: string, code?: string, skipped?: string }>} + */ +async function fsyncParentDir(filePath, opts) { + const fsImpl = (opts && opts.fsImpl) || require('fs').promises; + const platform = (opts && opts.platform) || process.platform; + + if (platform === 'win32') { + // NTFS journal 自带元数据持久化,无需 fsync + return { ok: true, skipped: 'win32' }; + } + + const parentDir = path.dirname(filePath); + let fh; + try { + fh = await fsImpl.open(parentDir, 'r'); + } catch (e) { + return { ok: false, error: e.message, code: e.code }; + } + try { + await fh.sync(); + } catch (e) { + return { ok: false, error: e.message, code: e.code }; + } finally { + try { + await fh.close(); + } catch { + // close 失败静默吞(与 folder:create 行为一致 —— 防止 fh 泄漏已尽力) + } + } + return { ok: true }; +} + +module.exports = { fsyncParentDir }; diff --git a/main/generate-tmp-path.js b/main/generate-tmp-path.js new file mode 100644 index 0000000..de67fb9 --- /dev/null +++ b/main/generate-tmp-path.js @@ -0,0 +1,38 @@ +// main/file-ops.js atomicWriteFile —— tmp 路径生成(audit P2-1) +// +// 背景:原 inline 实现 `Math.random().toString(36).slice(2, 8)` +// - Math.random() 不加密安全,并发同毫秒 + 同 pid + 同 dst 可能撞名 +// (虽然 36^6 ≈ 21 亿,实际不撞,但语义上 audit 要求与 crypto.randomUUID 对齐) +// - 仓库其它路径已用 crypto.randomUUID(),统一风格 +// +// 设计: +// - 抽 generateTmpPath(dst, { crypto, pid, now }) 工厂 +// - 注入 crypto / pid / now 便于单测验证随机性来源 +// - 默认走 node:crypto.randomUUID()(128-bit 加密安全) +// - 路径格式:${dst}.tmp.${pid}.${ts}.${randomId} +// 保持现有格式不破坏 fs-watcher(fs-watcher 旧逻辑只匹配 .tmp.* 前缀,randomId +// 改成 UUID 不影响匹配) +// +// 调用方:atomicWriteFile 在 fs.open 之前构造 tmpPath。 + +'use strict'; + +/** + * 生成 atomicWriteFile 用的 tmp 路径。 + * @param {string} dst 目标文件完整路径 + * @param {{ + * crypto?: { randomUUID: () => string }, + * pid?: number, + * now?: () => number, + * }} [opts] 注入依赖,便于单测 + * @returns {string} `${dst}.tmp.${pid}.${ts}.${randomId}` + */ +function generateTmpPath(dst, opts) { + const cryptoImpl = (opts && opts.crypto) || require('node:crypto'); + const pid = (opts && typeof opts.pid === 'number') ? opts.pid : process.pid; + const now = (opts && typeof opts.now === 'function') ? opts.now : Date.now; + const randomId = cryptoImpl.randomUUID(); + return `${dst}.tmp.${pid}.${now()}.${randomId}`; +} + +module.exports = { generateTmpPath }; diff --git a/main/ipc-sender.js b/main/ipc-sender.js new file mode 100644 index 0000000..0a56edf --- /dev/null +++ b/main/ipc-sender.js @@ -0,0 +1,111 @@ +// IPC handler 入口 sender / frame 守卫(audit R5 P1-10 / P1-11 / P1-14) +// ============================================================================ +// +// 背景:渲染端的 window.api.* 是 contextBridge 暴露的,渲染端任意 JS(XSS、 +// devtools console 注入、扩展、未来的 webview)都能调。`renderer:set-dirty` +// 单独加了 sender 校验(main.js:952),其它 20+ file/folder/shell/window/ai +// handler 全裸奔 —— 攻击面包括: +// - 读 / 写 / 删 用户笔记(file:read/write/delete) +// - 改设置 / dataDir / 主题(app:*, window:*) +// - 取消别人正在跑的 AI 请求(ai:cancel —— 跨窗口 race) +// - 弹出 arm 窗口让 reveal-ai-key 流程推进(B-10,未来 webview 嵌入) +// +// 修复:抽 assertMainSender(event, getMainWindow) → { ok:true } | { ok:false, +// error:SENDER_NOT_ALLOWED }。所有 IPC handler 顶部一行 if 早退。 +// senderFrame 守卫(assertTopFrame)单独抽,对子 frame 嵌入场景生效。 +// +// 测试注入:getMainWindow 走参数注入,不直接读 main.js 闭包 —— 单测里 +// 传 mock 即可。vi.mock('electron') 拦不住 CJS require,所以走参数。 +// +// 用法示例(main.js): +// const { assertMainSender, SENDER_NOT_ALLOWED } = require('./ipc-sender.js'); +// ipcMain.handle('file:read', async (event, filePath) => { +// const guard = assertMainSender(event, getMainWindow); +// if (!guard.ok) return { ok: false, error: guard.error }; +// // ... 原有逻辑 +// }); + +'use strict'; + +/** + * IPC handler 调用方来源校验。返回 ok=true 放行,ok=false 拒绝并附错误码。 + * + * @param {Electron.IpcMainInvokeEvent | Electron.IpcMainMessageEvent} event + * IPC 事件对象;读取 event.sender(webContents 引用)。 + * @param {() => (Electron.BrowserWindow | null)} getMainWindow + * 返回当前主窗口的 getter —— 走参数注入避免循环依赖 + 单测里 mock 方便。 + * 主窗口未创建(早期启动 race)或已销毁 → 拒绝。 + * @returns {{ ok: true } | { ok: false, error: string }} + */ +function assertMainSender(event, getMainWindow) { + if (!event || !event.sender) { + return { ok: false, error: SENDER_NOT_ALLOWED }; + } + const win = typeof getMainWindow === 'function' ? getMainWindow() : null; + if (!win || typeof win.isDestroyed !== 'function' || win.isDestroyed()) { + return { ok: false, error: SENDER_NOT_ALLOWED }; + } + if (event.sender !== win.webContents) { + return { ok: false, error: SENDER_NOT_ALLOWED }; + } + return { ok: true }; +} + +/** + * 顶层 frame 守卫:拒绝来自 iframe / webview 子 frame 的调用。 + * 用途:未来给主窗口加 webview 嵌入时(如设置面板内嵌文档预览),防止子 + * frame 里的脚本通过 ipcRenderer 直接调主进程敏感接口。 + * + * @param {Electron.IpcMainInvokeEvent | Electron.IpcMainMessageEvent} event + * @returns {{ ok: true } | { ok: false, error: string }} + */ +function assertTopFrame(event) { + // senderFrame 缺失(极少数场景)= 拒绝保守 + if (!event || !event.senderFrame) { + return { ok: false, error: SENDER_NOT_ALLOWED }; + } + // parent 真值 = 子 frame(iframe / webview / 子 BrowserWindow);null = 顶层 + if (event.senderFrame.parent) { + return { ok: false, error: SENDER_NOT_ALLOWED }; + } + return { ok: true }; +} + +// AI cancel requestId 两种合法格式(main/ai.js:968 / src/ai/ai-chat-panel.js:21): +// - 'ai--'(main 侧 fallback 生成) +// - ''(renderer 侧 crypto.randomUUID()) +// 这条正则同时拒掉任意垃圾字符串、过长 payload(防内存放大)、非 hex 字符。 +// 编译期一次,运行时零开销。 +const RE_AI_REQUEST_ID = /^(?:ai-\d{1,18}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i; + +/** + * ai:cancel IPC 的 requestId 格式校验。配合 assertMainSender 做纵深防御: + * - assertMainSender 拒掉恶意 webContents + * - assertAiRequestId 拒掉畸形 requestId(防 typo / 攻击者探测 Map 结构) + * + * @param {unknown} requestId + * @returns {{ ok: true, requestId: string } | { ok: false, error: string }} + */ +function assertAiRequestId(requestId) { + if (typeof requestId !== 'string') { + return { ok: false, error: 'INVALID_REQUEST_ID' }; + } + if (requestId.length > 200) { + return { ok: false, error: 'INVALID_REQUEST_ID' }; + } + if (!RE_AI_REQUEST_ID.test(requestId)) { + return { ok: false, error: 'INVALID_REQUEST_ID' }; + } + return { ok: true, requestId }; +} + +const SENDER_NOT_ALLOWED = 'SENDER_NOT_ALLOWED'; +const INVALID_REQUEST_ID = 'INVALID_REQUEST_ID'; + +module.exports = { + assertMainSender, + assertTopFrame, + assertAiRequestId, + SENDER_NOT_ALLOWED, + INVALID_REQUEST_ID, +}; diff --git a/main/lru-cache.js b/main/lru-cache.js new file mode 100644 index 0000000..b1c1d26 --- /dev/null +++ b/main/lru-cache.js @@ -0,0 +1,42 @@ +// P2-6(audit):通用 LRU 缓存 —— Map 迭代序即插入序,get/set 命中时 +// delete+set 把键提到最新;set 超 maxSize 删最老(第一个键)。 +// +// 用途:main/ai.js 的 pickOpenAITokenConfig / joinUrl 每次调用重复跑 +// 正则 + new URL 解析;同 model / 同 baseURL 在一次会话里反复命中, +// 缓存结果省 CPU。maxSize 64 足够覆盖「用户试过的模型 + URL」组合, +// 不会无界增长。 + +'use strict'; + +/** + * @param {object} [opts] + * @param {number} [opts.maxSize=64] + * @returns {{ get: (k: any) => any, set: (k: any, v: any) => void, + * has: (k: any) => boolean, size: number }} + */ +function createLruCache({ maxSize = 64 } = {}) { + const map = new Map(); + return { + get(key) { + if (!map.has(key)) return undefined; + const v = map.get(key); + // LRU:提到最新 + map.delete(key); + map.set(key, v); + return v; + }, + set(key, value) { + // 覆盖同键 = 使用一次,提到最新 + if (map.has(key)) map.delete(key); + map.set(key, value); + if (map.size > maxSize) { + // Map 第一个键 = 最老 + map.delete(map.keys().next().value); + } + }, + has(key) { return map.has(key); }, + get size() { return map.size; }, + }; +} + +module.exports = { createLruCache }; diff --git a/main/main-log-writer.js b/main/main-log-writer.js new file mode 100644 index 0000000..029d076 --- /dev/null +++ b/main/main-log-writer.js @@ -0,0 +1,105 @@ +// P2-10 / P2-12(audit):主进程日志异步缓冲写出。 +// +// 旧实现(main.js 顶部 console 重写层):每行 mkdirSync + appendFileSync —— +// 同步 IO 直接阻塞 main 事件循环。AI 流式 / fsWatcher 高频日志期间, +// 每次 console.* 都是一次磁盘往返;顶层同步 fs 也拖慢启动路径。 +// +// 新设计:write(line) 只进内存缓冲并调度一次 flush 定时器(默认 250ms, +// .unref() 不拖进程退出);flush 把整段缓冲拼成一次 mkdir(recursive) + +// appendFile 异步写出。崩溃窗口 = 最多 flushMs 毫秒的日志丢失 —— +// 对诊断日志可接受(崩溃路径另有 stderr 兜底)。 +// +// 失败语义与旧版一致:写盘失败静默丢弃(不应影响主流程)。 +// 缓冲上限 maxBuffered 行,超出丢最老,防 AI 刷屏撑爆内存。 + +'use strict'; + +/** + * @param {object} opts + * @param {string} opts.logDir + * @param {string} opts.logFile + * @param {{ mkdir: Function, appendFile: Function }} [opts.fsImpl] 默认 require('fs').promises + * @param {number} [opts.flushMs=250] + * @param {number} [opts.maxBuffered=1000] + * @param {Function} [opts.setTimeoutImpl] + * @param {Function} [opts.clearTimeoutImpl] + */ +function createMainLogWriter({ + logDir, + logFile, + fsImpl, + flushMs = 250, + maxBuffered = 1000, + setTimeoutImpl, + clearTimeoutImpl, +}) { + const fs = fsImpl || require('fs').promises; + const _setTimeout = setTimeoutImpl || setTimeout; + const _clearTimeout = clearTimeoutImpl || clearTimeout; + + /** @type {string[]} */ + let buffer = []; + /** @type {Promise|null} mkdir 只做一次,缓存 promise */ + let mkdirPromise = null; + /** @type {Promise|null} in-flight flush,dispose/重复 flush 复用 */ + let flushing = null; + let timer = null; + + function ensureDir() { + if (!mkdirPromise) { + mkdirPromise = fs.mkdir(logDir, { recursive: true }).catch(() => { + // mkdir 失败:下次 flush 重试(不缓存失败的 promise) + mkdirPromise = null; + throw new Error('mkdir failed'); + }); + } + return mkdirPromise; + } + + async function flush() { + if (flushing) return flushing; + if (buffer.length === 0) return; + const lines = buffer; + buffer = []; + flushing = (async () => { + try { + await ensureDir(); + await fs.appendFile(logFile, lines.join('')); + } catch { + // 写日志失败静默丢弃 —— 与旧同步实现「catch 忽略」语义一致 + } finally { + flushing = null; + } + })(); + return flushing; + } + + function scheduleFlush() { + if (timer) return; + timer = _setTimeout(() => { + timer = null; + flush(); + }, flushMs); + // 日志定时器不应单独吊住进程退出 + if (timer && typeof timer.unref === 'function') timer.unref(); + } + + return { + write(line) { + buffer.push(line); + if (buffer.length > maxBuffered) { + buffer.splice(0, buffer.length - maxBuffered); + } + scheduleFlush(); + }, + flush, + dispose() { + if (timer) { + _clearTimeout(timer); + timer = null; + } + }, + }; +} + +module.exports = { createMainLogWriter }; diff --git a/main/save-settings-validate.js b/main/save-settings-validate.js new file mode 100644 index 0000000..9f673dc --- /dev/null +++ b/main/save-settings-validate.js @@ -0,0 +1,32 @@ +// main.js app:save-settings —— partial payload 校验(audit P1-1) +// +// 背景:原 handler `if (!partial || typeof partial !== 'object') return +// { ok: true, settings: configStore.getConfig() };` 把 null / undefined / +// string / array 都当成「无更新」静默放过,renderer 缓存与磁盘脱钩 +// (settingsStore.update({...}) 拿不到 error 也不会重试,直到下次合法保存)。 +// +// 修复:抽 isValidPartialSettings(partial) 纯函数;handler 顶部调用, +// 拒收集合 → 返回 { ok: false, error: 'INVALID_PAYLOAD' }。 +// +// 为什么纯函数 + 抽出: +// 1. 单测无需 mock ipcMain / Electron 运行时; +// 2. 日后若 renderer 端要前置校验(避免 round-trip),同一函数可直接 import。 +// +// 拒收集合:null / undefined / 任何 primitive / Array / Function。 +// 接收:plain object(typeof === 'object' && !Array.isArray)。 + +'use strict'; + +/** + * @param {unknown} partial + * @returns {boolean} true = 合法 partial(plain object),false = 应拒 + */ +function isValidPartialSettings(partial) { + return ( + partial !== null && + typeof partial === 'object' && + !Array.isArray(partial) + ); +} + +module.exports = { isValidPartialSettings }; diff --git a/main/secret-store.js b/main/secret-store.js new file mode 100644 index 0000000..ea0f80a --- /dev/null +++ b/main/secret-store.js @@ -0,0 +1,167 @@ +// API Key 等敏感字段的加密存储层(audit P0-1) +// ============================================================================ +// +// 背景:原来 aiApiKey 直接以明文写入 userData/config.json。任何能拿到 +// userData 目录副本的人 / 备份脚本 / 同步盘历史版本都拿得到明文 Key。 +// +// 修复:用 electron.safeStorage(DPAPI / Keychain / libsecret)做对称加密, +// 磁盘仅留包络(enc:v1:)。运行时内存需要时再解密 +// 给 fetch 用 —— 主进程栈上才有明文窗口。 +// +// 包络格式: +// - 'enc:v1:' 前缀 + base64 编码的密文 Buffer +// - 没有前缀 = 旧版明文(升级前配置),按明文处理(懒迁移见下) +// - 前缀之后是 v1 版本号:未来升级算法 / 改用非对称 / 换 key 时 bump 到 v2 +// +// 降级(isEncryptionAvailable() === false,典型 Linux 无 gnome-keyring/kwallet): +// - encrypt 返回明文 + console.warn 一次(每进程) +// - decrypt 遇到包络但 keyring 不可用 → 返回 ''(AI 配置临时不可用,UI 提示重输) +// +// 迁移(懒迁移,audit P0-1 要求"启动时一次性加密" → 这里更轻量): +// - 老配置 plaintext 在 loadConfig 时进内存仍是明文 +// - 任意 settingsStore.update 触发 saveConfig → 写入前自动加密 +// - 不在 loadConfig 里主动写盘:避免「启动 + 配置没改也要做一次 IO」 +// - 透明:上层读 appConfig.aiApiKey 永远拿到明文(loadConfig decrypt 一次, +// saveConfig 只在写盘路径加密,merged 内存对象保持明文) +// +// 测试注入:_setSafeStorage(mock) 替换底层 safeStorage,_reset() 还原。 +// 单测里 vi.mock('electron') 拦不住 CJS require(与 main/config-store.js +// 同模式),用本模块自己的 setter。 + +'use strict'; + +const ENVELOPE_PREFIX = 'enc:v1:'; +let _warnedUnavailable = false; +let _safeStorageOverride = null; + +/** + * 取当前生效的 safeStorage(生产 = electron.safeStorage,单测 = 注入的 mock)。 + * 模块未在 Electron 内运行时(极少见,e.g. 脚本化测试)返回 null。 + * @returns {object|null} + */ +function getSafeStorage() { + if (_safeStorageOverride !== null) return _safeStorageOverride; + try { + const electron = require('electron'); + return electron && electron.safeStorage ? electron.safeStorage : null; + } catch { + return null; + } +} + +/** + * 系统密钥库是否可用。不可用时(典型 Linux 无 keyring)→ 降级到明文存储。 + * @returns {boolean} + */ +function isAvailable() { + const ss = getSafeStorage(); + if (!ss || typeof ss.isEncryptionAvailable !== 'function') return false; + try { + return !!ss.isEncryptionAvailable(); + } catch { + return false; + } +} + +/** + * 「不可用」时一次性 warn。每个进程只 warn 一次,避免设置面板来回点 + * 反复触发;用户看到的也是控制台一次性提示。 + * @param {string} reason + */ +function warnOnceDegraded(reason) { + if (_warnedUnavailable) return; + _warnedUnavailable = true; + console.warn( + `[secret-store] safeStorage 不可用 (${reason}); API Key 以明文写入 config.json(降级安全)。` + + 'Linux 上安装 gnome-keyring / kwallet 后重启即可恢复加密。' + ); +} + +/** + * 加密一段明文 → 包络字符串(可写盘)。 + * 输入:任意 string(空串、null、undefined 都安全处理)。 + * 输出: + * - 空 / null / undefined → ''(不写盘也无所谓;saveConfig 会跳过空 aiApiKey) + * - 已是包络(enc:v1:...)→ 原样返回(幂等,防止重复加密变套娃) + * - 明文 + safeStorage 可用 → 'enc:v1:' + base64(ciphertext) + * - 明文 + safeStorage 不可用 → 原明文 + warn 一次(降级) + * @param {string|null|undefined} plain + * @returns {string} + */ +function encrypt(plain) { + if (plain == null) return ''; + const s = String(plain); + if (s === '') return ''; + if (s.startsWith(ENVELOPE_PREFIX)) return s; + const ss = getSafeStorage(); + if (!ss || !isAvailable()) { + warnOnceDegraded('isEncryptionAvailable() === false'); + return s; + } + let buf; + try { + buf = ss.encryptString(s); + } catch (e) { + warnOnceDegraded(`encryptString 抛错: ${e && e.message ? e.message : 'unknown'}`); + return s; + } + return ENVELOPE_PREFIX + buf.toString('base64'); +} + +/** + * 解密包络 → 明文。 + * 输入:任意 string(空 / null / undefined 安全)。 + * 输出: + * - 空 / null / undefined → '' + * - 没有 enc:v1: 前缀 → 原样返回(旧版明文 passthrough;懒迁移关键) + * - 包络 + safeStorage 可用 → 解密后的明文 + * - 包络 + safeStorage 不可用 → ''(AI 临时不可用,UI 应引导重输) + * - 包络 + decryptString 抛错(损坏 / 跨用户 / 改 key 后)→ ''(同不可用) + * @param {string|null|undefined} stored + * @returns {string} + */ +function decrypt(stored) { + if (stored == null) return ''; + const s = String(stored); + if (s === '') return ''; + if (!s.startsWith(ENVELOPE_PREFIX)) return s; + const ss = getSafeStorage(); + if (!ss || !isAvailable()) return ''; + try { + const b64 = s.slice(ENVELOPE_PREFIX.length); + const buf = Buffer.from(b64, 'base64'); + return ss.decryptString(buf); + } catch { + return ''; + } +} + +// ---- 测试注入点(生产代码不应调用) ---- + +/** + * 替换底层 safeStorage。传 null 还原到 require('electron') 路径。 + * @param {object|null} mock + */ +function _setSafeStorage(mock) { + _safeStorageOverride = mock; + // 重置 warn 标志 —— 测试之间不串 + _warnedUnavailable = false; +} + +/** + * 重置模块状态(safeStorage 注入 + warn 标志)回默认。 + * 测试 beforeEach 用。 + */ +function _reset() { + _safeStorageOverride = null; + _warnedUnavailable = false; +} + +module.exports = { + encrypt, + decrypt, + isAvailable, + ENVELOPE_PREFIX, + _setSafeStorage, + _reset, +}; diff --git a/preload.js b/preload.js index 758666b..3b2b5ab 100644 --- a/preload.js +++ b/preload.js @@ -19,7 +19,6 @@ const { contextBridge, ipcRenderer, nativeImage } = require('electron'); const { marked } = require('marked'); const nodePath = require('path'); -const nodeFs = require('fs'); // 把 icon.ico 在 preload 启动时一次性转成 PNG data URL,渲染端作为 用。 // nativeImage 直接读 .ico 会拿到 Windows 编码, 不一定能渲染;toDataURL() @@ -27,12 +26,14 @@ const nodeFs = require('fs'); // 打包后路径仍指向资源目录。读不到 → 返回 null,渲染端继续走 .app-title::before 占位。 let APP_ICON_DATA_URL = null; try { + // P2-11:去掉 nodeFs.existsSync(iconPath) 同步 syscall。 + // nativeImage.createFromPath 本身就安全 —— 不存在的路径返 empty image 不抛, + // 下面 isEmpty() 守卫拦下空 image。existsSync 一次 preload 启动只跑一次,但 + // 在冷启动 path 上属于可省的 IO;删了不改变行为,启动期少一次 stat。 const iconPath = nodePath.join(__dirname, 'icon.ico'); - if (nodeFs.existsSync(iconPath)) { - const img = nativeImage.createFromPath(iconPath); - if (img && !img.isEmpty()) { - APP_ICON_DATA_URL = img.toDataURL(); - } + const img = nativeImage.createFromPath(iconPath); + if (img && !img.isEmpty()) { + APP_ICON_DATA_URL = img.toDataURL(); } } catch { // nativeImage 在某些 headless 测试 / 非 Electron 上下文里会失败;吞掉走 null 路径 @@ -251,6 +252,8 @@ contextBridge.exposeInMainWorld('api', { // 文件操作(只读 + 写入 + 文件管理) listFiles: () => ipcRenderer.invoke('file:list'), + // R7a P1-20:分页后续页。offset 必填(首屏由 listFiles 拿),limit 默认 200。 + listFilesMore: (offset, limit) => ipcRenderer.invoke('file:list:more', { offset, limit }), // Folder Browser(Stage 8):扫一个相对目录的一层条目(含文件夹 + 文件 + binary)。 // relDir 是 dataRoot 下的 POSIX 相对路径(如 'notes/2026'),空串代表根。 // 返回 { ok, dir, relDir, entries } 或 { ok:false, error, message? }。 @@ -277,6 +280,18 @@ contextBridge.exposeInMainWorld('api', { }, renameFile: (oldPath, newName) => ipcRenderer.invoke('file:rename', oldPath, newName), deleteFile: (filePath) => ipcRenderer.invoke('file:delete', filePath), + // 拖拽到文件夹 / 右键「剪切 → 粘贴」用:把 srcPath(文件或文件夹)移动到 destDir。 + moveFile: (srcPath, destDir) => ipcRenderer.invoke('file:move', srcPath, destDir), + // 拖拽(按住 Ctrl/Cmd)/ 右键「复制 → 粘贴」用:把 srcPath 复制到 destDir。 + copyFile: (srcPath, destDir) => ipcRenderer.invoke('file:copy', srcPath, destDir), + + // 文件夹管理(folder: 系列): + // folder:create — 在 opts.dir(或 dataRoot)下新建文件夹,自动避让重名 + // folder:rename — 在父目录下重命名(不跨父目录),校验 + 避让同 folder:create + // 删除复用 file:delete(主进程已支持 folder 分支:trashItem + rmdir 兜底), + // 通过 result.isFolder 让 renderer 区分后续行为(导航到父级) + createFolder: (name, opts) => ipcRenderer.invoke('folder:create', name, opts || {}), + renameFolder: (oldPath, newName) => ipcRenderer.invoke('folder:rename', oldPath, newName), openDataDir: () => ipcRenderer.invoke('app:open-data-dir'), // 打开任意路径(不修改设置)—— 设置对话框的"打开预览"按钮使用 openPath: (target) => ipcRenderer.invoke('app:open-path', target), @@ -329,15 +344,23 @@ contextBridge.exposeInMainWorld('api', { // - 兜底路径:pagehide 没跑 → beforeunload 触发,主进程侧 listener 被清 // 每个 on* 单独挂 listener + beforeunload(共享 cleanupOn helper,见顶部)。 - onMenuCommand: (callback) => { - // menu 事件是「无 payload 只有 channel 名」语义 - const events = ['menu:toggle-theme', 'menu:settings']; - const handlers = events.map(name => { - const handler = () => callback(name); - ipcRenderer.on(name, handler); - return { name, handler, cleanup: cleanupOn(name, handler) }; - }); - return () => handlers.forEach(({ cleanup }) => cleanup()); + onThemeToggle: (callback) => { + // P2-3:拆 onMenuCommand → 各事件单独 API,每个 API 只挂 1 个 listener。 + // 旧版 onMenuCommand 一次性挂 2 个 listener + 字符串分发,订阅 N 次就 2N + // 个 listener;拆分后每个 on* 严格 1 个 listener,与「一个 on* 一个 channel」 + // 1:1 对应,pagehide 清理路径可点对点核对。 + const handler = () => callback(); + ipcRenderer.on('menu:toggle-theme', handler); + const cleanup = cleanupOn('menu:toggle-theme', handler); + return cleanup; + }, + + onSettings: (callback) => { + // P2-3:同上,拆 onMenuCommand。 + const handler = () => callback(); + ipcRenderer.on('menu:settings', handler); + const cleanup = cleanupOn('menu:settings', handler); + return cleanup; }, onFilesChanged: (callback) => { diff --git a/scripts/check-syntax.js b/scripts/check-syntax.js index 4ffc7ad..2d7811e 100644 --- a/scripts/check-syntax.js +++ b/scripts/check-syntax.js @@ -14,8 +14,14 @@ const targets = [ 'main.js', 'preload.js', 'scripts/launch.js', - // src/ 下用 ESM import/export,node --check 会按 ESM 解析(package.json 无 "type" 字段 → CJS), - // 因此 src/**/*.js 必须以 .mjs 单独跑。 +]; + +// 额外需要语法检查的 CJS 目录(递归扫描其中的 .js)。main/ 与 shared/ 都是 CJS +// (module.exports),此前被漏检;ESLint 能抓到语法错误,但原生 node --check 覆盖不到。 +// 注意:walk 仅收集 .js(CJS);若某目录下出现 ESM 文件需另行处理。 +const cjsDirs = [ + 'shared', + 'main', ]; const esmTargets = [ @@ -41,6 +47,10 @@ function walk(dir, out) { /** @type {string[]} */ const allCjs = targets.map((p) => path.join(ROOT, p)).filter(fs.existsSync); +for (const d of cjsDirs) { + const abs = path.join(ROOT, d); + if (fs.existsSync(abs)) walk(abs, allCjs); +} /** @type {string[]} */ const allEsm = []; diff --git a/shared/render-sanitize.js b/shared/render-sanitize.js index b4ff253..85ab7a4 100644 --- a/shared/render-sanitize.js +++ b/shared/render-sanitize.js @@ -35,8 +35,22 @@ * 浏览器对 URL 会自动把 `\` 规范化成 `/`(Chrome/Firefox 都如此), * 校验放行 `\` 与 `/` 都不会引入新风险 —— 真实加载由 resolveRelativeImages 控。 */ +// 末尾 $ 锚整串;scheme / data:image / 纯锚点分支用 [^\s]* 兜底后续字符。 +// 顶层 `(?!)` 拒未在白名单的 scheme:相对路径分支 A (`[a-z+.-]+:[a-z\d.\-_]+(?![/\\])`) +// 会让 `about:blank` / `javascript:foo` / `vbscript:foo` / `data:text/html` 同形态通过 +// —— 这里在外层把已知危险 / 未授权 scheme 统一拒掉,保留分支 A 的 Mac 文件名修复。 +// 相对路径分支: +// A. `[a-z+.-]+:[a-z\d.\-_]+(?![/\\])` —— Mac/Linux 文件名带单冒号(如 +// `image:001.jpg`、`screenshot:1.png`),但 `:` 后不许是 `/` 或 `\` +// (否则会误吃 `data:text/html`、`javascript:foo/bar` 之类)。 +// B. `[a-z+.-]+(?:[\\/][^/\\#]*)*` —— 多段路径,支持 `img/a.png` / `../up.md` / +// `/abs/x.png` / Windows 反斜杠 / 路径段内含 `:`(如 `dir/sub:dir/file.png`) +// / 段内 #fragment(如 `sprite.svg#icon-home`、`page.md#chapter`)。 +// `data:image/...` 在顶层 `(?!)` 里只拒 `data:text/html`(白名单分支仍允许 +// `data:image/png` 等栅格格式);非 IMG URI 由 isDangerousUriNonImg 兜底 +// javascript:/vbscript:/file:/data(?!:image\/),IMG 由 installHooks 的额外检查兜底。 const ALLOWED_URI_REGEXP = - /^(?:(?:https?|mailto|tel|callto|file):|data:image\/(?:png|jpe?g|gif|webp|bmp|ico|tiff);base64,|#|(?:(?![/\\]{2})[/\\]|[^a-z/\\])|[a-z+.-]+(?:[\\/][^a-z]*|$))/i; + /^(?!about:|javascript:|vbscript:|data\s*:\s*text\s*\/\s*html)(?:(?:https?|mailto|tel|callto|file):[^\s]*|data:image\/(?:png|jpe?g|gif|webp|bmp|ico|tiff);base64,[^\s]*|#[^\s]*|(?:(?![/\\]{2})[/\\]|[^a-z/\\])[^\s]*|[a-z+.-]+:[a-z\d.\-_]+(?![/\\])|[a-z+.-]+(?:[\\/][^/\\#]*)*)$/i; // data:image 单 URL 字节上限(防 OOM)。10 MB 对正常 AI 输出 + 用户内嵌图 // 已经远超合理上限 —— Notes 单文件 5 MB 限制会先卡住写入,所以 10 MB 留余量。 @@ -95,6 +109,9 @@ const DANGEROUS_STYLE_PATTERNS = [ // url(javascript:...) / url("javascript:...") / url('javascript:...') // 容忍可选空白 + 单/双引号包裹 + 大小写 /url\s*\(\s*['"]?\s*(?:javascript|vbscript|data\s*:\s*text\s*\/\s*html|file)\s*:/i, + // 协议相对 URL(url(//evil.com/...)):浏览器按当前页协议补全为 http(s)://, + // 绕过上面的 scheme 白名单,造成外部资源请求(IP / Referer / UA 泄露)。 + /url\s*\(\s*['"]?\s*\/\//i, // legacy IE CSS expression() /expression\s*\(/i, // legacy IE HTC behavior @@ -187,6 +204,18 @@ function installHooks(DOMPurifyInstance) { data.keepAttr = false; return; } + // 显式拒未在白名单的 scheme:ALLOWED_URI_REGEXP 的分支 A + // (`[a-z+.-]+:[a-z\d.\-_]+(?![/\\])`) 允许 Mac/Linux 文件名带单 `:` + // (如 `image:001.jpg`),但形态上同 `javascript:foo` / `vbscript:foo` / + // `about:blank` —— 后者也会通过 ALLOWED。isDangerousUriNonImg 只跑非 IMG + // 路径且只覆盖危险 scheme,不含 `about:`,IMG 必须自己再拦一道。 + // data:text/html 在分支 A 里 `:text` 后续遇 `/` 被 `(?![/\\])` 否定、不通过; + // 这里保留是为了防御性深度扫描(与 audit Sec-D 风格一致),同时挡住形如 + // `data:text/html;