Files
Notes/main/file-ops.js
2026-10-16 16:34:15 +08:00

1028 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 文件操作 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 文件名匹配规则:`<name>.<editableExt>.tmp.<pid>.<ts>.<rand>`。
* 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<T>) => Promise<T>}
*/
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.<pid>.<now>
* 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<void>}
*/
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.<pid>.<ts>.<rand>` 文件。
* 由 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<void>}
*/
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 生成的 `<name>.<ext>.tmp.<pid>.<ts>.<rand>`,
// 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,
};