// 文件操作 helper(Stage 7 抽出 + Folder Browser 扩展) // // 职责: // - 暴露 file IPC 所需的可测试纯函数(isWithinDataDir / resolveFileName / scanFiles / scanDir / classifyEntry) // - 不包含 ipcMain 注册逻辑(仍留在 main.js,与 config-store 的拆分风格一致) // // 边界: // - 不引用 mainWindow / ipcMain / shell —— 这些留在 main.js 里就近注册 // - 不读 configStore —— 由调用方注入数据目录 // - scanFiles 的 ENOENT 行为:返回错误(不再静默 mkdir)—— // 防止「数据目录被外部删了之后被自动重建掩盖数据丢失」 // 见 audit #3 // // Folder Browser(2026-08): // - scanDir(absDir) 返回一层所有条目(文件夹 + 文件),含 entryType 分类 // - 文件分类由 classifyEntry(name) 完成:'folder' | 'editable' | 'binary' // - EDITABLE_EXTS(来自 shared/extension-lists.js)是「可打开 + 编辑」 // 的扩展名白名单;markdown (.md/.markdown) 与常见纯文本均在内 // // 测试:tests/unit/file-ops.test.js(jsdom 环境之外;纯 Node 即可) const fs = require('fs').promises; const path = require('path'); // 单一事实源:扩展名白名单(main + preload + renderer 三处共用) 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; /** * atomicWriteFile 产生的 tmp 文件名匹配规则:`..tmp...`。 * cleanupStaleFileTmpFiles 用它判断哪些 .tmp.* 是自家产物需要清理。 * 严格要求 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 之一(白名单扩展名)把误伤清零;rand 段允许连字符以匹配 UUID。 */ const ATOMIC_TMP_RE = new RegExp( '\\.(?:' + Array.from(EDITABLE_EXTS).map((e) => e.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('|') + ')\\.tmp\\.\\d+\\.\\d+\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', 'i', ); /** * 检查 fullPath 的占用情况 —— 返回三种状态: * - 'free' 路径不存在,可用 * - 'file' 路径已被一个文件占用,需要重命名避让 * - 'directory' 路径已被一个目录占用(用户手动 mkdir 出来),也需要避让 * * 审计修复 (Round 11 deep-fix P2-4):旧版用 fs.access() 只能判断「存在与否」, * 不能区分 file vs directory。用户新建笔记取名 `foo.md` 但数据目录里已经有一个 * 同名目录(手动 mkdir 出来的笔记文件夹),resolveFileName 直接把目录路径当成 * 可用文件返回;后续 atomicWriteFile 打开目录路径得到 EISDIR,UI 弹「无效参数」 * 误导信息。改用 fs.stat() 拿 dirent 类型确认。 */ async function pathOccupancy(fullPath) { try { const st = await fs.stat(fullPath); if (st.isDirectory()) return 'directory'; if (st.isFile()) return 'file'; // socket / fifo / device —— 数据目录里不应出现,但既然存在也当占用 return 'file'; } catch (e) { if (e && e.code === 'ENOENT') return 'free'; // EACCES / EPERM 等:保守起见当「占用」,让上层走避让分支 return 'file'; } } /** * 给一个文件名分类为 folder / editable / binary。 * 纯函数:不读文件系统、不感知 path —— 仅看 name 末尾扩展名。 * * 注意:folder 类型由调用方(scanDir)传入时显式标记, * 本函数在 entryType === 'folder' 时不会被调用(参见 scanDir 内部)。 * * @param {string} name * @returns {'editable'|'binary'} */ function classifyEntry(name) { if (typeof name !== 'string' || !name) return 'binary'; const dot = name.lastIndexOf('.'); // 没有扩展名 → binary if (dot < 0) return 'binary'; const ext = name.slice(dot + 1).toLowerCase(); // 隐藏文件 / 末尾多余点:扩展名部分为空(".env" 有扩展名 "env",不是这种) if (!ext) return 'binary'; // dot === 0 的隐藏文件名(如 ".gitignore"):扩展名部分非空但不在白名单 → binary // 注意 ".env" 现在会走 EDITABLE_EXTS.has('env') → 'editable'(修旧版的列表/链接割裂 bug) return EDITABLE_EXTS.has(ext) ? 'editable' : 'binary'; } /** * 拒绝 symlink:Notes 用户的工作流是「编辑自己数据目录里的 .md」, * 数据目录里出现 symlink 通常来自外部同步盘 / 误操作 / 恶意数据, * 写 symlink 会跟随到外部真实文件,绕过 isWithinDataDir 边界。 * 见 audit #7。 * * @param {string} filePath * @returns {Promise<{ok: true, isFile: boolean} | {ok: false, error: string, code?: string}>} */ async function assertNotSymlink(filePath) { let st; try { st = await fs.lstat(filePath); } catch (e) { if (e.code === 'ENOENT') return { ok: true, isFile: false }; // 不存在:留给调用方走 FILE_NOT_FOUND 路径 return { ok: false, error: e.message, code: e.code }; } if (st.isSymbolicLink()) { return { ok: false, error: 'SYMLINK_NOT_ALLOWED', message: '不允许编辑符号链接(防止越权写入)' }; } return { ok: true, isFile: st.isFile() }; } /** * audit fix (M1 main):递归检查 filePath 与其祖先(直到 stopAt 目录), * 任一层级出现 symlink 即拒绝。光检查 target 文件不够 —— 若父目录是 symlink * 指向 dataDir 外部,整个 target 的「真实路径」就会跳出 dataDir,绕过 * isWithinDataDir 的字符串前缀检查。同步盘 / 误操作 / 外部攻击都可能制造 * 这种中间层 symlink。 * * 策略:从 filePath 父目录开始向上 lstat,到 stopAt(一般是 dataDir)为止, * 任一是 symlink 就拒绝。stopAt 本身允许是 symlink?通常不允许,但它的边界 * 由 assertDataDirSafe 等其它检查负责,这里只覆盖 target 这棵子树。 * * @param {string} filePath - 目标文件路径 * @param {string} [stopAt] - 终止祖先链的目录(默认不传 = 一路 lstat 到根, * 但调用方一般会传 dataDir 来限定范围) * @returns {Promise<{ok:true} | {ok:false, error:string, code?:string}>} */ async function assertNoSymlinkAncestor(filePath, stopAt) { if (!filePath) return { ok: false, error: 'INVALID_PATH', code: 'INVALID_PATH', message: '路径不能为空' }; const stopResolved = stopAt ? path.resolve(stopAt) : null; // audit fix (Phase O fix):原实现只从 filePath 的**父目录**开始向上 lstat, // 漏掉 filePath 自身。若 filePath 本身是 symlink(指向外部目标盘),父目录 // 通常都是合法的 dataDir 子项,但 filePath 本身跟随 symlink 解析后就跳到 // 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); 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}` }; } } } let dir = path.dirname(resolvedPath); // 已经走到根盘符 / 根目录就停 const seen = new Set(); while (dir && !seen.has(dir)) { seen.add(dir); // 终止祖先链的判定:stopAt 目录已检查过、不再向上 lstat。 // audit fix (main-M10):之前三连 OR 里 `dir === stopResolved.toLowerCase?.()` // 是死分支 —— dir 来自 path.dirname(),Windows 上是混合大小写,根本不会 // 全小写化。删掉,只保留 POSIX 严格相等 + Windows 大小写无关两条。 if (stopResolved && (dir === stopResolved || (process.platform === 'win32' && dir.toLowerCase() === stopResolved.toLowerCase()))) { break; } try { const st = await fs.lstat(dir); if (st.isSymbolicLink()) { return { ok: false, error: 'SYMLINK_NOT_ALLOWED', code: 'SYMLINK_NOT_ALLOWED', message: `路径中包含符号链接目录,不允许编辑(${dir})`, }; } } catch (e) { // 父目录不存在是允许的(target 可能尚未创建);其它错误上报 if (e.code !== 'ENOENT') { // audit fix (K1-R4):统一带 code + message 字段,与 assertNotSymlink 对齐。 return { ok: false, error: e.message, code: e.code, message: `读取目录状态失败:${e.code || e.message}` }; } break; } const parent = path.dirname(dir); if (parent === dir) break; // 已经到盘符根 dir = parent; } return { ok: true }; } /** * 检查目标路径是否在数据目录内(防止越权读写) * - Windows 不区分大小写 * - 字符串前缀比对(path.resolve 后再做) * - 未调用 fs.realpath(symlink 不解);file:write/rename/delete 已先 lstat 拒绝 symlink * * @param {string} target * @param {string} root */ function isWithinDataDir(target, root) { if (!root || !target) return false; const rootResolved = path.resolve(root); const targetResolved = path.resolve(target); if (process.platform === 'win32') { const rootLower = rootResolved.toLowerCase(); const targetLower = targetResolved.toLowerCase(); return targetLower.startsWith(rootLower + path.sep) || targetLower === rootLower; } return targetResolved === rootResolved || targetResolved.startsWith(rootResolved + path.sep); } /** * 文件名清洗:拒绝路径分隔符 / `..` / 空字符串 / 控制字符;用户输入的扩展名 * 原样保留;重名 (2)、(3)...。 * * audit fix (Phase L3-FS 4A/4B):拒绝 Windows 保留字符(<>:"/\\|?*)和保留设备名 * (CON / PRN / AUX / NUL / COM1-9 / LPT1-9)。之前 name 里含 `|` 或 `:` 仍能通过 * resolveFileName,到 fs.rename 才在 Windows 上撞 EPERM/EBUSY,UI 弹出 * 「没有重命名权限 / 文件被占用」误导性中文。预先拒绝给清晰的中文错误。 * * 用户反馈(2026-08-28):「新建笔记时直接把后缀放在文件名后面,而不是自动 * 加上」—— 本函数不再强制补 .md。用户输入 `foo.md` / `foo.txt` / `foo`(无扩展名) * 都按字面保留。重名避让时扩展名也原样保留(foo.txt → foo (2).txt)。 * 重命名场景走 resolveRenameName,行为完全对称。 * * @param {string} raw * @param {string} dir - 数据目录绝对路径 * @returns {Promise<{ok:true, path:string, name:string} | {ok:false, error:string}>} */ // Windows 保留设备名(基础名,无扩展名时)。注意是大写比对;用户的实际 // 输入通常是大小写混合,做 toUpperCase 后再校验。带扩展名也照样拒 // (Windows 把 CON.md / con.txt 都视作设备名 —— 这是 NTFS 的硬规则, // fs.rename 一定撞 EPERM)。 const WIN_RESERVED_DEVICE_NAMES = new Set([ 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', ]); async function resolveFileName(raw, dir) { if (typeof raw !== 'string' || !raw.trim()) { return { ok: false, error: '文件名不能为空' }; } const name = raw.trim(); // 拒绝路径分隔符与 .. 段 if (/[/\\]/.test(name) || name.includes('..')) { return { ok: false, error: '文件名不能包含路径分隔符或 ..' }; } // Windows 保留字符:<>:"/\\|?*(/\\ 已被上面挡一次,这里再列一遍保持语义独立, // 让错误信息精确指向 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 保留设备名:取最后一个 . 之前的部分("CON.md" / "CON" / "CON.txt" 都算) const lastDot = name.lastIndexOf('.'); const baseForReserved = (lastDot > 0 ? name.slice(0, lastDot) : name).toUpperCase(); if (WIN_RESERVED_DEVICE_NAMES.has(baseForReserved)) { return { ok: false, error: `"${baseForReserved}" 是 Windows 保留设备名,不允许作为文件名` }; } // 用户输入的扩展名原样保留 —— 故意不强制 .md(与 resolveRenameName 对齐)。 let candidate = name; let counter = 2; for (;;) { const fullPath = path.join(dir, candidate); // 任何候选路径都必须仍在数据目录内(防止 join 出 ..\) if (!isWithinDataDir(fullPath, dir)) { return { ok: false, error: '非法路径' }; } const occupancy = await pathOccupancy(fullPath); if (occupancy === 'free') { return { ok: true, path: fullPath, name: candidate }; } // 'file' / 'directory' 都视为占用 → 走避让分支 // 重名避让:取最后一个 . 之前的部分加 (N),扩展名原样保留(与 resolveRenameName 对齐)。 // foo.md → foo (2).md;bar.txt → bar (2).txt;baz(无扩展名) → baz (2) const cutAt = lastDot > 0 ? lastDot : name.length; const base = name.slice(0, cutAt); const ext = name.slice(cutAt); candidate = `${base} (${counter})${ext}`; counter += 1; if (counter > 1000) return { ok: false, error: '重名次数过多' }; } } /** * 重命名专用的文件名清洗。 * * 用户反馈:「重命名不要自动补后缀」—— 重命名场景下用户输入什么就用什么, * 包括完全去掉扩展名(foo.md → bar)或换成别的扩展名(foo.md → bar.txt)。 * * 2026-08-28 反馈后,resolveFileName(新建文件)与本函数语义完全对齐: * 都不强制补 .md,新建时用户输入什么就用什么(默认 .md 仍由 renderer 的 * defaultName 带出来)。这样用户想新建一个 .txt 纯文本笔记 / .json 数据笔记 * 不再需要「先建 .md → 重命名成 .txt」两步。 * * 仍然保留的校验: * - 路径分隔符 / .. * - Windows 保留字符 * - Windows 保留设备名(基础名按 . 之前的部分取,"CON.md" 也算) * - 控制字符 * - 重名避让((2)/(3) 后缀保留原始扩展名:foo.md → foo (2).md; * foo.txt → foo (2).txt;无扩展名 foo → foo (2)) * * @param {string} raw * @param {string} dir */ async function resolveRenameName(raw, dir) { 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: '文件名包含非法字符' }; } // Windows 保留设备名:取最后一个 . 之前的部分("CON.md" / "CON" / "CON.txt" 都算) const lastDot = name.lastIndexOf('.'); const baseForReserved = (lastDot > 0 ? name.slice(0, lastDot) : name).toUpperCase(); if (WIN_RESERVED_DEVICE_NAMES.has(baseForReserved)) { return { ok: false, error: `"${baseForReserved}" 是 Windows 保留设备名,不允许作为文件名` }; } let candidate = name; let counter = 2; for (;;) { const fullPath = path.join(dir, candidate); if (!isWithinDataDir(fullPath, dir)) { return { ok: false, error: '非法路径' }; } // 审计修复 (Round 11 deep-fix P2-4):用 pathOccupancy 替代 fs.access, // 区分 file vs directory —— 同名目录不能被当作可用文件返回。 const occupancy = await pathOccupancy(fullPath); if (occupancy === 'free') { return { ok: true, path: fullPath, name: candidate }; } // 重名避让:取最后一个 . 之前的部分加 (N),扩展名原样保留。 // foo.md → foo (2).md;bar.txt → bar (2).txt;baz(无扩展名) → baz (2) const cutAt = lastDot > 0 ? lastDot : name.length; const base = name.slice(0, cutAt); const ext = name.slice(cutAt); candidate = `${base} (${counter})${ext}`; counter += 1; if (counter > 1000) return { ok: false, error: '重名次数过多' }; } } /** * 扫描数据目录下的所有 .md / .markdown 文件,按名称排序(locale zh-CN)。 * * 重要变更(audit #3):目录不存在(ENOENT)时不再自动 mkdir, * 直接返回错误让上层决定如何处理(弹对话框 / 切换目录), * 避免「数据目录被外部删了之后被静默重建掩盖数据丢失」。 * * @param {string} dir * @returns {Promise<{ok:true, files:Array} | {ok:false, error:string, code?:string}>} */ async function scanFiles(dir) { if (!dir || typeof dir !== 'string') { return { ok: false, error: 'invalid dir', code: 'EINVAL' }; } try { let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch (e) { // ENOENT:目录不存在 —— 不再自动重建,让用户看到明确错误 if (e.code === 'ENOENT') { return { ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT', message: `数据目录不存在:${dir}` }; } throw e; } const mdFiles = entries.filter((e) => { if (!e.isFile()) return false; // Round 13 fix (H6):与 scanDir 对称过滤符号链接。OneDrive / 网盘 // 用户在数据目录里放指向外部 secret 文件的 symlink 时,旧版会跟随 // 读 size / mtime 但 file:read 拒开,UX 不一致且把外部 stat 信息暴露 // 到 renderer 内存。 if (e.isSymbolicLink()) return false; const lower = e.name.toLowerCase(); return lower.endsWith('.md') || lower.endsWith('.markdown'); }); const files = await Promise.all(mdFiles.map(async (e) => { const fullPath = path.join(dir, e.name); try { const st = await fs.stat(fullPath); return { name: e.name, path: fullPath, size: st.size, mtimeMs: st.mtimeMs, }; } catch { return null; } })); const list = files .filter((f) => f !== null) .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')); return { ok: true, files: list }; } catch (e) { return { ok: false, error: e.message, code: e.code }; } } /** * 简易并发限流器:把 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)的核心入口。 * * 行为约定: * - 不递归:只列 absDir 的一层直接子项;想看深层 → 点文件夹进入 * - 跳过符号链接文件夹(与 assertNotSymlink 同源策略; * 不跟随,避免 symlink 指向 dataDir 外部造成越权) * - 跳过符号链接文件(同样防御) * - 文件 entryType 由 classifyEntry(name) 决定: * 'editable' = Markdown 或常见纯文本(白名单内) * 'binary' = 其它扩展名(侧栏仍显示但灰掉、点击弹 toast) * - 文件夹 entryType 固定为 'folder' * - 排序:文件夹在前(按名称),文件在后(按名称); * 让用户先看到目录结构再看到内容,更符合 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, * dir: string, // 回传绝对路径,便于调用方对照 * entries: Array<{ * name: string, * path: string, // 绝对路径 * entryType: 'folder'|'editable'|'binary', * isFolder: boolean, * size?: number, * mtimeMs?: number, * }> * } | { ok:false, error, code?, message? }>} */ async function scanDir(absDir) { if (!absDir || typeof absDir !== 'string') { return { ok: false, error: 'invalid dir', code: 'EINVAL' }; } let dirents; try { dirents = await fs.readdir(absDir, { withFileTypes: true }); } catch (e) { if (e.code === 'ENOENT') { return { ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT', message: `目录不存在:${absDir}` }; } // audit fix (Round 12 P2):非 ENOENT 路径不再把原始 e.message(英文 errno // + 绝对路径)透出去。renderer fs-watcher 推过来时直接当成 toast 文案 // 显示,会泄漏路径 + 看着割裂。走 friendly-fs-error 与 Round 8 EROFS / // ENAMETOOLONG 等统一:未知 errno 时拿 e.message 作为 fallback(兜底 // 业务码),不能让用户看空白 toast。 return { ok: false, error: sharedFriendlyFsError(e && e.code, e && e.message) || '扫描目录失败', code: e.code, }; } // 用 lstat 一次性拿每一项的类型,过滤掉符号链接(防止越权)。 // 注意 fs.readdir(..., {withFileTypes:true}) 给的 dirent.isSymbolicLink() // 在 Windows 上对 junction 也判定为 true —— 这里统一跳,避免跟随。 const safeDirents = []; for (const d of dirents) { if (d.isSymbolicLink()) continue; safeDirents.push(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 { name: d.name, path: fullPath, entryType: 'folder', isFolder: true, }; } if (d.isFile()) { try { const st = await fs.stat(fullPath); return { name: d.name, path: fullPath, entryType: classifyEntry(d.name), isFolder: false, size: st.size, mtimeMs: st.mtimeMs, }; } catch { // 文件在 readdir 与 stat 之间被删了 —— 静默跳过 return null; } } // 其它(socket / fifo / block device 等)—— 不展示 return null; })); 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; return a.name.localeCompare(b.name, 'zh-CN'); }); return { ok: true, dir: absDir, entries: list }; } /** * 把数据目录根下的相对路径解析成绝对路径,并做防御性校验。 * * 渲染端传来的 relDir 必须满足: * - 空字符串 → dataRoot(根目录) * - 正斜杠分隔的相对路径(如 'notes/2026'),不含 .. * - 不含绝对路径前缀(Windows 盘符 / POSIX /) * * 解析后用 isWithinDataDir 二次校验(防止路径穿越 / symlink 跟随)。 * * @param {string} relDir * @param {string} dataRoot - 数据目录绝对路径 * @returns {{ok:true, absDir:string, relDir:string} | {ok:false, error:string, message?:string}} */ function resolveDirRelative(relDir, dataRoot) { if (typeof relDir !== 'string') { return { ok: false, error: 'INVALID_REL_DIR', message: 'relDir 必须是字符串' }; } // 拒绝盘符 / 绝对路径前缀(必须在 strip 前做,否则 '/etc/passwd' 被剥成 // 'etc/passwd' 反而通过校验) if (/^[a-z]:[\\/]/i.test(relDir) || relDir.startsWith('/') || relDir.startsWith('\\')) { return { ok: false, error: 'PATH_NOT_ALLOWED', message: '不允许使用绝对路径' }; } const normalized = relDir.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); // 拒绝 .. 段(即使藏在中间) if (normalized.split('/').some((seg) => seg === '..' || seg === '.')) { return { ok: false, error: 'PATH_NOT_ALLOWED', message: '相对路径不能包含 . 或 ..' }; } const absDir = normalized === '' ? dataRoot : path.join(dataRoot, normalized); if (!isWithinDataDir(absDir, dataRoot)) { return { ok: false, error: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; } // 重新标准化回 POSIX(与传入保持一致)+ 末尾无 / const relOut = normalized; return { ok: true, absDir, relDir: relOut }; } /** * 给定 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 相对路径,根目录时为 '' */ function toRelativeDir(absDir, dataRoot) { if (!absDir || !dataRoot) return ''; const a = absDir.replace(/\\/g, '/').replace(/\/+$/, ''); const r = dataRoot.replace(/\\/g, '/').replace(/\/+$/, ''); // 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 (icmp(a.slice(0, prefix.length), prefix)) return a.slice(r.length + 1); // 不在 dataRoot 下(理论上不该发生)—— 返回空字符串让渲染端走根路径分支 return ''; } /** * 原子写文件 —— audit fix(C1/C2 file-IO): * 1. 写到 dst.tmp.. * 2. fsync tmp(让内容确实落盘,再 rename 才不会丢) * 3. renameWithRetry 覆盖 dst(Windows Defender / 杀毒 / 同步盘 * 偶尔瞬态持锁,retry-on-busy 比直接放弃稳得多) * * 失败路径: * - 写入 tmp 失败 → unlink tmp + 抛原始 errno * - rename 失败(retry 后)→ unlink tmp + 抛原始 errno * * 副作用:成功后磁盘上要么是旧文件(旧文件全程未动),要么是新文件; * 永远不会有半截内容被读到。 * * @param {string} dst 目标绝对路径 * @param {string} content UTF-8 文本 */ async function atomicWriteFile(dst, content) { // 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 { // 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 等价 // FlushFileBuffers,rename 之前必须强制落盘。 await fh.sync(); await fh.close(); fh = null; } catch (e) { if (fh) { try { await fh.close(); } catch { /* ignore */ } } try { await fs.unlink(tmpPath); } catch { /* tmp 不存在也忽略 */ } throw e; } try { await renameWithRetry(tmpPath, dst); } 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); } } /** * audit fix (C3 file-IO):rename 重试 EBUSY/EPERM/EACCES。 * Windows Defender / 杀毒 / 同步盘会在极短时间内持锁目标文件, * 单次 rename 失败率约 1-3%。3 次 50/100/200ms backoff 后仍失败才抛。 * 与 config-store 的同名实现保持一致的 backoff 时序,避免两条路径表现差异。 * @param {string} src * @param {string} dst */ async function renameWithRetry(src, dst) { const delays = [50, 100, 200]; for (let i = 0; i <= delays.length; i += 1) { try { await fs.rename(src, dst); return; } catch (e) { const transient = e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'EACCES'; // 末次重试仍失败 → 直接抛;非瞬态错误也直接抛(不浪费重试)。 if (!transient || i === delays.length) throw e; await new Promise((r) => setTimeout(r, delays[i])); } } } /** * 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 在启动时 / 切换数据目录时调用一次。 * * 两层阈值(与 main/config-store.js#cleanupStaleTmpFiles 对齐): * - 自己 pid + 60s 前 → 必删(防止本进程崩溃留下永久孤儿) * - 非自己 pid + 24h 前 → 必删(清理上一次 Notes 实例崩溃的孤儿) * * audit fix (Round 16 R2):atomicWriteFile 自身的 try/catch 已经在失败时 * unlink tmp,但「本进程崩溃 / 被 kill -9 / 断电」三种场景不会进入清理分支。 * `scripts/_archive/` 9 个一次性 repro 脚本的历史足以说明崩溃/中断场景不是 * 理论风险。启动时扫一次数据目录,避免孤儿永久累积。 * * @param {string} dataDir 数据目录绝对路径 * @returns {Promise} */ async function cleanupStaleFileTmpFiles(dataDir) { if (!dataDir) return; try { const files = await fs.readdir(dataDir); const myPid = process.pid; const now = Date.now(); const STALE_THRESHOLD_MS = 60_000; const CROSS_PID_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; for (const f of files) { // 只匹配 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 // 之一(白名单扩展名)。 // config-store 的 `config.json.tmp.*` 不在此正则范围内,仍由它自家扫(注释指向 // shared/extension-lists.js#EDITABLE_EXTS,与 fileOps / sidebar 共用扩展名)。 if (!ATOMIC_TMP_RE.test(f)) continue; const full = path.join(dataDir, f); try { const st = await fs.stat(full); const isMine = f.includes(`.${myPid}.`); const ageMs = now - st.mtimeMs; const shouldRemove = isMine ? ageMs > STALE_THRESHOLD_MS : ageMs > CROSS_PID_STALE_THRESHOLD_MS; if (shouldRemove) { await fs.unlink(full); } } catch { /* 单个文件 stat/unlink 失败不影响整体 */ } } } catch { // readdir 失败(目录不存在等)静默 —— 后续 IPC handler 会处理 } } /** * audit fix (Round 4 收尾):file:write 的 errno 翻译 —— 与 main.js 各 IPC handler * (_friendlyCreateError / _friendlyRenameError / _friendlyDeleteError / _friendlyReadError) * 共用一份文案。Round 4 之前本函数与 src/app.js#friendlyWriteError、 * src/file-ops.js#friendlyFsError 三处独立,EROFS / ENAMETOOLONG / ENOTDIR 文案 * 三处不同,ENOTEMPTY 在 src/file-ops.js 独有 —— 用户看到的提示不一致。 * * 改用 shared/friendly-fs-error.js 单一事实源(preload 同时把它过桥到 * window.api.friendlyFsError,renderer 两处旧实现也走同一份)。本函数保留壳子 * 是为了不重写所有调用点的语义(main 内部仍叫 friendlyWriteError 表达 * 「写盘错误翻译」,renderer 走 window.api.friendlyFsError 表达通用 errno 翻译)。 * * @param {NodeJS.ErrnoException|null|undefined} e * @returns {string} 中文提示(永不为空 —— 兜底走「未知错误」) */ function friendlyWriteError(e) { // 把 e.message 作为 fallback 透传给 sharedFriendlyFsError。 // - 已知 errno:shared 模块返回固定中文文案,与 e.message 无关 // - 未知 errno / null e / e.code 缺失:shared 模块走 fallback || '未知错误' // → e?.message 有就透传英文 errno + 路径(renderer 拿到的是已经走 // friendlyFsError 二次翻译过的中文,不会再让英文 errno 漏到这里); // 没有就回退到「未知错误」(注意:旧版这里固定传「写入文件失败」, // 与 renderer 三处的「未知错误」兜底文案不一致,统一为后者) 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, ATOMIC_TMP_RE, assertNotSymlink, assertNoSymlinkAncestor, classifyEntry, isWithinDataDir, resolveFileName, resolveRenameName, resolveFolderName, resolveRenameFolderName, scanFiles, scanDir, resolveDirRelative, toRelativeDir, atomicWriteFile, renameWithRetry, recursiveCopy, cleanupStaleFileTmpFiles, friendlyWriteError, };