This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
// tests/unit/main/utils/atomic-file.test.ts
//
// 守卫:AtomicFile 是「用户亲手维护的单文件状态」的通用抽象 —— 本项目里
// 唯一的生产实例就是用户的收藏夹 <root>/workspace/favorites/favorites.md。
// 它是**唯一**的数据保护层(parse 失败 → quarantine;写 → 原子写),
// 之前**没有任何直接测试**(tests/unit/main/utils/ 只有 safe-write)。
//
// audit r199 (2026-10-20) 修的 bug:start() 里 fs.readFile 和 this.parse
// 共用一个 try/catch ⇒ **任何读错误**都被当成「文件损坏」:
// 1. 把用户的 favorites.md rename 成 .corrupt-<ts>(内容可能完好!);
// 2. 内存态回落**空列表**且不抛错 ⇒ 界面上 ★ 全部消失,零错误提示;
// 3. 用户下一次点 ★ → persist 写出一个**新的** favorites.md ⇒
// 原来的 200 条收藏只剩 .corrupt-* 那一份。
// 即:一次瞬时读失败(权限 / 被占用 / 路径其实是目录)= 收藏全丢。
//
// 本文件的 6 例锁住新契约:
// 1. 读失败(EISDIR:路径其实是目录)→ start() 抛错,目录原样保留,
// **不产生 .corrupt-***,后续写入被拒(写锁);
// 2. 读失败(EACCES:权限不足)→ 原文件内容**一字不动**,不 quarantine,
// 写入被拒 —— 这是「用户的 200 条收藏还在盘上」那条最关键的断言;
// 3. **parse 失败仍然 quarantine**(r196 契约不能被 r199 改坏):
// 坏文件被改名保留,内存态回落默认值,之后仍可正常写;
// 4. ENOENT → 写一份 defaultValue 落地(首次运行路径);
// 5. 正常加载 → 内存态 = 解析结果,无 .corrupt-*;
// 6. 读阶段的瞬时 EBUSY 会被退避重试(而不是被当成「损坏」)。
import { mkdtemp, rm, readFile, readdir, stat, writeFile, mkdir } from 'fs/promises';
import { promises as fsPromises } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AtomicFile } from '../../../../src/main/utils/atomic-file';
interface Shape {
entries: string[];
}
const DEFAULT_VALUE: Shape = { entries: [] };
function makeFile(filePath: string): AtomicFile<Shape> {
return new AtomicFile<Shape>({
filePath,
defaultValue: DEFAULT_VALUE,
serialize: (v) => JSON.stringify(v),
parse: (raw) => {
const p = JSON.parse(raw) as { entries?: unknown };
if (!Array.isArray(p.entries)) throw new Error('entries must be an array');
return { entries: p.entries as string[] };
},
logScope: 'atomic-file-test',
});
}
/** 造一个带 errno code 的错误(模拟 EACCES / EBUSY / EISDIR) */
function fsError(code: string, msg: string): NodeJS.ErrnoException {
const e = new Error(msg) as NodeJS.ErrnoException;
e.code = code;
return e;
}
describe('atomic-file', () => {
let tmpRoot: string;
beforeEach(async () => {
tmpRoot = await mkdtemp(join(tmpdir(), 'atomic-file-test-'));
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(tmpRoot, { recursive: true, force: true });
});
it('读失败(EISDIR:路径其实是目录)→ 抛错 + 目录原样保留 + 不 quarantine + 写入被拒', async () => {
const filePath = join(tmpRoot, 'favorites.md');
await mkdir(filePath); // 目录占位 → readFile 必然失败
const file = makeFile(filePath);
await expect(file.start()).rejects.toThrow();
// 文件还在原地(没被 rename 走),类型也没变
expect((await stat(filePath)).isDirectory()).toBe(true);
// 没有产生任何 .corrupt-* 兄弟文件
expect((await readdir(tmpRoot)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
// 写锁生效:★ toggle 不能把用户数据覆盖成空态
await expect(file.write({ entries: ['x'] })).rejects.toThrow(/Refusing to overwrite/);
});
it('读失败(EACCES:权限不足)→ 原文件内容一字不动,写入被拒', async () => {
const filePath = join(tmpRoot, 'favorites.md');
const original = JSON.stringify({ entries: ['a', 'b', 'c'] });
await writeFile(filePath, original, 'utf8');
const file = makeFile(filePath);
const spy = vi
.spyOn(fsPromises, 'readFile')
.mockImplementationOnce(() => Promise.reject(fsError('EACCES', 'permission denied')));
await expect(file.start()).rejects.toThrow();
spy.mockRestore();
// 用户的收藏内容**原封不动**留在盘上
expect(await readFile(filePath, 'utf8')).toBe(original);
// 没有被当成「损坏文件」改名走
expect((await readdir(tmpRoot)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
// 写锁生效:后续 toggle 不会把这份完好数据覆盖掉
await expect(file.write({ entries: ['x'] })).rejects.toThrow(/Refusing to overwrite/);
expect(await readFile(filePath, 'utf8')).toBe(original);
});
it('parse 失败仍然 quarantine(r196 契约):坏文件改名保留,内存态回落默认值,之后可正常写', async () => {
const filePath = join(tmpRoot, 'favorites.md');
const garbage = 'this is not json at all';
await writeFile(filePath, garbage, 'utf8');
const file = makeFile(filePath);
// parse 失败**不抛错**(启动继续),而是 quarantine + 回落默认值
await expect(file.start()).resolves.toBeUndefined();
expect(file.get()).toEqual(DEFAULT_VALUE);
const siblings = await readdir(tmpRoot);
const quarantined = siblings.filter((n) => n.startsWith('favorites.md.corrupt-'));
expect(quarantined).toHaveLength(1);
// 原件已被改名走,内容完整保留在 .corrupt-* 里
expect(await readFile(join(tmpRoot, quarantined[0]), 'utf8')).toBe(garbage);
await expect(stat(filePath)).rejects.toThrow();
// quarantine 成功 ⇒ 没有锁写(数据已经安全留存)
await file.write({ entries: ['fresh'] });
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ entries: ['fresh'] });
});
it('ENOENT(首次运行)→ 写一份 defaultValue 落地,内存态 = 默认值', async () => {
const filePath = join(tmpRoot, 'favorites.md');
const file = makeFile(filePath);
await file.start();
expect(file.get()).toEqual(DEFAULT_VALUE);
expect((await stat(filePath)).isFile()).toBe(true);
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(DEFAULT_VALUE);
});
it('正常加载 → 内存态 = 解析结果,无 .corrupt-*', async () => {
const filePath = join(tmpRoot, 'favorites.md');
await writeFile(filePath, JSON.stringify({ entries: ['x', 'y'] }), 'utf8');
const file = makeFile(filePath);
await file.start();
expect(file.get()).toEqual({ entries: ['x', 'y'] });
expect((await readdir(tmpRoot)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
});
it('读阶段的瞬时 EBUSY 被退避重试(而不是被当成「文件损坏」)', async () => {
const filePath = join(tmpRoot, 'favorites.md');
await writeFile(filePath, JSON.stringify({ entries: ['x'] }), 'utf8');
const file = makeFile(filePath);
const spy = vi
.spyOn(fsPromises, 'readFile')
.mockImplementationOnce(() => Promise.reject(fsError('EBUSY', 'file is busy')));
await expect(file.start()).resolves.toBeUndefined();
spy.mockRestore();
expect(file.get()).toEqual({ entries: ['x'] });
expect((await readdir(tmpRoot)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
});
});

View File

@@ -0,0 +1,72 @@
// tests/unit/main/utils/errno-code.test.ts
//
// 守 errnoCode 的收窄契约(2026-10-20,r201):
//
// unknown → string | undefined,判据是**own property** 且值是 string。
//
// 为什么是 helper 而不是 inline `(err as { code?: unknown }).code`:
// inline 让 code 保持 unknown,`code === 'ENOENT'` 在 number / Symbol / null
// 上都严格不等 —— 运行时行为其实**等价**,但契约不显式;helper 把
// "不是 string 的 code 一律落 undefined"这条写成类型,读者不用再推。
//
// 本文件锁三条:
// 1. own string code → 原样返回;
// 2. 非 string code(number / null / undefined / Symbol / 对象)→ undefined;
// 3. **原型链上的 code 不算** —— 契约写的是 own property,`Object.prototype`
// 上挂一个 code 就让所有对象都"有 code"是经典事故。
//
// 同族:errPayloadStr → tests/unit/main/utils/error-payload-str.test.ts
// error.ts 全量 → tests/unit/utils/error.test.ts
import { describe, expect, it } from 'vitest';
import { errnoCode } from '../../../../src/main/utils/error';
describe('errnoCode — own string code 收窄(r201)', () => {
it('own string code → 原样返回', () => {
expect(errnoCode({ code: 'ENOENT' })).toBe('ENOENT');
expect(errnoCode({ code: 'EACCES' })).toBe('EACCES');
expect(errnoCode({ code: '' })).toBe('');
});
it('own code 但不是 string → undefined', () => {
expect(errnoCode({ code: 42 })).toBeUndefined();
expect(errnoCode({ code: null })).toBeUndefined();
expect(errnoCode({ code: undefined })).toBeUndefined();
expect(errnoCode({ code: Symbol('E') })).toBeUndefined();
expect(errnoCode({ code: { nested: true } })).toBeUndefined();
});
it('没有 code 字段 → undefined', () => {
expect(errnoCode({})).toBeUndefined();
expect(errnoCode({ errno: -2 })).toBeUndefined();
});
it('原型链上的 code → undefined(own property 收紧)', () => {
// 契约写的是 own property。`Object.prototype.code` 一旦被挂上,
// 用 `'code' in err` 判据会让**每一个**对象都"有 code"。
const proto = { code: 'INHERITED' } as Record<string, unknown>;
const child = Object.create(proto) as Record<string, unknown>;
expect(errnoCode(child)).toBeUndefined();
expect(errnoCode(Object.create({}) as Record<string, unknown>)).toBeUndefined();
});
it('null-proto 对象上的 own code 仍能被读到(hasOwnProperty.call 兼容)', () => {
const np = Object.create(null) as Record<string, unknown>;
np.code = 'ENOENT';
expect(errnoCode(np)).toBe('ENOENT');
});
it('非对象(字符串 / 数字 / null / undefined / 布尔)→ undefined', () => {
expect(errnoCode('ENOENT')).toBeUndefined();
expect(errnoCode(42)).toBeUndefined();
expect(errnoCode(null)).toBeUndefined();
expect(errnoCode(undefined)).toBeUndefined();
expect(errnoCode(true)).toBeUndefined();
});
it('NodeJS.ErrnoException 形态(真实 fs 错误)→ code 原样', () => {
const e = new Error('ENOENT: no such file') as NodeJS.ErrnoException;
e.code = 'ENOENT';
expect(errnoCode(e)).toBe('ENOENT');
});
});

View File

@@ -0,0 +1,89 @@
// tests/unit/main/utils/error-payload-str.test.ts
//
// 守 errPayloadStr 的单行日志契约(2026-10-20,r201):
//
// unknown → string,永不 undefined、永不 throw。
//
// 它是 errPayload 的"字符串版",直接喂 log.error / IPC 错误消息。关键点:
// 1. Error 有 stack → "message\nstack";无 stack → 只有 message。
// 2. cause 链拼成 "…\nCaused by: …",让根因在日志里**可见**
// (AppError / ValidationError 都用 { cause } 把根因传下去)。
// 3. cause 为 null / undefined → 不拼,避免 "Caused by: null" 噪音。
// 4. cause 环引用 → 截断成 '[Circular cause]',**不**无限递归。
// 5. 永不返回 undefined —— toast / dialog 渲染要稳定。
//
// 同族:errnoCode → tests/unit/main/utils/errno-code.test.ts
// error.ts 全量 → tests/unit/utils/error.test.ts
import { describe, expect, it } from 'vitest';
import { errPayloadStr } from '../../../../src/main/utils/error';
describe('errPayloadStr — 单行日志串(r201)', () => {
it('Error 带 stack → message + "\\n" + stack', () => {
const e = new Error('boom');
const s = errPayloadStr(e);
expect(s).toBe(`boom\n${e.stack}`);
expect(s.startsWith('boom\n')).toBe(true);
});
it('Error 无 stack → 只有 message', () => {
const e = new Error('boom');
Object.defineProperty(e, 'stack', { value: undefined, configurable: true });
expect(errPayloadStr(e)).toBe('boom');
});
it('字符串原样返回', () => {
expect(errPayloadStr('plain')).toBe('plain');
});
it('null / undefined → 字面量,永不 undefined', () => {
expect(errPayloadStr(null)).toBe('null');
expect(errPayloadStr(undefined)).toBe('undefined');
expect(typeof errPayloadStr(new Error('x'))).toBe('string');
});
it('普通对象 → [object Object]', () => {
expect(errPayloadStr({})).toBe('[object Object]');
});
it('null-proto 对象不炸(降级链兜住)', () => {
expect(() => errPayloadStr(Object.create(null))).not.toThrow();
expect(errPayloadStr(Object.create(null))).toBe('[object Object]');
});
it('cause 链被拼成 "Caused by:" 序列', () => {
const a = new Error('A');
const b = new Error('B', { cause: a });
const c = new Error('C', { cause: b });
const s = errPayloadStr(c);
expect(s.startsWith('C\n')).toBe(true);
expect(s).toContain('Caused by: B\n');
expect(s).toContain('Caused by: A\n');
});
it('cause === null → 不拼 "Caused by: null"', () => {
const e = new Error('x', { cause: null });
expect(errPayloadStr(e)).not.toContain('Caused by');
});
it('cause 环引用 → 截断为 [Circular cause],不栈溢出', () => {
const e = new Error('loop') as Error & { cause?: unknown };
e.cause = e;
let s = '';
expect(() => {
s = errPayloadStr(e);
}).not.toThrow();
expect(s).toContain('[Circular cause]');
});
it('互相引用的两节点环 → 同样终止', () => {
const a = new Error('A') as Error & { cause?: unknown };
const b = new Error('B', { cause: a });
a.cause = b;
let s = '';
expect(() => {
s = errPayloadStr(a);
}).not.toThrow();
expect(s).toContain('[Circular cause]');
});
});

View File

@@ -0,0 +1,129 @@
// tests/unit/main/utils/file-check.test.ts
//
// 守 assertFileExists 的**错误分类**契约(2026-10-20,r201):
//
// fs.open('r') 失败时的错误码必须区分:
// ENOENT / 无 code → FILE_MISSING("文件不存在")
// 其它( EACCES / EPERM / EISDIR / EBUSY …)→ IO_ERROR
//
// 为什么这条分类是行为面(不是风格):调用方(notes 的"在文件夹中显示"、文献
// 的 open 路径)据此决定 UI 文案与是否可重试。旧实现一律 FILE_MISSING,
// 权限问题被错报成"文件不存在",用户不停 retry,真实根因被埋没 —— r33 修过
// 一次(r33 只分了 ENOENT / 其它两支),r96 又把"拿不到 code"这一支从
// IO_ERROR 改回 FILE_MISSING(保守:不认识 → 当成不存在)。
//
// 本文件锁住 r96 之后的三支语义 —— 源码注释里明确写着"摘 !code 分支必红":
// 1. ENOENT → FILE_MISSING
// 2. 错误对象**没有** code → FILE_MISSING(保守兜底)
// 3. EACCES 等其它 code → IO_ERROR
// 4. 空路径 → FILE_MISSING
// 5. 打开成功 → 句柄一定被 close(不泄漏 fd)
import { promises as fsPromises } from 'fs';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { assertFileExists } from '../../../../src/main/utils/file-check';
import { createLogger } from '../../../../src/main/utils/logger';
const log = createLogger('file-check-test');
/** 造一个带 errno code 的错误(模拟 EACCES / EPERM / EISDIR / EBUSY) */
function fsError(code: string, msg: string): NodeJS.ErrnoException {
const e = new Error(msg) as NodeJS.ErrnoException;
e.code = code;
return e;
}
/** 取 LibraryError 的 code */
function codeOf(e: unknown): string | undefined {
return (e as { code?: string }).code;
}
describe('assertFileExists — 错误分类(r201)', () => {
let tmpRoot: string;
beforeEach(async () => {
tmpRoot = await mkdtemp(join(tmpdir(), 'rkms-filecheck-'));
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(tmpRoot, { recursive: true, force: true });
});
it('空路径 → FILE_MISSING(不发起 syscall)', async () => {
const spy = vi.spyOn(fsPromises, 'open');
const err = await assertFileExists('', '文件不存在', log).then(
() => null,
(e: unknown) => e,
);
expect(err).toBeInstanceOf(Error);
expect(codeOf(err)).toBe('FILE_MISSING');
expect(spy).not.toHaveBeenCalled();
});
it('文件存在 → 正常 resolve', async () => {
const p = join(tmpRoot, 'there.md');
await writeFile(p, '# hi', 'utf-8');
await expect(assertFileExists(p, '文件不存在', log)).resolves.toBeUndefined();
});
it('文件不存在(ENOENT)→ FILE_MISSING,且消息带上 errno 文本便于排查', async () => {
const p = join(tmpRoot, 'missing.md');
const err = await assertFileExists(p, '文件不存在', log).then(
() => null,
(e: unknown) => e,
);
expect(err).toBeInstanceOf(Error);
expect(codeOf(err)).toBe('FILE_MISSING');
expect((err as Error).message).toContain('文件不存在');
expect((err as Error).message).toMatch(/ENOENT|no such file/i);
});
it('无 code 的错误 → FILE_MISSING(r96 保守兜底:不认识就当不存在)', async () => {
const spy = vi
.spyOn(fsPromises, 'open')
.mockImplementationOnce(() => Promise.reject(new Error('weird failure')) as never);
const err = await assertFileExists(join(tmpRoot, 'x.md'), '文件不存在', log).then(
() => null,
(e: unknown) => e,
);
expect(codeOf(err)).toBe('FILE_MISSING');
expect(spy).toHaveBeenCalledTimes(1);
});
it('EACCES → IO_ERROR(不是 FILE_MISSING)', async () => {
vi.spyOn(fsPromises, 'open').mockImplementationOnce(
() => Promise.reject(fsError('EACCES', 'permission denied')) as never,
);
const err = await assertFileExists(join(tmpRoot, 'x.md'), '文件不存在', log).then(
() => null,
(e: unknown) => e,
);
expect(codeOf(err)).toBe('IO_ERROR');
});
it('EISDIR(路径其实是目录)→ IO_ERROR', async () => {
vi.spyOn(fsPromises, 'open').mockImplementationOnce(
() => Promise.reject(fsError('EISDIR', 'illegal operation on a directory')) as never,
);
const err = await assertFileExists(join(tmpRoot, 'dir.md'), '文件不存在', log).then(
() => null,
(e: unknown) => e,
);
expect(codeOf(err)).toBe('IO_ERROR');
});
it('打开成功 → 句柄一定被 close(不泄漏 fd)', async () => {
const close = vi.fn(async () => undefined);
const fakeFh = { close } as unknown as Awaited<ReturnType<typeof fsPromises.open>>;
vi.spyOn(fsPromises, 'open').mockImplementationOnce((() =>
Promise.resolve(fakeFh)) as unknown as typeof fsPromises.open);
await expect(
assertFileExists(join(tmpRoot, 'ok.md'), '文件不存在', log),
).resolves.toBeUndefined();
expect(close).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,148 @@
// tests/unit/main/utils/migrate-old-userdata-partial-copy.test.ts
//
// 行为锁(audit-r243,2026-10-21):**跨盘搬迁(copy fallback)失败时,不能留下
// 「半份目标副本」** —— 留了就会在下次启动把 source 端原件删掉,永久丢数据。
//
// ── 缺陷链路(旧实现)────────────────────────────────────────────────
// 1. 首次启动:userData 跨盘(OneDrive 重定向 / appData 挪到 D 盘)
// ⇒ fs.rename 抛 EXDEV ⇒ 走 copy fallback。
// 2. copy 中途失败(ENOSPC / EIO / 断电)⇒ newPath 留下**半份**拷贝
// (文件分支:截断文件;目录分支:部分子树)。旧实现只记一条 warning。
// 3. 下次启动:moveItem 先 lstat(newPath) ⇒ **存在** ⇒ 返回
// {ok:true, skipped} ⇒ 没有 warning ⇒ hasFailure 仍为 false ⇒
// itemsHandled 满 ⇒ 启动流程判定「新端已有对应副本」⇒
// **删掉 source 端原件**。
// ⇒ 用户原始 settings.json / location.json 只剩那份截断副本 —— 永久丢数据。
//
// ── 修法 ────────────────────────────────────────────────────────────
// copy 失败时把目标端残留删掉 ⇒ 下次启动「目标端不存在」⇒ 重新从 source
// 搬 ⇒ source 始终是权威副本。
//
// ── 本测怎么造出 EXDEV + 中途失败 ───────────────────────────────────
// Windows 上没法真造跨盘 rename,故 mock 'fs' 的 promises:
// · rename 一律抛 EXDEV(逼代码走 copy fallback)
// · copyFile / cp 造出「目标端半份残留」后抛 ENOSPC
// 然后跑两轮迁移:第一轮失败、第二轮恢复正常 ⇒ 断言 source 在第一轮后
// 仍在、第二轮才被删(旧实现:第一轮就删了)。
import { mkdtemp, mkdir, writeFile, rm, readFile } 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 flags = vi.hoisted(() => ({ failCopy: true }));
vi.mock('electron', () => ({
app: { getPath: () => '' },
}));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const real = actual.promises;
// audit-r253 (2026-10-21):`typeof import('fs')` 里**没有** default 属性
// (@types/node 的 fs 是 CJS 导出,default 由 esModuleInterop 合成,
// 不体现在模块命名空间的类型上)⇒ 直接写 actual.default 是 TS2339。
// 这里显式把「可能有 default」的形状加回去,运行时语义不变。
const withDefault = actual as typeof actual & { default?: typeof actual };
return {
...actual,
default: withDefault.default ?? actual,
promises: {
...real,
// 跨盘:rename 一律 EXDEV ⇒ 逼代码走 copy fallback
rename: async (oldPath: string, newPath: string) => {
const err = new Error('EXDEV: cross-device link not permitted') as NodeJS.ErrnoException;
err.code = 'EXDEV';
void oldPath;
void newPath;
throw err;
},
// 中途失败:先在目标端造出半份残留,再抛 ENOSPC
copyFile: async (src: string, dest: string) => {
if (flags.failCopy) {
await real.writeFile(dest, 'PARTIAL', 'utf8');
const err = new Error('ENOSPC: no space left on device') as NodeJS.ErrnoException;
err.code = 'ENOSPC';
void src;
throw err;
}
return real.copyFile(src, dest);
},
cp: async (src: string, dest: string, opts?: unknown) => {
if (flags.failCopy) {
await real.mkdir(dest, { recursive: true });
await real.writeFile(join(dest, 'partial.bin'), 'PARTIAL', 'utf8');
const err = new Error('ENOSPC: no space left on device') as NodeJS.ErrnoException;
err.code = 'ENOSPC';
void src;
void opts;
throw err;
}
return real.cp(src, dest, opts as never);
},
},
};
});
describe('migrateUserDataAppItems:跨盘 copy 失败不留半份残留(audit-r243)', () => {
let oldUserData: string;
let newUserData: string;
beforeEach(async () => {
vi.resetModules();
flags.failCopy = true;
oldUserData = await mkdtemp(join(tmpdir(), 'rkms-oldud-'));
newUserData = await mkdtemp(join(tmpdir(), 'rkms-newud-'));
// 老 userData 里放 3 个应用文件(闸门要求「每个 item 都被处理过」)
await writeFile(join(oldUserData, 'location.json'), '{"root":"C:/data"}', 'utf8');
await writeFile(join(oldUserData, 'settings.json'), '{"theme":"dark"}', 'utf8');
await mkdir(join(oldUserData, 'index'), { recursive: true });
await writeFile(join(oldUserData, 'index', 'a.txt'), 'A', 'utf8');
});
afterEach(async () => {
vi.resetModules();
await rm(oldUserData, { recursive: true, force: true });
await rm(newUserData, { recursive: true, force: true });
});
it('copy 失败后:source 原件仍在 + 目标端无半份残留(下次启动能重试)', async () => {
const { migrateUserDataAppItems } = await import(
'../../../../src/main/utils/migrate-old-userdata'
);
const report = await migrateUserDataAppItems(oldUserData, newUserData);
// ① 搬失败 ⇒ 必须有 warning(闸门据此保留 source)
expect(report.warnings.length, 'copy 失败却没有 warning').toBeGreaterThan(0);
// ② source 原件必须还在
expect(existsSync(join(oldUserData, 'settings.json')), 'source 被删了 ⇒ 丢数据').toBe(true);
expect(existsSync(join(oldUserData, 'location.json')), 'source 被删了 ⇒ 丢数据').toBe(true);
// ③ 目标端**不能**留半份残留(旧实现的致命点)
expect(
existsSync(join(newUserData, 'settings.json')),
'目标端留了半份残留 ⇒ 下次启动会把它当「已搬完」并删掉 source',
).toBe(false);
expect(existsSync(join(newUserData, 'location.json')), '目标端留了半份残留').toBe(false);
});
it('第二轮(copy 恢复正常)⇒ 正常搬走并删 source(证明第一轮没误删、也没卡死)', async () => {
const { migrateUserDataAppItems } = await import(
'../../../../src/main/utils/migrate-old-userdata'
);
await migrateUserDataAppItems(oldUserData, newUserData);
// 第一轮之后 source 必须还在(否则就没得搬了)
expect(existsSync(join(oldUserData, 'settings.json'))).toBe(true);
flags.failCopy = false;
const report2 = await migrateUserDataAppItems(oldUserData, newUserData);
// 第二轮:搬成功 ⇒ 目标端有完整副本
const copied = await readFile(join(newUserData, 'settings.json'), 'utf8');
expect(copied).toBe('{"theme":"dark"}');
// 搬完才删 source(跨盘走的是 copy + rm 分支,source 在 moveItem 里就被删了)
expect(existsSync(join(oldUserData, 'settings.json')), '搬成功后 source 仍留着').toBe(false);
expect(report2.warnings.length, '第二轮不该再有 warning').toBe(0);
// 目录项也搬过去了
expect(existsSync(join(newUserData, 'index', 'a.txt'))).toBe(true);
});
});

View File

@@ -0,0 +1,122 @@
// 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);
});
});

View File

@@ -0,0 +1,102 @@
// tests/unit/main/utils/safe-write.test.ts
//
// 守卫:safe-write 是主进程所有写操作的唯一入口(rootPath 边界检查)。
// 写的对错决定用户数据安全 —— UNSAFE_WRITE 误抛会让笔记无法保存;
// 反之漏抛会写到 rootPath 外,污染用户家目录。
// 4 例守最关键契约:
// 1. setRootPath + getRootPath round-trip,未初始化抛 INTERNAL。
// 2. safeWriteFileAtomic 写进 rootPath 内的子目录 OK(注意是相对路径,
// 函数内部 resolve(dp, target))。
// 3. 试图写绝对路径到 rootPath 外抛 UnsafeWriteError(code='UNSAFE_WRITE')。
// 4. ENAMETOOLONG-style 超长单段名显式 reject(而非让 fs 抛)。当前
// safeRecursiveTrash 等走 resolveInsideDataPath 链,parent UNSAFE
// 入口撞 ENAMETOOLONG 应被显式 catch 转为 UNSAFE_WRITE;此处简化
// 验证「safeWriteFileAtomic 接受短名成功、文件名超长被拒」的形状。
//
// 历史:audit-r164 N4 (2026-10-15) 把 filesystem ENAMETOOLONG 兜底换成
// main 显式 reject;memory r165 锁定 setI18nLangProvider 槽;本测
// 复跑同一组约束,断不再回退。
import { mkdtemp, rm, stat, mkdir } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
describe('safe-write', () => {
let tmpRoot: string;
// 关键:每次 beforeEach 重置模块 —— safe-write 的 currentDataPath / langProvider
// 都是 module-level 单例,vi.resetModules + 重新 import 让测试彼此隔离
// (memory r165 第五个避坑)。
beforeEach(async () => {
vi.resetModules();
tmpRoot = await mkdtemp(join(tmpdir(), 'sw-test-'));
});
afterEach(async () => {
vi.resetModules();
await rm(tmpRoot, { recursive: true, force: true });
});
it('getRootPath 未初始化抛 INTERNAL', async () => {
const { getRootPath } = await import('../../../../src/main/utils/safe-write');
try {
getRootPath();
expect.fail('应抛错');
} catch (e: unknown) {
expect((e as { code?: string }).code).toBe('INTERNAL');
}
});
it('setRootPath + getRootPath round-trip,然后 in-root 写入成功', async () => {
const { setRootPath, getRootPath, safeWriteFileAtomic } = await import(
'../../../../src/main/utils/safe-write'
);
setRootPath(tmpRoot);
expect(getRootPath()).toBe(tmpRoot);
const target = 'notes/hello.md';
await safeWriteFileAtomic(target, '# Hello\n');
const onDisk = await stat(join(tmpRoot, 'notes', 'hello.md'));
expect(onDisk.isFile()).toBe(true);
});
it('写 rootPath 外抛 UnsafeWriteError(code UNSAFE_WRITE)', async () => {
const { setRootPath, safeWriteFileAtomic, UnsafeWriteError } = await import(
'../../../../src/main/utils/safe-write'
);
setRootPath(tmpRoot);
// 拿到一个 parent 目录的绝对路径作为 attack
const outsideDir = await mkdtemp(join(tmpdir(), 'sw-out-'));
try {
const attack = join(outsideDir, 'evil.md');
let caught: unknown;
try {
await safeWriteFileAtomic(attack, 'x');
} catch (e: unknown) {
caught = e;
}
expect(caught).toBeInstanceOf(UnsafeWriteError);
expect((caught as { code?: string }).code).toBe('UNSAFE_WRITE');
} finally {
await rm(outsideDir, { recursive: true, force: true });
}
});
it('单段文件名长度超 255 显式 reject(不合 ENAMETOOLONG 透传)', async () => {
const { setRootPath, safeWriteFileAtomic } = await import(
'../../../../src/main/utils/safe-write'
);
setRootPath(tmpRoot);
await mkdir(join(tmpRoot, 'notes'), { recursive: true });
const huge = 'a'.repeat(300) + '.md';
let code: string | undefined;
let rejected = false;
try {
await safeWriteFileAtomic(`notes/${huge}`, 'x');
} catch (e: unknown) {
rejected = true;
code = (e as { code?: string }).code;
}
expect(rejected).toBe(true);
// 不限具体是 UNSAFE_WRITE 还是 VALIDATION,只要不在文件系统层抛 ENAMETOOLONG 即可
expect(code).not.toBe('ENAMETOOLONG');
expect(typeof code).toBe('string');
});
});

View File

@@ -0,0 +1,113 @@
// tests/unit/main/utils/scrub-internal.test.ts
//
// 守 scrubInternal 的**脱敏**契约(2026-10-20,r201):
//
// 把 INTERNAL 错误消息里的用户家目录替换成 <rootPath> 占位符。
//
// 为什么这是隐私面:IPC 错误消息会被结构化日志全量记录(以及任何抓 IPC
// 错误日志的 telemetry 渠道)。fs 抛错原文含 `failed at
// C:\Users\foo\ResearchHub\notes\x.md` ⇒ 家目录一路泄到 renderer / 日志。
//
// 本文件锁住:
// 1. root 全路径被替换成 <rootPath>(后面必须跟分隔符或字符串结尾 ——
// `ResearchHubX\y` 不是 `ResearchHub` 的子目录,不能误伤);
// 2. 大小写不敏感文件系统(win32/darwin)上大小写变体同样脱敏;
// 3. root 带尾分隔符(盘根 `C:\`)时也能正确脱敏(r124 修的静默失效);
// 4. 正则元字符(root 里有 `+` `[` `(` 等)被正确转义;
// 5. 超过 500 字符截断(防单条消息炸 IPC buffer);
// 6. rootPath 未初始化 → 原样返回(不阻断错误上报);
import { describe, expect, it, vi } from 'vitest';
import { isCaseInsensitiveFs } from '../../../../src/main/utils/platform';
const ROOT = 'C:\\Users\\foo\\ResearchHub';
/** 盘根(`C:\`)/POSIX 根(`/`)两条语义都依赖 platform,分别用 skipIf 隔离 */
const isWin = process.platform === 'win32';
/** 加载一份全新的模块实例(safe-write 的 rootPath 是 module 级单例) */
async function load(root?: string): Promise<(msg: string) => string> {
vi.resetModules();
if (root !== undefined) {
const { setRootPath } = await import('../../../../src/main/utils/safe-write');
setRootPath(root);
}
const { scrubInternal } = await import('../../../../src/main/utils/scrub-internal');
return scrubInternal;
}
describe('scrubInternal — 家目录脱敏(r201)', () => {
it('root 全路径 → <rootPath> 占位符', async () => {
const scrub = await load(ROOT);
const out = scrub(`failed at ${ROOT}\\notes\\x.md`);
expect(out).toBe('failed at <rootPath>\\notes\\x.md');
expect(out).not.toContain('Users');
});
it('root 出现在字符串结尾 → 同样替换', async () => {
const scrub = await load(ROOT);
expect(scrub(`path is ${ROOT}`)).toBe('path is <rootPath>');
});
it('仅是前缀相同的兄弟目录(ResearchHubX)→ 不误伤', async () => {
const scrub = await load(ROOT);
const out = scrub(`failed at ${ROOT}X\\y.md`);
expect(out).toContain('ResearchHubX');
expect(out).not.toContain('<rootPath>');
});
it('与 root 无关的路径 → 原样保留', async () => {
const scrub = await load(ROOT);
const msg = 'failed at D:\\other\\place\\y.md';
expect(scrub(msg)).toBe(msg);
});
it.skipIf(!isCaseInsensitiveFs)(
'大小写不敏感 FS(win32/darwin):大小写变体同样脱敏',
async () => {
const scrub = await load(ROOT);
const out = scrub('failed at c:\\users\\FOO\\researchhub\\notes\\x.md');
expect(out).toBe('failed at <rootPath>\\notes\\x.md');
},
);
it.skipIf(isCaseInsensitiveFs || isWin)('大小写敏感 FS(POSIX):大小写变体不脱敏', async () => {
const scrub = await load('/srv/data');
const out = scrub('failed at /SRV/DATA/x.md');
expect(out).toBe('failed at /SRV/DATA/x.md');
});
it.skipIf(!isWin)('root 带尾分隔符(盘根 C:\\)→ 盘内路径仍被脱敏(r124)', async () => {
const scrub = await load('C:\\');
const out = scrub('failed at C:\\Users\\foo\\ResearchHub\\notes\\x.md');
expect(out).toBe('failed at <rootPath>\\Users\\foo\\ResearchHub\\notes\\x.md');
expect(out).not.toContain('C:\\Users');
});
it.skipIf(isWin)('root 长度 < 2(POSIX 根 /)→ 保守跳过,整条消息原样', async () => {
const scrub = await load('/');
const msg = 'failed at /srv/data/x.md';
expect(scrub(msg)).toBe(msg);
});
it('root 含正则元字符 → 被正确转义(不误伤、不漏伤)', async () => {
const weird = 'C:\\a+b[test]\\root';
const scrub = await load(weird);
const out = scrub(`failed at ${weird}\\notes\\x.md`);
expect(out).toBe('failed at <rootPath>\\notes\\x.md');
});
it('超长消息 → 截断到 500 字符 + 省略号(防炸 IPC buffer)', async () => {
const scrub = await load(ROOT);
const long = 'x'.repeat(600);
const out = scrub(long);
expect(out).toHaveLength(501);
expect(out.endsWith('…')).toBe(true);
});
it('rootPath 未初始化 → 原样返回(不阻断错误上报)', async () => {
const scrub = await load();
const msg = 'failed at C:\\Users\\foo\\secret\\x.md';
expect(scrub(msg)).toBe(msg);
});
});

View File

@@ -0,0 +1,96 @@
// tests/unit/main/utils/self-write-echo.test.ts
//
// 守 perf r239「自写回声抑制器」的**契约**(不碰 service,单测纯逻辑):
//
// 1. 打点后、同指纹 ⇒ 命中(自写回声,抑制重扫)。
// 2. 指纹不同 ⇒ 不命中(文件被别人动过 ⇒ 外部改动必须可见)。
// —— 这条是**时间窗口实现的老洞**:窗口内的外部改动会被一起吞掉。
// 3. 记录**不被消费**:同一份指纹要能反复命中(应用连续 autosave 时
// 指纹会被新的写入覆盖,不会互相干扰)。
// 4. TTL 之外 ⇒ 不命中(平台丢事件 / 回声迟到太久 ⇒ 宁可多重扫,
// 不可漏扫)。
// 5. 路径大小写 / 分隔符差异 ⇒ 同一把钥匙(win32 不区分大小写)。
// 6. 过期打点不会永久占位(map 会自清理)。
import { describe, expect, it } from 'vitest';
import {
SelfWriteEchoSuppressor,
sameFingerprint,
} from '../../../../src/main/utils/self-write-echo';
import type { FileFingerprint } from '../../../../src/main/utils/self-write-echo';
import { isCaseInsensitiveFs } from '../../../../src/main/utils/platform';
const fp = (over: Partial<FileFingerprint> = {}): FileFingerprint => ({
mtimeMs: 1000,
size: 200,
ino: 7,
...over,
});
describe('SelfWriteEchoSuppressor(r239)', () => {
it('同指纹 ⇒ 命中自写回声', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:/lib/a.md', fp(), 0);
expect(s.isSelfEcho('C:/lib/a.md', fp(), 100)).toBe(true);
});
it('指纹不同 ⇒ 不命中(外部改动必须可见)', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:/lib/a.md', fp(), 0);
// 外部工具改过 ⇒ mtime 变 / size 变 / inode 变,任一不同即不命中
expect(s.isSelfEcho('C:/lib/a.md', fp({ mtimeMs: 2000 }), 100)).toBe(false);
expect(s.isSelfEcho('C:/lib/a.md', fp({ size: 201 }), 100)).toBe(false);
expect(s.isSelfEcho('C:/lib/a.md', fp({ ino: 8 }), 100)).toBe(false);
});
it('记录不被消费:同一份指纹反复命中', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:/lib/a.md', fp(), 0);
expect(s.isSelfEcho('C:/lib/a.md', fp(), 100)).toBe(true);
expect(s.isSelfEcho('C:/lib/a.md', fp(), 200)).toBe(true);
});
it('TTL 之外不命中(回声迟到太久 / 平台丢事件)', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:/lib/a.md', fp(), 0);
expect(s.isSelfEcho('C:/lib/a.md', fp(), 30_001)).toBe(false);
});
it('没打点过的路径不命中', () => {
const s = new SelfWriteEchoSuppressor(30_000);
expect(s.isSelfEcho('C:/lib/never-written.md', fp(), 10)).toBe(false);
});
it('分隔符风格差异视为同一把钥匙', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:\\lib\\a.md', fp(), 0);
expect(s.isSelfEcho('C:/lib/a.md', fp(), 10)).toBe(true);
});
it('大小写不敏感文件系统上大小写差异也算同一把钥匙', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:\\lib\\a.md', fp(), 0);
// 大小写折叠只发生在 case-insensitive FS 上(win32 / macOS APFS);
// POSIX 上大小写是路径语义的一部分 ⇒ 不能折叠。
const same = isCaseInsensitiveFs
? s.isSelfEcho('C:/LIB/A.md', fp(), 10)
: !s.isSelfEcho('C:/LIB/A.md', fp(), 10);
expect(same).toBe(true);
});
it('过期打点被清掉,不会永久占位', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:/lib/a.md', fp(), 0);
expect(s.size).toBe(1);
// 之后的 mark 会顺手 prune 掉过期的
s.mark('C:/lib/b.md', fp(), 50_000);
expect(s.size).toBe(1);
expect(s.isSelfEcho('C:/lib/b.md', fp(), 50_100)).toBe(true);
});
it('sameFingerprint:三因子全等才算同一份内容', () => {
expect(sameFingerprint(fp(), fp())).toBe(true);
expect(sameFingerprint(fp(), fp({ mtimeMs: 1 }))).toBe(false);
expect(sameFingerprint(fp(), fp({ size: 1 }))).toBe(false);
expect(sameFingerprint(fp(), fp({ ino: 1 }))).toBe(false);
});
});

