1033 lines
40 KiB
JavaScript
1033 lines
40 KiB
JavaScript
// main/file-ops.js 单测(audit #6)
|
||
//
|
||
// 覆盖:
|
||
// - isWithinDataDir: Windows 大小写不敏感 / POSIX 区分 / 边界 .. / 越权 ../etc
|
||
// - assertNotSymlink: 真文件通过 / symlink 拒绝 / ENOENT 不挡
|
||
// - resolveFileName: 空名 / 路径分隔符 / .. / 控制字符 / 重名加 (2)(3)
|
||
// - scanFiles: ENOENT 不再自动 mkdir / 返回 DATA_DIR_NOT_FOUND / .md/.markdown 收录
|
||
// - MAX_FILE_SIZE 数值正确
|
||
//
|
||
// 测试不依赖 electron,纯 Node 即可;用 mkdtempSync 建真实临时目录。
|
||
|
||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
import {
|
||
mkdtempSync, rmSync, writeFileSync, mkdirSync, symlinkSync,
|
||
} from 'fs';
|
||
import { tmpdir } from 'os';
|
||
import { join, sep } from 'path';
|
||
|
||
const fileOps = require('../../main/file-ops.js');
|
||
const {
|
||
MAX_FILE_SIZE,
|
||
assertNotSymlink,
|
||
assertNoSymlinkAncestor,
|
||
isWithinDataDir,
|
||
resolveFileName,
|
||
resolveRenameName,
|
||
scanFiles,
|
||
scanDir,
|
||
classifyEntry,
|
||
resolveDirRelative,
|
||
toRelativeDir,
|
||
EDITABLE_EXTS,
|
||
} = fileOps;
|
||
|
||
let dir;
|
||
|
||
beforeEach(() => {
|
||
dir = mkdtempSync(join(tmpdir(), 'notes-fileops-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
describe('MAX_FILE_SIZE', () => {
|
||
it('是 5 MB', () => {
|
||
expect(MAX_FILE_SIZE).toBe(5 * 1024 * 1024);
|
||
});
|
||
});
|
||
|
||
describe('isWithinDataDir', () => {
|
||
it('正常文件返回 true', () => {
|
||
expect(isWithinDataDir(join(dir, 'a.md'), dir)).toBe(true);
|
||
});
|
||
|
||
it('根目录本身算内部', () => {
|
||
expect(isWithinDataDir(dir, dir)).toBe(true);
|
||
});
|
||
|
||
it('越权路径(../etc/passwd)返回 false', () => {
|
||
expect(isWithinDataDir(join(dir, '..', 'etc', 'passwd'), dir)).toBe(false);
|
||
});
|
||
|
||
it('空参数返回 false(防止误判 true)', () => {
|
||
expect(isWithinDataDir('', dir)).toBe(false);
|
||
expect(isWithinDataDir(join(dir, 'a.md'), '')).toBe(false);
|
||
expect(isWithinDataDir(null, dir)).toBe(false);
|
||
expect(isWithinDataDir(undefined, dir)).toBe(false);
|
||
});
|
||
|
||
it('Windows 大小写不敏感', () => {
|
||
if (process.platform !== 'win32') {
|
||
// 在 POSIX 上人为构造一个 Windows 行为测试跳过
|
||
// (isWithinDataDir 始终走 POSIX 分支,不受测试环境影响)
|
||
const mixed = dir.toUpperCase();
|
||
// 用全路径解析过的 root 来做大小写差异
|
||
const resolved = dir;
|
||
// POSIX 上大小写敏感,A.md 与 a.md 是不同文件 → 视为越权
|
||
expect(isWithinDataDir(`${resolved}${sep}Notes${sep}A.md`, `${resolved}${sep}notes`)).toBe(false);
|
||
return;
|
||
}
|
||
expect(isWithinDataDir(join(dir.toUpperCase(), 'A.MD'), dir.toLowerCase())).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('assertNotSymlink', () => {
|
||
it('普通文件:返回 ok:true, isFile:true', async () => {
|
||
const p = join(dir, 'normal.md');
|
||
writeFileSync(p, 'hello');
|
||
const r = await assertNotSymlink(p);
|
||
expect(r).toEqual({ ok: true, isFile: true });
|
||
});
|
||
|
||
it('不存在:返回 ok:true, isFile:false(留给调用方走 FILE_NOT_FOUND)', async () => {
|
||
const r = await assertNotSymlink(join(dir, 'missing.md'));
|
||
expect(r).toEqual({ ok: true, isFile: false });
|
||
});
|
||
|
||
it('符号链接:返回 SYMLINK_NOT_ALLOWED', async () => {
|
||
const real = join(dir, 'real.md');
|
||
writeFileSync(real, 'real content');
|
||
const link = join(dir, 'link.md');
|
||
try {
|
||
symlinkSync(real, link);
|
||
} catch {
|
||
// Windows 上非管理员可能不允许创建符号链接 → 跳过
|
||
return;
|
||
}
|
||
const r = await assertNotSymlink(link);
|
||
expect(r.ok).toBe(false);
|
||
expect(r.error).toBe('SYMLINK_NOT_ALLOWED');
|
||
});
|
||
});
|
||
|
||
describe('assertNoSymlinkAncestor (Phase O-H1/H2/H3 防御)', () => {
|
||
// dataRoot 下的子目录是 symlink 指向外部目标盘 —— file:scan-dir / file:watch-dir
|
||
// 必须拒绝(之前只 lstat(absDir) 自身,被祖先链是 symlink 的场景绕过)。
|
||
it('祖先链中是 symlink 的目录 → 拒绝(filePath 在 symlink 子目录下)', async () => {
|
||
// 真实外部目标目录(在 dataRoot 之外)
|
||
const externalTarget = mkdtempSync(join(tmpdir(), 'notes-ext-'));
|
||
writeFileSync(join(externalTarget, 'secret.md'), 'secret');
|
||
// dataRoot 内创建指向外部目标的 symlink
|
||
const linkDir = join(dir, 'links');
|
||
try {
|
||
symlinkSync(externalTarget, linkDir);
|
||
} catch {
|
||
// Windows 上非管理员可能不允许创建符号链接 → 跳过
|
||
return;
|
||
}
|
||
// file:scan-dir 会拿 absDir = dataRoot/links/secret.md 调 assertNoSymlinkAncestor
|
||
// (scan-dir 入参是 dir,但 list 时会拼接文件名;这里我们用 filePath 在 symlink
|
||
// 子树下,让 assertNoSymlinkAncestor 从 filePath 父目录开始向上 lstat)
|
||
const r = await assertNoSymlinkAncestor(join(linkDir, 'secret.md'), dir);
|
||
expect(r.ok).toBe(false);
|
||
expect(r.error).toBe('SYMLINK_NOT_ALLOWED');
|
||
// 目标位置 = 中间 symlink 目录
|
||
expect(r.message).toContain('links');
|
||
rmSync(externalTarget, { recursive: true, force: true });
|
||
});
|
||
|
||
it('filePath 自身是 symlink → 拒绝(Phase O-fix:自身也参与检查)', async () => {
|
||
// audit fix (Phase O-fix):assertNoSymlinkAncestor 现在也检查 filePath 自身。
|
||
// 之前只走父目录向上 lstat,导致 dataRoot/links(自身是 symlink 指向外部目标)
|
||
// 整条链路:parent = dataRoot(合法)→ break → 漏检。
|
||
// Phase O 把 file:scan-dir / file:watch-dir / file:create 的 lstat(targetDir)
|
||
// 替换成 assertNoSymlinkAncestor(targetDir, dataRoot),必须让「target 自身是
|
||
// symlink」也被挡下,否则原 lstat 的越权检查失效。
|
||
const externalTarget = mkdtempSync(join(tmpdir(), 'notes-ext-'));
|
||
const linkDir = join(dir, 'links');
|
||
try {
|
||
symlinkSync(externalTarget, linkDir);
|
||
} catch {
|
||
return;
|
||
}
|
||
const r = await assertNoSymlinkAncestor(linkDir, dir);
|
||
expect(r.ok).toBe(false);
|
||
expect(r.error).toBe('SYMLINK_NOT_ALLOWED');
|
||
rmSync(externalTarget, { recursive: true, force: true });
|
||
});
|
||
|
||
it('全是普通目录 → 通过', async () => {
|
||
const sub = join(dir, 'a', 'b', 'c');
|
||
mkdirSync(sub, { recursive: true });
|
||
const r = await assertNoSymlinkAncestor(join(sub, 'foo.md'), dir);
|
||
expect(r.ok).toBe(true);
|
||
});
|
||
|
||
it('父目录不存在(target 尚未创建)→ 允许', async () => {
|
||
const r = await assertNoSymlinkAncestor(join(dir, 'nonexistent', 'foo.md'), dir);
|
||
expect(r.ok).toBe(true);
|
||
});
|
||
|
||
it('空路径 → 拒绝', async () => {
|
||
const r = await assertNoSymlinkAncestor('', dir);
|
||
expect(r.ok).toBe(false);
|
||
expect(r.error).toBe('INVALID_PATH');
|
||
});
|
||
});
|
||
|
||
describe('resolveFileName', () => {
|
||
// 2026-08-28 用户反馈:resolveFileName 不再强制补 .md,与 resolveRenameName 对齐。
|
||
// 用户输入什么就用什么:
|
||
// - "hello" → "hello"(无扩展名)
|
||
// - "hello.md" → "hello.md"
|
||
// - "hello.txt" → "hello.txt"(用户可建纯文本笔记)
|
||
// - "note.markdown"→ "note.markdown"
|
||
// - "NOTE.MD" → "NOTE.MD"(大小写保留)
|
||
// 这样用户新建 .txt 纯文本笔记 / .json 数据笔记不再需要「先建 .md → 重命名」两步。
|
||
it('无扩展名 → 原样保留(不再自动补 .md)', async () => {
|
||
const r = await resolveFileName('hello', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('hello');
|
||
expect(r.path).toBe(join(dir, 'hello'));
|
||
expect(isWithinDataDir(r.path, dir)).toBe(true);
|
||
});
|
||
|
||
it('.md 后缀原样保留', async () => {
|
||
const r = await resolveFileName('hello.md', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('hello.md');
|
||
expect(r.path).toBe(join(dir, 'hello.md'));
|
||
});
|
||
|
||
it('.txt 等其他扩展名原样保留(用户可建纯文本笔记)', async () => {
|
||
const r = await resolveFileName('hello.txt', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('hello.txt');
|
||
expect(r.path).toBe(join(dir, 'hello.txt'));
|
||
});
|
||
|
||
it('.markdown 扩展名原样保留', async () => {
|
||
const r = await resolveFileName('note.markdown', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('note.markdown');
|
||
});
|
||
|
||
it('大小写保留(不强制小写化)', async () => {
|
||
const r = await resolveFileName('NOTE.MD', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('NOTE.MD');
|
||
});
|
||
|
||
it('空字符串 / 非字符串 → 拒绝', async () => {
|
||
expect((await resolveFileName('', dir)).ok).toBe(false);
|
||
expect((await resolveFileName(' ', dir)).ok).toBe(false);
|
||
expect((await resolveFileName(null, dir)).ok).toBe(false);
|
||
expect((await resolveFileName(123, dir)).ok).toBe(false);
|
||
});
|
||
|
||
it('包含路径分隔符 → 拒绝', async () => {
|
||
expect((await resolveFileName('a/b.md', dir)).ok).toBe(false);
|
||
expect((await resolveFileName('a\\b.md', dir)).ok).toBe(false);
|
||
});
|
||
|
||
it('包含 .. → 拒绝(含 substring .. 也算,防止视觉混淆)', async () => {
|
||
expect((await resolveFileName('..evil.md', dir)).ok).toBe(false);
|
||
// `a..b.md` 也含 `..` 子串——生产代码故意一并拒绝(更严格,无歧义)
|
||
expect((await resolveFileName('a..b.md', dir)).ok).toBe(false);
|
||
});
|
||
|
||
it('包含 Windows 保留字符 → 拒绝', async () => {
|
||
expect((await resolveFileName('bad|name', dir)).ok).toBe(false);
|
||
expect((await resolveFileName('bad:name', dir)).ok).toBe(false);
|
||
});
|
||
|
||
it('Windows 保留设备名(基础名取最后一个 . 之前)→ 拒绝', async () => {
|
||
// "CON" / "CON.md" / "CON.txt" 都按 NTFS 规则视为设备名
|
||
expect((await resolveFileName('CON', dir)).ok).toBe(false);
|
||
expect((await resolveFileName('CON.md', dir)).ok).toBe(false);
|
||
expect((await resolveFileName('CON.txt', dir)).ok).toBe(false);
|
||
// "foo.CON" 不算 —— 基础名是 foo
|
||
const ok = await resolveFileName('foo.CON', dir);
|
||
expect(ok.ok).toBe(true);
|
||
});
|
||
|
||
it('基础名取最后一个 . 之前(foo.CON.bar 不算保留设备名)', async () => {
|
||
// 基础名取最后一个 . 之前的部分。"foo.CON.bar" 的基础名是 "foo.CON",
|
||
// 不是保留设备名,应该通过校验。
|
||
const r = await resolveFileName('foo.CON.bar', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('foo.CON.bar');
|
||
});
|
||
|
||
it('包含控制字符 → 拒绝', async () => {
|
||
expect((await resolveFileName('bad |