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,64 @@
// tests/unit/main/ipc/library-reader-not-initialized.test.ts
//
// 守 audit-r244「工作区未初始化 ≠ 路径越界」契约。
//
// 问题:ipc/library.ts 的 handler 把 assertInsideAllowedRoot 抛出的
// **所有**错误一律翻成 `AppError('EPERM', 'file is outside the workspace')`。
// 但 assertInsideAllowedRoot 会因为两种完全不同的原因抛错:
// ① 路径真的在工作区外 / 是 symlink ⇒ EPERM(该拒);
// ② 工作区还没初始化(getRootPath() 未 setRootPath)⇒ INTERNAL
// 「Root path not initialized. Call setRootPath() first.」
// 旧实现把 ② 也报成 ① ⇒ renderer 显示「文件在工作区外」,而真实原因是
// 启动期工作区还没就绪 —— 排查方向完全跑偏(而且这个提示对用户是**假的**:
// 文件其实可能就在工作区里)。
//
// 本测锁:工作区未初始化时,reader 通道回的是 INTERNAL(不是 EPERM)。
//
// 注意:本测要拿到「未初始化」的模块态,所以用 vi.resetModules() +
// 动态 import 拿一份**全新的** safe-write 模块(它的 currentDataPath 初值
// 为 null);同文件里不调 setRootPath。
import { beforeEach, describe, expect, it, vi } from 'vitest';
const electronMock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, fn);
},
},
};
});
vi.mock('electron', () => ({
ipcMain: electronMock.ipcMain,
shell: { trashItem: vi.fn() },
}));
import { IPC } from '../../../../src/shared/types';
interface IpcRes {
ok: boolean;
code?: string;
message?: string;
}
describe('audit-r244:reader 通道在「工作区未初始化」时报 INTERNAL', () => {
beforeEach(() => {
vi.resetModules();
electronMock.handlers.clear();
});
it('未 setRootPath 时 → code=INTERNAL(不是 EPERM)', async () => {
const { registerLibraryIPC } = await import('../../../../src/main/ipc/library');
registerLibraryIPC();
const h = electronMock.handlers.get(IPC.LIBRARY_READER_FOR_FILE);
expect(h).toBeTruthy();
const res = (await h!({}, 'C:/whatever/inside-workspace.txt')) as IpcRes;
expect(res.ok).toBe(false);
expect(res.code).toBe('INTERNAL');
expect(res.code).not.toBe('EPERM');
});
});

View File

@@ -0,0 +1,68 @@
// tests/unit/main/ipc/paths-no-path-leak.test.ts
//
// 守 audit-r244「白名单拒绝消息不回显绝对路径」契约。
//
// 问题:ipc/wrap.ts 只对 **INTERNAL** 走 scrubInternal(把 rootPath **之内**
// 的路径替成占位符);已知 code(EPERM 等)是**原样透传**的。而
// `assertInsideAllowedRoot` 的 EPERM 消息里带着 `abs` —— 恰恰是
// rootPath **之外**的路径(scrubInternal 恰恰脱敏不到它)⇒ 家目录 /
// OneDrive 目录名随 IPC 错误回包进 renderer 与 telemetry。
//
// 口径已由 ipc/shell.ts(r229)、ipc/library.ts(r191)确立:消息不含路径,
// 完整路径只留 main 侧日志。paths.ts 是**源头**,之前靠三个调用点各自
// 改写文案才没漏 —— 属「运气」而非「设计」,新调用点一接就漏。
//
// 本测锁:白名单拒绝抛出的 AppError.message 里既没有越界路径,也没有
// 工作区根路径。
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { setRootPath } from '../../../../src/main/utils/safe-write';
import { assertInsideAllowedRoot } from '../../../../src/main/ipc/paths';
import { AppError } from '../../../../src/main/utils/error';
describe('audit-r244:白名单拒绝消息不含绝对路径', () => {
let root: string;
let outside: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-pathleak-root-'));
outside = await fs.mkdtemp(join(tmpdir(), 'rkms-pathleak-out-'));
setRootPath(root);
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
await fs.rm(outside, { recursive: true, force: true });
});
it('工作区外的绝对路径 → EPERM,消息里既无越界路径也无根路径', () => {
const secret = join(outside, 'id_rsa');
let err: unknown;
try {
assertInsideAllowedRoot(secret);
} catch (e: unknown) {
err = e;
}
expect(err).toBeInstanceOf(AppError);
const appErr = err as AppError;
expect(appErr.code).toBe('EPERM');
expect(appErr.message).not.toContain(secret);
expect(appErr.message).not.toContain(outside);
expect(appErr.message).not.toContain(root);
});
it('工作区外的相对路径(基于 root 解析后越界)→ 同样不含路径', () => {
let err: unknown;
try {
assertInsideAllowedRoot('../escape-outside');
} catch (e: unknown) {
err = e;
}
expect(err).toBeInstanceOf(AppError);
expect((err as AppError).code).toBe('EPERM');
expect((err as AppError).message).not.toContain(root);
});
});

View File