View File

@@ -0,0 +1,151 @@
// tests/unit/main/utils/ssrf.test.ts
//
// 守 SSRF 防护的**范围判定**契约(2026-10-20,r201):
//
// isPrivateIpLiteral(host) : 同步,判字面 IP 是否落在禁止段
// isPrivateUrl(urlString) : 异步,判整个 URL 是否指向禁止地址
//
// 这份文件是主进程唯一的出站地址白名单(当前 caller:window.ts 的
// setWindowOpenHandler 同源防御)。判错的方向是"把公网当内网拦掉"
//(误伤)或"把内网当公网放行"(SSRF)—— 两个方向都要锁。
//
// 覆盖的段位(源码 IPV4_PRIVATE_RANGES / IPV6_PRIVATE_RANGES 的语义):
// IPv4 : 0/8 10/8 127/8 100.64/10 169.254/16 172.16/12 192.168/16
// 224/4(multicast) 240/4(reserved)
// IPv6 : ::/128 ::1/128 fe80::/10 fc00::/7 ff00::/8 2002::/16(6to4)
// 64:ff9b::/96(NAT64)
// IPv4-mapped IPv6(::ffff:a.b.c.d)按**数值**识别(r85),不依赖文本前缀。
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { lookupMock } = vi.hoisted(() => ({ lookupMock: vi.fn() }));
vi.mock('dns', async (importOriginal) => {
const actual = await importOriginal<typeof import('dns')>();
return { ...actual, lookup: lookupMock };
});
import { isPrivateIpLiteral, isPrivateUrl } from '../../../../src/main/utils/ssrf';
/** 让 mock 的 dns.lookup 回调返回给定的地址列表 */
function resolveTo(...addresses: string[]): void {
lookupMock.mockImplementation(
(_host: string, _opts: unknown, cb: (err: unknown, addrs: unknown[]) => void) => {
cb(null, addresses.map((address) => ({ address, family: address.includes(':') ? 6 : 4 })));
},
);
}
describe('isPrivateIpLiteral — IPv4 段位(r201)', () => {
it('RFC1918 / loopback / CGNAT / link-local / multicast / reserved 全拦', () => {
for (const ip of [
'0.0.0.0',
'10.0.0.1',
'127.0.0.1',
'127.1.2.3',
'100.64.0.1',
'169.254.1.1',
'172.16.0.1',
'172.31.255.255',
'192.168.1.1',
'224.0.0.1',
'240.0.0.1',
]) {
expect(isPrivateIpLiteral(ip), ip).toBe(true);
}
});
it('公网地址放行', () => {
for (const ip of ['1.1.1.1', '8.8.8.8', '172.32.0.1', '172.15.255.255', '100.128.0.1', '223.255.255.255']) {
expect(isPrivateIpLiteral(ip), ip).toBe(false);
}
});
it('非 IP 字面量(hostname)→ false,交由调用方进一步解析', () => {
expect(isPrivateIpLiteral('example.com')).toBe(false);
expect(isPrivateIpLiteral('localhost')).toBe(false);
});
});
describe('isPrivateIpLiteral — IPv6 段位(r201)', () => {
it('loopback / unspecified / link-local / ULA / multicast / 6to4 / NAT64 全拦', () => {
for (const ip of [
'::',
'::1',
'fe80::1',
'fc00::1',
'fd12:3456::1',
'ff02::1',
'2002::1',
'64:ff9b::1',
]) {
expect(isPrivateIpLiteral(ip), ip).toBe(true);
}
});
it('公网 IPv6 放行', () => {
expect(isPrivateIpLiteral('2606:4700:4700::1')).toBe(false);
expect(isPrivateIpLiteral('2001:db8::1')).toBe(false);
});
it('IPv4-mapped IPv6 按数值识别(r85):三种写法都拦', () => {
expect(isPrivateIpLiteral('::ffff:127.0.0.1')).toBe(true);
expect(isPrivateIpLiteral('::ffff:7f00:1')).toBe(true);
expect(isPrivateIpLiteral('0:0:0:0:0:ffff:7f00:1')).toBe(true);
});
it('IPv4-mapped 公网地址放行', () => {
expect(isPrivateIpLiteral('::ffff:8.8.8.8')).toBe(false);
});
});
describe('isPrivateUrl — URL 层(r201)', () => {
beforeEach(() => {
lookupMock.mockReset();
resolveTo('93.184.216.34');
});
it('非 http(s) 协议 → 拒', async () => {
await expect(isPrivateUrl('file:///C:/Windows/System32')).resolves.toBe(true);
await expect(isPrivateUrl('ftp://example.com/x')).resolves.toBe(true);
});
it('非法 URL → 拒(不抛)', async () => {
await expect(isPrivateUrl('not a url')).resolves.toBe(true);
});
it('host 是私有 IP 字面量 → 拒(不走 DNS)', async () => {
await expect(isPrivateUrl('http://127.0.0.1:8080/x')).resolves.toBe(true);
await expect(isPrivateUrl('http://[::1]/x')).resolves.toBe(true);
expect(lookupMock).not.toHaveBeenCalled();
});
it('host 是公网 IP 字面量 → 放行', async () => {
await expect(isPrivateUrl('https://8.8.8.8/x')).resolves.toBe(false);
});
it('hostname 解析到内网 IP → 拒(防"字面合法、解析到内网")', async () => {
resolveTo('93.184.216.34', '10.0.0.5');
await expect(isPrivateUrl('https://sneaky.example.com/x')).resolves.toBe(true);
});
it('hostname 全部解析到公网 → 放行', async () => {
resolveTo('93.184.216.34');
await expect(isPrivateUrl('https://example.com/x')).resolves.toBe(false);
});
it('DNS 解析失败 → 保守拒(不抛)', async () => {
lookupMock.mockImplementation(
(_host: string, _opts: unknown, cb: (err: unknown, addrs: unknown[]) => void) => {
cb(new Error('ENOTFOUND'), []);
},
);
await expect(isPrivateUrl('https://example.com/x')).resolves.toBe(true);
});
it('IDN 域名:URL 解析器已转 punycode(纯 ASCII)→ 走 DNS 判定', async () => {
// 源码注释说「拒绝非 ASCII hostname」,但 URL 的 hostname 一定是
// punycode 形式(ASCII)—— 该分支对 URL 入参实际不可达。此处锁住
// **真实行为**(punycode 放行),避免有人以为 IDN 会被拦而误改。
await expect(isPrivateUrl('https://例え.jp/x')).resolves.toBe(false);
});
});