This commit is contained in:
2026-09-12 14:15:26 +08:00
commit 9c06d3f4be
99 changed files with 41853 additions and 0 deletions

694
main/file-ops.js Normal file
View File

@@ -0,0 +1,694 @@
// 文件操作 helperStage 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 Browser2026-08
// - scanDir(absDir) 返回一层所有条目(文件夹 + 文件),含 entryType 分类
// - 文件分类由 classifyEntry(name) 完成:'folder' | 'editable' | 'binary'
// - EDITABLE_EXTS来自 shared/extension-lists.js是「可打开 + 编辑」
// 的扩展名白名单markdown (.md/.markdown) 与常见纯文本均在内
//
// 测试tests/unit/file-ops.test.jsjsdom 环境之外;纯 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 打开目录路径得到 EISDIRUI 弹「无效参数」
* 误导信息。改用 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';
}
/**
* 拒绝 symlinkNotes 用户的工作流是「编辑自己数据目录里的 .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.realpathsymlink 不解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/EBUSYUI 弹出
* 「没有重命名权限 / 文件被占用」误导性中文。预先拒绝给清晰的中文错误。
*
* 用户反馈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).mdbar.txt → bar (2).txtbaz无扩展名 → 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).mdbar.txt → bar (2).txtbaz无扩展名 → 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 Browser2026-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 fixC1/C2 file-IO
* 1. 写到 dst.tmp.<pid>.<now>
* 2. fsync tmp让内容确实落盘再 rename 才不会丢)
* 3. renameWithRetry 覆盖 dstWindows 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 关键:没有 fsyncrename 之后断电可能留下「磁盘上 inode 改了
// 但内容还在 page cache、从未刷盘」的零字节文件。Windows 上 fsync 等价
// FlushFileBuffersrename 之前必须强制落盘。
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.friendlyFsErrorrenderer 两处旧实现也走同一份)。本函数保留壳子
* 是为了不重写所有调用点的语义main 内部仍叫 friendlyWriteError 表达
* 「写盘错误翻译」renderer 走 window.api.friendlyFsError 表达通用 errno 翻译)。
*
* @param {NodeJS.ErrnoException|null|undefined} e
* @returns {string} 中文提示(永不为空 —— 兜底走「未知错误」)
*/
function friendlyWriteError(e) {
// 把 e.message 作为 fallback 透传给 sharedFriendlyFsError。
// - 已知 errnoshared 模块返回固定中文文案,与 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,
};