// 文件操作 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'); /** 单文件大小上限(5 MB),防止超大文件冻结 renderer。 */ const MAX_FILE_SIZE = 5 * 1024 * 1024; /** * 检查 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); 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; 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 }; } } /** * 扫描一个目录下的所有直接条目(文件夹 + 文件),按类型分类。 * Folder Browser(2026-08)的核心入口。 * * 行为约定: * - 不递归:只列 absDir 的一层直接子项;想看深层 → 点文件夹进入 * - 跳过符号链接文件夹(与 assertNotSymlink 同源策略; * 不跟随,避免 symlink 指向 dataDir 外部造成越权) * - 跳过符号链接文件(同样防御) * - 文件 entryType 由 classifyEntry(name) 决定: * 'editable' = Markdown 或常见纯文本(白名单内) * 'binary' = 其它扩展名(侧栏仍显示但灰掉、点击弹 toast) * - 文件夹 entryType 固定为 'folder' * - 排序:文件夹在前(按名称),文件在后(按名称); * 让用户先看到目录结构再看到内容,更符合 Explorer/Finder 的直觉 * * @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); } const entries = await Promise.all(safeDirents.map(async (d) => { 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 list = entries.filter(Boolean); // 排序: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 字段。 * * @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(/\/+$/, ''); if (a === r) return ''; const prefix = r + '/'; if (a.startsWith(prefix)) return a.slice(prefix.length); // 不在 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) { const tmpPath = `${dst}.tmp.${process.pid}.${Date.now()}`; let fh; try { fh = await fs.open(tmpPath, 'w'); 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); // 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') { try { const dirFh = await fs.open(path.dirname(dst), 'r'); await dirFh.sync(); await dirFh.close(); } catch (dirFsyncErr) { console.warn('[file-ops] 父目录 fsync 失败(不影响本次保存内容,但跨崩溃可能丢目录项):', dirFsyncErr.message); } } } catch (e) { // rename 失败:清理残余 tmp(不影响下次保存) try { await fs.unlink(tmpPath); } catch { /* 文件可能已被 rename 移走 */ } throw e; } } /** * 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 (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); } module.exports = { MAX_FILE_SIZE, EDITABLE_EXTS, assertNotSymlink, assertNoSymlinkAncestor, classifyEntry, isWithinDataDir, resolveFileName, resolveRenameName, scanFiles, scanDir, resolveDirRelative, toRelativeDir, atomicWriteFile, renameWithRetry, friendlyWriteError, };