@@ -0,0 +1,365 @@
// tests/unit/main/ipc/paths.test.ts
// 守 src/main/ipc/paths.ts 的「路径白名单 + symlink 防御」契约(r229 补齐 🔒 守卫缺失)。
//
// ── 被守卫的契约(退化后的用户可见后果)─────────────────────────────
// C1 isInsideAnyRoot —— 唯一一处「路径是否落在根集合之内」的判定。
// 退化(改回 startsWith 前缀比较)⇒ 工作区外的**同前缀兄弟目录**
// (如 <root>-evil)被误判成「在白名单内」⇒ 渲染层可经 rkms:// iframe /
// shell:open-path 打开工作区外的文件(读侧越界,用户隐私外泄)。
// C2 assertInsideAllowedRoot —— 相对路径以 rootPath 为基准解析、越界抛 EPERM。
// 退化(按 cwd 解析相对路径)⇒ 应用的启动目录一变,「打开笔记所在目录」
// 这类相对路径操作全部落到工作区之外 ⇒ 用户看到「路径不允许」而毫无线索。
// C3 assertNotSymlink —— 白名单内但**是 symlink** → EPERM
// (这条正是源码里那处 🔒 指针指向的行为)。
// 退化(去掉 lstat / realpath 两道防线)⇒ 攻击者把 <root>/notes/foo.md
// 换成指向 ~/.ssh/id_rsa 的 symlink,渲染层的 rkms:// iframe 就能读到
// 工作区外的任意文件;用户侧表现是「笔记内容莫名其妙变成了 SSH 私钥」。
// C4 assertNotSymlink 的 roots 参数 —— 防御对照的是**调用方自己**的白名单。
// 退化(写死 allowedRoots())⇒ 用了别的根集合的调用方要么被误杀、要么
// 放行本该拦住的路径。
//
// ── 变异思路(哪几处改动会红)─────────────────────────────────────
// M1 isInsideAnyRoot 的 rel 判定换成 startsWith 前缀比较 → A3 / A6 / D8 红。
// M2 isInsideAnyRoot 删掉 caseInsensitive 折叠 → A4 红。
// M3 isInsideAnyRoot 改成按 process.platform 在 posix/win32 之间切 →
// B 组(注入 impl)与 C 组(宿主语义)同时红。
// M4 assertInsideAllowedRoot 的相对路径改成按 cwd 解析 → D3 红。
// M5 assertInsideAllowedRoot 不再调用 assertNotSymlink → D10 / D11 红。
// M6 assertNotSymlink 去掉第一道 lstat 防线 → E3 红。
// M7 assertNotSymlink 去掉第二道 realpath 链防线 → E4 红。
// M8 assertNotSymlink 把 roots 写死成 allowedRoots() → F1 / F2 红。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { tmpdir } from 'os';
import { join, posix, resolve, win32 } from 'path';
// safe-write.ts 顶层 import { shell } from 'electron' —— node 环境下 electron
// 包导出的是二进制路径字符串,不做 mock 会在 import 期就炸。
vi.mock('electron', () => ({
shell: { trashItem: vi.fn(), openPath: vi.fn() },
}));
import { setRootPath } from '../../../../src/main/utils/safe-write';
import { AppError, errnoCode } from '../../../../src/main/utils/error';
import {
allowedRoots,
assertInsideAllowedRoot,
assertNotSymlink,
buildWorkspacePaths,
isInsideAnyRoot,
} from '../../../../src/main/ipc/paths';
/** 注入用的 posix / win32 路径语义(生产路径用的是宿主默认那套) */
const POSIX = { relative: posix.relative, isAbsolute: posix.isAbsolute };
const WIN32 = { relative: win32.relative, isAbsolute: win32.isAbsolute };
let root: string;
let outside: string;
/**
* 造一个指向 target 的**目录链接**。
* Windows 上 junction 不需要管理员权限(普通 symlink 需要 Developer Mode /
* 管理员);POSIX 上 Node 忽略 type 参数 ⇒ 等价于普通符号链接。
*/
async function makeLink(target: string, link: string): Promise<void> {
try {
await fs.symlink(target, link, 'junction');
} catch (err: unknown) {
throw new Error(
`造不出 junction(${target} → ${link}): ${String(err)}\n` +
`若所在环境完全禁止创建符号链接,这一组用例需要改成 mock fs.lstatSync。`,
);
}
}
/** 断言 fn 抛 AppError 且 code === expected */
function expectAppError(fn: () => unknown, expected: string): void {
let thrown: unknown;
try {
fn();
} catch (err: unknown) {
thrown = err;
}
expect(thrown, `期望抛 AppError(${expected}),但没有抛`).toBeInstanceOf(AppError);
expect((thrown as AppError).code).toBe(expected);
// errnoCode 是 wrap.ts / protocol.ts 判错码用的同一个 helper —— 两处口径要一致
expect(errnoCode(thrown)).toBe(expected);
}
beforeEach(async () => {
// realpath:macOS 的 /var → /private/var 是符号链接,不归一化的话 realpath
// 链防线会把「合法路径」判成越界(那是测试自身的问题,不是被测代码的)。
root = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'rkms-paths-root-')));
outside = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'rkms-paths-out-')));
setRootPath(root);
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
await fs.rm(outside, { recursive: true, force: true });
});
describe('A. isInsideAnyRoot:注入 path 语义后的判定(纯函数)', () => {
it('A1 根自身算在内(rel === "")', () => {
expect(isInsideAnyRoot(['/data'], '/data', { caseInsensitive: false, impl: POSIX })).toBe(true);
});
it('A2 根的子目录算在内', () => {
expect(
isInsideAnyRoot(['/data'], '/data/notes/a.md', { caseInsensitive: false, impl: POSIX }),
).toBe(true);
});
it('A3 同前缀兄弟目录不算在内(/data-evil ≠ /data)', () => {
// 这是本文件存在的头号理由:startsWith 前缀比较会把 /data-evil 放行
expect(
isInsideAnyRoot(['/data'], '/data-evil/x', { caseInsensitive: false, impl: POSIX }),
).toBe(false);
});
it('A4 大小写:caseInsensitive 决定 /DATA 与 /data 是否同一目录', () => {
expect(
isInsideAnyRoot(['/data'], '/DATA/x', { caseInsensitive: false, impl: POSIX }),
).toBe(false);
expect(
isInsideAnyRoot(['/data'], '/DATA/x', { caseInsensitive: true, impl: POSIX }),
).toBe(true);
});
it('A5 多根集合:落在任一根内即算在内', () => {
expect(
isInsideAnyRoot(['/a', '/b'], '/b/x', { caseInsensitive: false, impl: POSIX }),
).toBe(true);
expect(
isInsideAnyRoot(['/a', '/b'], '/c/x', { caseInsensitive: false, impl: POSIX }),
).toBe(false);
});
it('A6 空根集合 ⇒ 一律不在内', () => {
expect(isInsideAnyRoot([], '/data', { caseInsensitive: false, impl: POSIX })).toBe(false);
});
it('A7 win32 语义:同盘子目录在内,换盘在外', () => {
expect(
isInsideAnyRoot(['C:\\data'], 'C:\\data\\notes', { caseInsensitive: false, impl: WIN32 }),
).toBe(true);
// 换盘 ⇒ relative 返回绝对路径 ⇒ 判为不在内
expect(
isInsideAnyRoot(['C:\\data'], 'D:\\data\\notes', { caseInsensitive: false, impl: WIN32 }),
).toBe(false);
});
it('A8 ".." 片段由 relative 内部归一化 ⇒ 仍判在内', () => {
// path.relative 内部会 resolve 两侧 ⇒ '/a/b/../b/c' ≡ '/a/b/c' ⇒ 在 '/a/b' 内。
// (反过来 '/data-evil' 之所以被拒,是因为归一化后它真的落在根之外。)
expect(
isInsideAnyRoot(['/a/b'], '/a/b/../b/c', { caseInsensitive: false, impl: POSIX }),
).toBe(true);
expect(
isInsideAnyRoot(['/a/b'], '/a/b/../../etc/passwd', { caseInsensitive: false, impl: POSIX }),
).toBe(false);
});
});
describe('B. isInsideAnyRoot:默认 impl(宿主 path 语义)', () => {
it('B1 根自身 / 子目录在内,区外在外', () => {
expect(isInsideAnyRoot([root], root)).toBe(true);
expect(isInsideAnyRoot([root], join(root, 'notes', 'a.md'))).toBe(true);
expect(isInsideAnyRoot([root], outside)).toBe(false);
});
it('B2 同前缀兄弟目录不在内(宿主语义下同样成立)', () => {
// root 与 sibling 只差后缀 ⇒ 前缀比较会误放行
const sibling = `${root}-evil`;
expect(isInsideAnyRoot([root], join(sibling, 'x'))).toBe(false);
});
it('B3 默认 opts 省略时 caseInsensitive 视为 false', () => {
// 显式传 false 与不传 opts 语义一致(省略 opts ⇒ 默认 { caseInsensitive: false })
const rootUpper = root.toUpperCase();
expect(isInsideAnyRoot([root], join(rootUpper, 'x'))).toBe(
isInsideAnyRoot([root], join(rootUpper, 'x'), { caseInsensitive: false }),
);
});
});
describe('C. allowedRoots / buildWorkspacePaths', () => {
it('C1 allowedRoots() 只含当前 rootPath(库下线后白名单缩成单元素)', () => {
expect(allowedRoots()).toEqual([resolve(root)]);
});
it('C2 buildWorkspacePaths() 给出 root / notes / favorites 三个绝对路径', () => {
const paths = buildWorkspacePaths();
expect(paths.root).toBe(resolve(root));
expect(paths.notes).toBe(join(resolve(root), 'notes'));
expect(paths.favorites).toBe(join(resolve(root), 'workspace', 'favorites'));
});
});
describe('D. assertInsideAllowedRoot:白名单 + 相对路径基准', () => {
it('D1 空串 → EINVAL', () => {
expectAppError(() => assertInsideAllowedRoot(''), 'EINVAL');
});
it('D2 非字符串 → EINVAL', () => {
expectAppError(() => assertInsideAllowedRoot(42 as unknown as string), 'EINVAL');
});
it('D3 相对路径以 rootPath 为基准解析(不是 cwd)', () => {
const abs = assertInsideAllowedRoot('notes');
expect(abs).toBe(join(resolve(root), 'notes'));
});
it('D4 绝对路径(区内)原样返回', async () => {
const dir = join(root, 'notes');
await fs.mkdir(dir, { recursive: true });
expect(assertInsideAllowedRoot(dir)).toBe(dir);
});
it('D5 根自身允许(相对结果为空串)', () => {
expect(assertInsideAllowedRoot(root)).toBe(resolve(root));
});
it('D6 区外路径 → EPERM', () => {
expectAppError(() => assertInsideAllowedRoot(outside), 'EPERM');
});
it('D7 相对路径逃逸("..")→ EPERM', () => {
expectAppError(() => assertInsideAllowedRoot('../escape'), 'EPERM');
});
it('D8 同前缀兄弟目录 → EPERM', () => {
expectAppError(() => assertInsideAllowedRoot(`${root}-evil`), 'EPERM');
});
it('D9 区内但尚不存在的路径 → 放行(先校验再写)', async () => {
const missing = join(root, 'notes', 'not-created-yet.md');
expect(assertInsideAllowedRoot(missing)).toBe(missing);
});
it('D10 区内但本身是 symlink → EPERM', async () => {
const target = join(root, 'real-target');
await fs.mkdir(target, { recursive: true });
const link = join(root, 'link');
await makeLink(target, link);
expectAppError(() => assertInsideAllowedRoot(link), 'EPERM');
});
it('D11 中间目录段被换成 symlink 指向区外 → EPERM(realpath 链防线)', async () => {
const secret = join(outside, 'secret.txt');
await fs.writeFile(secret, 'SECRET', 'utf-8');
await makeLink(outside, join(root, 'sub'));
expectAppError(() => assertInsideAllowedRoot(join(root, 'sub', 'secret.txt')), 'EPERM');
});
});
describe('E. assertNotSymlink:两道防线', () => {
it('E1 普通文件放行', async () => {
const f = join(root, 'plain.txt');
await fs.writeFile(f, 'hello', 'utf-8');
expect(() => assertNotSymlink(f, [root])).not.toThrow();
});
it('E2 普通目录放行', async () => {
const dir = join(root, 'plain-dir');
await fs.mkdir(dir, { recursive: true });
expect(() => assertNotSymlink(dir, [root])).not.toThrow();
});
it('E3 不存在的路径放行(后续 fs.open 自己报 ENOENT)', () => {
const missing = join(root, 'never-created.md');
expect(() => assertNotSymlink(missing, [root])).not.toThrow();
});
it('E4 白名单内但 lstat 是 symlink → EPERM(源码指针指向的那条行为)', async () => {
// 目标**也在白名单内** —— 拦它的理由是「它是链接」,不是「它指向区外」
const target = join(root, 'real-target');
await fs.mkdir(target, { recursive: true });
const link = join(root, 'link');
await makeLink(target, link);
expectAppError(() => assertNotSymlink(link, [root]), 'EPERM');
});
it('E5 中间段 symlink 把落点带出白名单 → EPERM(realpath 链防线)', async () => {
const secret = join(outside, 'secret.txt');
await fs.writeFile(secret, 'SECRET', 'utf-8');
await makeLink(outside, join(root, 'sub'));
expectAppError(() => assertNotSymlink(join(root, 'sub', 'secret.txt'), [root]), 'EPERM');
});
});
describe('F. assertNotSymlink 的 roots 参数 = 调用方自己的白名单', () => {
it('F1 路径在 allowedRoots 内、但 roots 是别的集合 → EPERM', async () => {
// 写死 allowedRoots() 的实现会放行 ⇒ 这条红
const f = join(root, 'plain.txt');
await fs.writeFile(f, 'hello', 'utf-8');
expectAppError(() => assertNotSymlink(f, [outside]), 'EPERM');
});
it('F2 路径不在 allowedRoots 内、但 roots 包含它 → 放行', async () => {
const f = join(outside, 'plain.txt');
await fs.writeFile(f, 'hello', 'utf-8');
expect(() => assertNotSymlink(f, [outside])).not.toThrow();
});
});
/**
* G 组(audit-r229 补):根自身是 symlink / junction 时,区内合法文件
* **不得**被判成「realpath 链逃逸」。
*
* 复现形态(Windows 上最常见):数据目录放在 OneDrive / Dropbox(云盘目录
* 本身是 reparse point),或用户按 utils/path-check.ts 的指引用 `mklink /J`
* 把数据目录建成 junction。此时
* - 白名单存的是**明文根** resolve(junction) → C:\data
* - realpath(区内的文件) 解析到**物理位置** → C:\real\data\notes\a.md
* 两侧口径不一致 ⇒ relative() 算出 '..\real\data\...' ⇒ 旧实现抛 EPERM。
*
* 用户现象:shell:open-path 报「路径不允许」、rkms:// 协议对所有文件返 403
* (文献 / PDF / 图片全打不开),而磁盘上的数据好好躺着。
*
* 修法见 src/main/ipc/paths.ts 的 isInsideRootsRealpathAware:落点落在
* 「明文根」**或**「realpath 后的根」之内即放行(与 safe-write.ts 2026-10-01
* 的同源修法一致);真正的逃逸(root/notes → /etc)两侧都不满足,仍被拒。
*/
describe('G. 根自身是 symlink/junction(数据目录在 OneDrive / mklink /J 下)', () => {
it('G1 根是 junction 时,区内真实文件不得被判 EPERM', async () => {
const realRoot = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'rkms-paths-real-')));
const linkRoot = join(tmpdir(), `rkms-paths-link-${process.pid}-${Date.now()}`);
await makeLink(realRoot, linkRoot);
try {
setRootPath(linkRoot);
const notes = join(linkRoot, 'notes');
await fs.mkdir(notes, { recursive: true });
const file = join(notes, 'a.md');
await fs.writeFile(file, '# a', 'utf-8');
// 修前实测:抛 AppError('EPERM', 'path resolves outside allowed roots ...')
expect(() => assertInsideAllowedRoot(file)).not.toThrow();
expect(() => assertNotSymlink(file, [resolve(linkRoot)])).not.toThrow();
} finally {
setRootPath(root);
await fs.rm(linkRoot, { recursive: true, force: true });
await fs.rm(realRoot, { recursive: true, force: true });
}
});
it('G2 根是 junction 时,逃逸 symlink 仍被拒(防线不放松)', async () => {
const realRoot = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'rkms-paths-real-')));
const linkRoot = join(tmpdir(), `rkms-paths-link2-${process.pid}-${Date.now()}`);
await makeLink(realRoot, linkRoot);
try {
setRootPath(linkRoot);
// 把 junction 下的 notes 换成指向区外的 symlink —— 逃逸必须仍被拦
const notes = join(linkRoot, 'notes');
await fs.mkdir(notes, { recursive: true });
await fs.rm(notes, { recursive: true, force: true });
await makeLink(outside, notes);
const file = join(notes, 'evil.txt');
await fs.writeFile(file, 'x', 'utf-8');
expectAppError(() => assertNotSymlink(file, [resolve(linkRoot)]), 'EPERM');
} finally {
setRootPath(root);
await fs.rm(linkRoot, { recursive: true, force: true });
await fs.rm(realRoot, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,354 @@
// tests/unit/main/ipc/pull-snapshot-companion.test.ts
//
// 守 r54 I4「pull-snapshot 配对」这条 IPC 契约(🔒 标记自 r54 一直挂在这个
// 路径上,r211 补上守卫本体)。
//
// ── 被守卫的缺陷(不是风格问题,是契约空洞)──────────────────────────
// r54 I4 的设计:每个 push 通道配一条 GET_STATE invoke,让 renderer 在
// mount 时主动拉一份 baseline,补「主进程 push 已先于 listener 注册」的
// race。配套的注释在五层里都写得很认真:
//
// src/shared/types.ts 「每个配对 push 都对应一条 GET_STATE invoke」
// src/preload/index.ts 「防『主进程 push 已先于 renderer listener』竞态」
// src/main/ipc/workspace.ts 「renderer remount 时若 listener 还没注册会错过」
// src/renderer/.../literature.ts 「pull-snapshot 由 literatureGetState 兜底」
//
// 但 LITERATURE_GET_STATE 与 FAVORITES_GET_STATE **从没有任何 renderer
// 调用点**(实测:src/renderer/ 全域 grep 零命中,只有注释提到它们)。
// 而两者的 handler 又与同域的 LIST 通道同源同形:
// literature:get-state → svc.list() ≡ literature:list
// favorites:get-state → listEntries()+filter ≡ favorites:list
// ⇒ 它们是两条**空壳 API**:既骗注释(宣称兜底一个并不存在的 race),
// 又是五层(enum / preload / api-types / main handler / schema)里的死代码。
//
// 那个 race 的真实修法是「**先订阅再 load**」,不是额外一次 pull:
// favorites: useSyncStores 的 useEffect —— 先 api.onFavoritesChanged(…),
// 再 useFavoritesStore.getState().load()(2026-09-10 的修法)
// literature: store 模块顶层 _ensureInit() 订阅(严格早于任何组件挂载),
// LiteratureList 的 useEffect 里首次 load()
// 订阅严格早于首次拉取 ⇒ 不存在漏事件窗口 ⇒ 额外 pull 是多余的。
//
// r211 的处置:两条空壳配对整对下线(enum / preload / api-types / main /
// schema 全清),配对表只剩启动进度一条(STARTUP_PROGRESS →
// STARTUP_GET_STATE,它的 renderer 调用方是 useSyncStores 的
// api.getStartupState())。
//
// ── 本测锁的契约 ───────────────────────────────────────────────────
// A. 配对表与 IPC 枚举**完全一致**:
// A1 枚举里每个 *_GET_STATE 通道都在配对表里
// (表外的 *_GET_STATE 必然是空壳 —— 那正是本轮清掉的两条)
// A2/A3 配对表里每个通道都还在枚举里(表不许写得过时)
// B. 每一对的 **push** 通道五层齐:preload 有 ipcRenderer.on 订阅方法 →
// api-types 的 Api 接口声明了它 → renderer 真的调用了它 → 主进程有
// 真实 send 点(且不在 ipc/ handler 层里)
// C. 每一对的 **get_state** 通道五层齐:preload 有 invokeGuard 方法 →
// api-types 声明 → renderer **真的调用了它** → src/main/ipc 有 handler
// —— C 里的「renderer 真的调用了它」是本守卫的核心:少了这一条,
// 空壳配对就能一路混过五层而不被发现(当初就是这么混过去的)
// D. 已退役的 LITERATURE_GET_STATE / FAVORITES_GET_STATE 不得复活
// (enum / preload / api-types / main / schemas 任一处出现都红)
//
// ── 判定方式 ───────────────────────────────────────────────────────
// 纯静态扫源码(不 import electron / 不起进程):枚举、正则切方法体、
// 递归找调用点。注释先剥掉,免得「注释里提到通道名」被当成接线。
import { readFileSync, readdirSync, statSync } from 'fs';
import { join, relative } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
function read(rel: string): string {
return readFileSync(join(ROOT, rel), 'utf-8');
}
/** 剥掉块注释与行注释(行注释的 `//` 前不许是 `:` —— 免得砍掉 https://) */
function stripComments(src: string): string {
return src
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walk(full));
else if (/\.tsx?$/.test(entry)) out.push(full);
}
return out;
}
function relOf(abs: string): string {
return relative(ROOT, abs).split('\\').join('/');
}
// ── IPC 枚举 ────────────────────────────────────────────────────────
/** src/shared/types.ts 里 IPC 常量的成员名 */
function ipcChannelKeys(): string[] {
const src = read('src/shared/types.ts');
const start = src.indexOf('export const IPC = {');
const end = src.indexOf('} as const;', start);
expect(start, 'src/shared/types.ts 里找不到 `export const IPC = {`').toBeGreaterThan(-1);
expect(end, 'src/shared/types.ts 里找不到 `} as const;`').toBeGreaterThan(-1);
const body = src.slice(start, end);
const keys: string[] = [];
for (const m of body.matchAll(/^\s{2}([A-Z][A-Z0-9_]*):/gm)) keys.push(m[1]);
return keys;
}
// ── preload ─────────────────────────────────────────────────────────
/**
* preload 顶层 `const api: Api = { … }` 的方法名 → 方法体(注释已剥)。
* 方法体 = 下一个顶层方法起点之前的全部文本。
*/
function preloadMethodBodies(): Map<string, string> {
const src = stripComments(read('src/preload/index.ts'));
const marks: Array<{ name: string; start: number }> = [];
for (const m of src.matchAll(/^ {2}([A-Za-z_$][\w$]*):/gm)) {
marks.push({ name: m[1], start: m.index });
}
const bodies = new Map<string, string>();
marks.forEach((mk, i) => {
const end = i + 1 < marks.length ? marks[i + 1].start : src.length;
bodies.set(mk.name, src.slice(mk.start, end));
});
return bodies;
}
/** preload 里「订阅了 IPC.<channel>」的方法名 */
function preloadOnMethod(channel: string): string | null {
for (const [name, body] of preloadMethodBodies()) {
if (body.includes(`ipcRenderer.on(IPC.${channel}`)) return name;
}
return null;
}
/** preload 里「invoke 了 IPC.<channel>」的方法名 */
function preloadInvokeMethod(channel: string): string | null {
for (const [name, body] of preloadMethodBodies()) {
if (!body.includes(`IPC.${channel}`)) continue;
if (!body.includes('invokeGuard')) continue;
return name;
}
return null;
}
// ── api-types(Api 接口 = preload 的类型真相)────────────────────────
/** shared/api-types.ts 的 `Api` 接口体(注释已剥) */
function apiInterfaceBody(): string {
const src = stripComments(read('src/shared/api-types.ts'));
const start = src.indexOf('export interface Api {');
expect(start, 'src/shared/api-types.ts 里找不到 `export interface Api {`').toBeGreaterThan(-1);
const end = src.indexOf('\n}', start);
expect(end, 'Api 接口没有闭合的 `\\n}`').toBeGreaterThan(-1);
return src.slice(start, end);
}
function apiTypesDeclares(method: string): boolean {
return new RegExp(`^\\s{2}${method}\\s*\\(`, 'm').test(apiInterfaceBody());
}
// ── renderer ────────────────────────────────────────────────────────
/** renderer 里调用了 `.<method>(` 的文件(相对路径) */
function rendererCallers(method: string): string[] {
const hits: string[] = [];
for (const file of walk(join(ROOT, 'src', 'renderer'))) {
const src = read(relOf(file));
if (new RegExp(`\\.${method}\\s*\\(`).test(src)) hits.push(relOf(file));
}
return hits;
}
// ── main ────────────────────────────────────────────────────────────
/** main 侧 IPC.<channel> 的 send 点(不在 ipc/ handler 层里) */
function mainSendPoints(channel: string): string[] {
const hits: string[] = [];
for (const file of walk(join(ROOT, 'src', 'main'))) {
const rel = relOf(file);
if (rel.startsWith('src/main/ipc/')) continue; // handler 层不算 send 点
for (const line of read(rel).split('\n')) {
if (!line.includes(`IPC.${channel}`)) continue;
if (!/send|broadcast|postMessage/i.test(line)) continue;
hits.push(rel);
}
}
return hits;
}
/** main/ipc 层里注册了 IPC.<channel> 的文件 */
function mainHandlerFiles(channel: string): string[] {
const hits: string[] = [];
for (const file of walk(join(ROOT, 'src', 'main', 'ipc'))) {
if (read(relOf(file)).includes(`IPC.${channel}`)) hits.push(relOf(file));
}
return hits;
}
// ── 配对表 ──────────────────────────────────────────────────────────
/**
* 现存的 pull-snapshot 配对。改这张表 = 改 IPC 契约,必须同时改
* enum / preload / api-types / main handler / renderer 调用点。
*/
const PAIRS: ReadonlyArray<{ push: string; getState: string }> = [
{ push: 'STARTUP_PROGRESS', getState: 'STARTUP_GET_STATE' },
];
/** r211 整对下线的通道 —— 不得复活(复活即空壳) */
const RETIRED_GET_STATE = ['LITERATURE_GET_STATE', 'FAVORITES_GET_STATE'];
describe('pull-snapshot 配对契约(r54 I4 / r211)', () => {
const channels = ipcChannelKeys();
describe('A. 配对表与 IPC 枚举一致', () => {
it('A1 枚举里每个 *_GET_STATE 通道都在配对表里(表外的必然是空壳)', () => {
const inEnum = channels.filter((c) => c.endsWith('_GET_STATE'));
const table = new Set(PAIRS.map((p) => p.getState));
const orphans = inEnum.filter((c) => !table.has(c));
expect(
orphans,
orphans.length > 0
? `这些 *_GET_STATE 通道不在配对表里:\n` +
orphans.map((c) => ` IPC.${c}`).join('\n') +
`\n空壳通道的典型症状 = 五层都齐(enum/preload/api-types/main)、` +
`但 renderer 从不调用它。要么接上真实调用方,要么整对下线。`
: '',
).toEqual([]);
});
it('A2 配对表里每个 get_state 通道都还在枚举里', () => {
const missing = PAIRS.map((p) => p.getState).filter((c) => !channels.includes(c));
expect(missing, `配对表里的通道不在 IPC 枚举里: ${missing.join(', ')}`).toEqual([]);
});
it('A3 配对表里每个 push 通道都还在枚举里', () => {
const missing = PAIRS.map((p) => p.push).filter((c) => !channels.includes(c));
expect(missing, `配对表里的通道不在 IPC 枚举里: ${missing.join(', ')}`).toEqual([]);
});
});
describe('B. 每对的 push 通道五层齐', () => {
for (const pair of PAIRS) {
it(`B ${pair.push}:preload 订阅 → api-types 声明 → renderer 调用 → main 有 send 点`, () => {
const onMethod = preloadOnMethod(pair.push);
expect(onMethod, `preload 里找不到 ipcRenderer.on(IPC.${pair.push}, …)`).not.toBeNull();
expect(
apiTypesDeclares(onMethod as string),
`api-types 的 Api 接口没声明 preload 的订阅方法 ${onMethod}`,
).toBe(true);
const callers = rendererCallers(onMethod as string);
expect(
callers,
`renderer 里没人调用 ${onMethod}(…) ⇒ 这条 push 通道根本没人听`,
).not.toEqual([]);
const sends = mainSendPoints(pair.push);
expect(
sends,
`主进程里找不到 IPC.${pair.push} 的 send 点(要有一个含 send/broadcast 的调用)`,
).not.toEqual([]);
});
}
});
describe('C. 每对的 get_state 通道五层齐(含 renderer 真实调用方)', () => {
for (const pair of PAIRS) {
it(`C ${pair.getState}:preload invoke → api-types 声明 → renderer 调用 → main handler`, () => {
const invokeMethod = preloadInvokeMethod(pair.getState);
expect(
invokeMethod,
`preload 里找不到 invokeGuard(… IPC.${pair.getState} …)`,
).not.toBeNull();
expect(
apiTypesDeclares(invokeMethod as string),
`api-types 的 Api 接口没声明 preload 的 invoke 方法 ${invokeMethod}`,
).toBe(true);
const callers = rendererCallers(invokeMethod as string);
expect(
callers,
`renderer 里没人调用 ${invokeMethod}(…) ⇒ 这条 GET_STATE 是空壳\n` +
`(五层都齐,但没人用 —— r211 清掉的 LITERATURE/FAVORITES 两对就是\n` +
` 这个形状:与同域 LIST 通道同源同形,注释还宣称它兜底 race。\n` +
` 修法二选一:① 接上真实调用方(并在注释里说明为什么需要它);\n` +
` ② 整对下线(enum/preload/api-types/main/schema 全清),\n` +
` 把「先订阅再 load」的顺序写成真正的兜底说明。)`,
).not.toEqual([]);
const handlers = mainHandlerFiles(pair.getState);
expect(handlers, `src/main/ipc 下没有注册 IPC.${pair.getState} 的 handler`).not.toEqual([]);
});
}
});
describe('D. 已退役的 GET_STATE 通道不得复活', () => {
for (const channel of RETIRED_GET_STATE) {
it(`D ${channel}:enum / preload / api-types / main / schemas 全域不得出现`, () => {
expect(
channels,
`IPC 枚举里复活了 ${channel}(r211 已把它整对下线 —— 它是空壳:` +
`与同域 LIST 通道同源同形,且 renderer 从来没调用过)`,
).not.toContain(channel);
// 只看**代码**,不看注释:退役说明本身要在注释里提到通道名(那是
// 给人看的历史记录),把注释也算进「复活」会让这条守卫没法用。
const base = channel.replace(/_GET_STATE$/, '');
const pascal = base.charAt(0) + base.slice(1).toLowerCase();
const patterns = [`IPC.${channel}`, `Zod${pascal}GetState`];
const scanned: Array<[string, string]> = [
['src/preload/index.ts', stripComments(read('src/preload/index.ts'))],
['src/shared/api-types.ts', stripComments(read('src/shared/api-types.ts'))],
['src/shared/schemas/ipc.ts', stripComments(read('src/shared/schemas/ipc.ts'))],
];
for (const file of walk(join(ROOT, 'src', 'main'))) {
scanned.push([relOf(file), stripComments(read(relOf(file)))]);
}
const alive = scanned
.filter(([, src]) => patterns.some((p) => src.includes(p)))
.map(([f]) => f);
expect(
alive,
`${channel} 在这些文件里复活了:\n` + alive.map((f) => ` ${f}`).join('\n'),
).toEqual([]);
});
}
});
describe('E. 守卫自身有效(变异对照)', () => {
it('E1 方法体切分能把「注释里提到通道名」与「真的订阅了」区分开', () => {
// 造一段假的 preload:方法 a 的注释里提到 IPC.FAKE_PUSH,方法 b 才真的订阅
const fake = [
'const api = {',
' // 说明文字:方法 a 的注释里提到 IPC.FAKE_PUSH',
' a: () => {',
' // IPC.FAKE_PUSH',
' },',
' b: () => {',
' ipcRenderer.on(IPC.FAKE_PUSH, listener);',
' },',
'};',
].join('\n');
const marks: Array<{ name: string; start: number }> = [];
for (const m of fake.matchAll(/^ {2}([A-Za-z_$][\w$]*):/gm)) {
marks.push({ name: m[1], start: m.index });
}
const bodies = new Map<string, string>();
marks.forEach((mk, i) => {
const end = i + 1 < marks.length ? marks[i + 1].start : fake.length;
bodies.set(mk.name, fake.slice(mk.start, end));
});
const found = [...bodies].find(([, body]) => body.includes('ipcRenderer.on(IPC.FAKE_PUSH'));
expect(found?.[0]).toBe('b');
});
it('E2 真实仓库里 preload 的 STARTUP 订阅方法查得到、且不是被注释骗到', () => {
const method = preloadOnMethod('STARTUP_PROGRESS');
expect(method).toBe('onStartupProgress');
expect(apiTypesDeclares(method as string)).toBe(true);
expect(rendererCallers(method as string).length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,119 @@
// tests/unit/main/ipc/settings-update-whitelist.test.ts
//
// 守 audit-r244「SETTINGS_UPDATE 只能改 UI 偏好字段」契约。
//
// 漏洞(本轮实证):`src/main/ipc/settings.ts` 的 handler 把 renderer 传来的
// patch 直接交给 `sanitizeSettingsPatch` —— 而它只是 `sanitizeAppSettings`
// (settings/validate.ts,同一个**加载侧**净化器)的别名,接受任何
// 「绝对 + ≤4096 字符」的 rootPath / dataPath。
//
// 而主进程 `settings/store.ts` 的 updateSettings 是
// `const next = { ...base, ...patch }; await saveSettings(next);`
// ⇒ 被劫持 / XSS 的 renderer 调
// api.updateSettings({ rootPath: 'C:\\Windows' })
// 就能把伪造的 rootPath **落盘到 settings.json**。
//
// 后果(不是数据丢失 —— 没有文件 IO 跟着伪造值走,但 UI 全错):
// - 设置页渲染错的根;renderer 侧 deriveNotesPath(settings.rootPath)
// 指向工作区**之外**的目录;
// - 「打开数据目录」走 SHELL_OPEN_PATH → assertInsideAllowedRoot 对
// **真实**根校验 → EPERM ⇒ 按钮变死;
// - 脏值要等到下次启动 reconcileRootPath 才自愈。
//
// 本测锁:
// 1. rootPath / dataPath 被剥掉(**不落盘**);
// 2. theme / language / workspaceLayout 等 UI 偏好字段照常更新;
// 3. 未知字段(未来新增的、还没进白名单的)也被剥掉(默认拒绝,而不是
// 默认放行)—— 白名单语义。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const electronMock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, fn);
},
},
};
});
vi.mock('electron', () => ({
ipcMain: electronMock.ipcMain,
dialog: { showOpenDialog: vi.fn() },
BrowserWindow: { fromWebContents: vi.fn() },
}));
const storeMock = vi.hoisted(() => ({
getSettings: vi.fn(() => ({})),
updateSettings: vi.fn(async (patch: unknown) => patch),
}));
vi.mock('../../../../src/main/settings/store', () => storeMock);
vi.mock('../../../../src/main/settings/migration', () => ({
switchDataPath: vi.fn(),
restoreDefaultDataPath: vi.fn(),
}));
import { registerSettingsIPC } from '../../../../src/main/ipc/settings';
import { IPC } from '../../../../src/shared/types';
describe('audit-r244:SETTINGS_UPDATE 只允许 UI 偏好字段', () => {
beforeEach(() => {
electronMock.handlers.clear();
storeMock.updateSettings.mockClear();
registerSettingsIPC();
});
afterEach(() => {
vi.restoreAllMocks();
});
function handler(): (event: unknown, ...args: unknown[]) => unknown {
const h = electronMock.handlers.get(IPC.SETTINGS_UPDATE);
expect(h).toBeTruthy();
return h!;
}
it('rootPath / dataPath 被剥掉,不进 updateSettings', async () => {
await handler()({}, {
rootPath: 'C:\\Windows',
dataPath: 'C:\\Windows',
theme: 'dark',
});
expect(storeMock.updateSettings).toHaveBeenCalledTimes(1);
const patch = storeMock.updateSettings.mock.calls[0]![0] as Record<string, unknown>;
expect(patch.rootPath).toBeUndefined();
expect(patch.dataPath).toBeUndefined();
expect(patch.theme).toBe('dark');
});
it('非法 theme 的 patch 被净化层剥掉后仍是空 patch(不写盘)', async () => {
await handler()({}, { theme: 'not-a-theme', rootPath: 'C:\\Windows' });
const patch = storeMock.updateSettings.mock.calls[0]![0] as Record<string, unknown>;
expect(Object.keys(patch)).toEqual([]);
});
it('UI 偏好字段照常更新(theme / language / workspaceLayout)', async () => {
await handler()({}, {
theme: 'system',
language: 'en-US',
workspaceLayout: { fontFamily: 'serif' },
});
const patch = storeMock.updateSettings.mock.calls[0]![0] as Record<string, unknown>;
expect(patch.theme).toBe('system');
expect(patch.language).toBe('en-US');
expect(patch.workspaceLayout).toEqual({ fontFamily: 'serif' });
});
it('未知字段被剥掉(白名单语义 = 默认拒绝)', async () => {
await handler()({}, { enabledExtensions: { evil: true } });
const patch = storeMock.updateSettings.mock.calls[0]![0] as Record<string, unknown>;
expect(patch.enabledExtensions).toBeUndefined();
});
});