Files
Literature-Management/tests/unit/main/utils/safe-write-trash-symlink.test.ts
2026-10-21 14:42:59 +08:00

123 lines
5.6 KiB
TypeScript

// tests/unit/main/utils/safe-write-trash-symlink.test.ts
//
// 行为锁(audit-r243,2026-10-21):**删文件夹时不得把 symlink 的目标也删掉**。
//
// ── 缺陷 ────────────────────────────────────────────────────────────
// safe-write.ts 的 trashDirContents(递归删目录内容的内部 helper)在遇到
// 子项 symlink 时走的是 `shell.trashItem(child)`。而 shell.trashItem 在
// Windows 上 **follow symlink** —— 同一文件里 safeTrash 的头注(r151)明明白白
// 写着这件事,并因此对 symlink 直接抛 UNSAFE_WRITE。
// trashDirContents 却反着来:旧注释说「lstat 已经挡过 planted 越界」——
// **假的**:上面 lstat 的是**父目录**,子项 symlink 从没被 lstat 过。
//
// 实证后果:用户在 literature/links/ 放一个指向家目录某个 PDF 的 symlink,
// 再「删除文件夹 links/」⇒ **dataPath 外的那个 PDF 被移进回收站**。
// 越界删除(用户只说删文件夹,没说删文件夹里链接指向的东西)。
//
// ── 修法 ────────────────────────────────────────────────────────────
// symlink 子项不再走 shell.trashItem,改用 fs.rm(force+recursive):
// rm 对 symlink 只摘**链接本身**,不 follow(unlink 语义),
// 链接指向的目标留在原处(Windows 目录 junction 实测:目标仍在)。
//
// ── 本测怎么「让假实现露馅」──────────────────────────────────────────
// mock 的 shell.trashItem **模拟 Windows 的真实语义**:对传入路径做
// realpath(跟随 symlink)再把**目标**搬进假回收站。
// ⇒ 旧实现下,外部文件会被搬走(断言挂);新实现下,shell.trashItem 根本
// 不会收到 symlink 路径,外部文件原地不动。
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises';
import { existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const trashCalls: string[] = [];
// mock electron:trashItem 模拟 Windows 真实语义 —— follow symlink 后搬走**目标**
vi.mock('electron', () => ({
shell: {
trashItem: vi.fn(async (target: string) => {
trashCalls.push(target);
// 真实 Windows 行为:跟随 symlink,搬走的是**目标**
const { realpath, rename } = await import('fs/promises');
const { join: j, basename: b } = await import('path');
const real = await realpath(target);
const trashDir = j(real, '..', '..', '.fake-trash');
await mkdir(trashDir, { recursive: true });
await rename(real, j(trashDir, b(real)));
return true;
}),
},
}));
describe('safeRecursiveTrash 不 follow symlink(audit-r243)', () => {
let dataRoot: string;
let outsideDir: string;
let outsideFile: string;
beforeEach(async () => {
vi.resetModules();
trashCalls.length = 0;
dataRoot = await mkdtemp(join(tmpdir(), 'rkms-trash-link-'));
outsideDir = await mkdtemp(join(tmpdir(), 'rkms-outside-'));
outsideFile = join(outsideDir, 'tax-return.pdf');
await writeFile(outsideFile, 'PRECIOUS', 'utf8');
});
afterEach(async () => {
vi.resetModules();
await rm(dataRoot, { recursive: true, force: true });
await rm(outsideDir, { recursive: true, force: true });
});
it('目录里的 symlink 只摘链接本身,dataPath 外的目标文件原地不动', async () => {
const { setRootPath, safeRecursiveTrash } = await import(
'../../../../src/main/utils/safe-write'
);
setRootPath(dataRoot);
const dir = join(dataRoot, 'literature', 'links');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'real.pdf'), 'REAL', 'utf8');
const link = join(dir, 'evil.pdf');
try {
await symlink(outsideFile, link, 'file');
} catch (err: unknown) {
// 没开开发者模式 / 无符号链接权限 ⇒ 本机无法构造前提,跳过
const code = (err as { code?: string }).code;
if (code === 'EPERM' || code === 'EACCES' || code === 'ENOSYS') {
return;
}
throw err;
}
await safeRecursiveTrash(join(dataRoot, 'literature', 'links'));
// ① 外部目标文件必须还在(旧实现:被 shell.trashItem follow 搬走)
expect(existsSync(outsideFile), 'dataPath 外的目标文件被删了 ⇒ 越界删除').toBe(true);
// ② symlink 本身被摘掉(链接不再是链接 / 已不存在)
expect(existsSync(link), 'symlink 本身没被摘掉').toBe(false);
// ③ shell.trashItem 不得收到 symlink 路径(旧实现会收到)
expect(
trashCalls.some((p) => p === link),
`shell.trashItem 收到了 symlink 路径:${trashCalls.join(', ')}`,
).toBe(false);
});
it('目录里的普通文件仍走回收站(别把修复做成「什么都不删」)', async () => {
const { setRootPath, safeRecursiveTrash } = await import(
'../../../../src/main/utils/safe-write'
);
setRootPath(dataRoot);
const dir = join(dataRoot, 'literature', 'plain');
await mkdir(dir, { recursive: true });
const realFile = join(dir, 'a.pdf');
await writeFile(realFile, 'REAL', 'utf8');
await safeRecursiveTrash(join(dataRoot, 'literature', 'plain'));
expect(trashCalls.length, '普通文件没走 trashItem').toBeGreaterThan(0);
expect(existsSync(realFile), '普通文件还在(没被搬走)').toBe(false);
});
});