Files
Literature-Management/tests/unit/main/rel-path-validation.test.ts
2026-10-21 14:42:59 +08:00

278 lines
12 KiB
TypeScript

// tests/unit/main/rel-path-validation.test.ts
//
// 差分守卫(r186)—— relPath 校验的**四处入口**必须对同一串输入给出
// **同一个判定**,判定来源只有一处(shared/rel-path.ts)。
//
// 背景:仓库里曾有**三份**互不相同的 relPath 校验(shared/rel-path.ts
// 头注有详述):
// A. src/main/ipc/folders.ts requireRelPath / requireParentRelPath
// / requireFolderName
// B. src/main/notes/standalone.ts assertTargetRelPath
// C. src/main/literature/literature-service.ts assertLitTargetRelPath
// 三份规则各说各话,用户能碰到:
// - 磁盘上有一个名字以**空格开头**的夹(Explorer 随手就能建):
// 侧栏**列得出来**(listSubdirs 不做名字过滤)→ 点进去走 A → A 拒收
// → VALIDATION 报错 → 这个夹**永远进不去**;
// - 同一个夹,notes 的 move 走 B → 放行 → 笔记**搬得进去**;
// 文献的 move 走 C → 也放行。
// ⇒ 「应用能往一个它自己不承认存在的夹里搬数据」—— 数据在,界面找不到。
//
// 本测把同一张输入表喂给各入口,要求判定(放行/拒收)**逐条一致**;
// 判定一旦分叉,说明又有人在本入口之外手写了规则。
//
// 判定形态统一为 'ok' | 'reject'(不看错误类型/消息:三处错误类型本就
// 不同 —— ValidationError vs LibraryError —— 那是各文件自己的翻译层)。
//
// 语义分层(shared/rel-path.ts 的设计,本测照此对齐):
// - **path 语义**(指向磁盘上**已存在**的东西):不限段长;根 = 空串,
// 取决于调用点是否 allowRoot。
// → 可比对的三个入口:requireParentRelPath / assertTargetRelPath /
// assertLitTargetRelPath(三者皆 allowRoot=true)。
// - **name 语义**(新建/改名的**单段**名字):段长 ≤ MAX_FOLDER_NAME_CHARS。
// → requireFolderName 与 shared 的 validateFolderName 必须一致。
// - requireRelPath 是**严格 path 语义**(allowRoot=false,拒绝空串),
// 它与上面三个不可直接对齐 —— 单独与 shared 的 validateRelPath(v)
// (同样 allowRoot=false)对齐。
//
// 两条不变量(shared/rel-path.ts 头注):
// I1「能用 name 建的,一定能用 path 进」—— name ⊂ path;
// I2「列得出来的,一定能进得去」—— listSubdirs 用同一份核心规则过滤。
import { describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
vi.mock('electron', () => ({
shell: { trashItem: vi.fn(), showItemInFolder: vi.fn() },
// 本测只碰校验函数,不落盘 —— getPath 不会被真正用到,给个稳定的假路径。
app: { getPath: vi.fn(() => 'C:/tmp/rkms-rel-path-test') },
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
deleteResource: vi.fn(),
listResources: vi.fn(() => []),
upsertResource: vi.fn(),
}));
import {
requireRelPath,
requireFolderName,
requireParentRelPath,
requireNewFolderRelPath,
} from '../../../src/main/ipc/folders';
import { assertTargetRelPath } from '../../../src/main/notes/standalone';
import { assertLitTargetRelPath } from '../../../src/main/literature/literature-service';
import {
validateRelPath,
validateFolderName,
isValidRelPathSegment,
MAX_FOLDER_NAME_CHARS,
} from '../../../src/shared/rel-path';
import { listSubdirs } from '../../../src/main/utils/safe-write';
type Verdict = 'ok' | 'reject';
function verdict(fn: () => unknown): Verdict {
try {
fn();
return 'ok';
} catch {
return 'reject';
}
}
/** 三个「path 语义(allowRoot=true)」入口 —— 必须同进同退。 */
const PATH_ENTRIES: { label: string; check: (v: string) => unknown }[] = [
{ label: 'ipc/folders.requireParentRelPath', check: (v) => requireParentRelPath('ch', v, 'relPath') },
{ label: 'notes/standalone.assertTargetRelPath', check: (v) => assertTargetRelPath(v, 'label') },
{
label: 'literature-service.assertLitTargetRelPath',
check: (v) => assertLitTargetRelPath(v, 'label'),
},
];
/**
* 输入表 —— 覆盖 shared/rel-path.ts 头注列出的每一条规则,以及历史上
* 三份规则**分叉过**的那些串。
*/
const PATH_CASES: { value: string; note: string }[] = [
{ value: 'foo', note: '普通单段' },
{ value: 'foo/bar', note: '两级路径' },
{ value: 'foo/bar/baz', note: '多级路径' },
{ value: ' foo', note: '段首空白 —— 旧 A 拒 / 旧 B、C 放行 ⇒ 统一放行' },
{ value: 'foo ', note: '段尾空白 —— 统一放行' },
{ value: 'a b/c d', note: '路径中间的空格 —— 放行' },
{ value: '资料', note: '中文段' },
{ value: '..', note: '越界段' },
{ value: '../foo', note: '越界段(带子目录)' },
{ value: 'foo/../bar', note: '路径中间的 ..' },
{ value: 'foo..bar', note: '名字里含 ..(不是越界)—— 放行' },
{ value: 'foo/..bar', note: '路径中某段含 .. 但段本身不是 .. —— 放行' },
{ value: '/foo', note: '绝对路径(Unix 形态)' },
{ value: 'C:\\foo', note: '绝对路径(Windows 形态)' },
{ value: 'C:/foo', note: '绝对路径(Windows 混合分隔符)' },
{ value: 'foo//bar', note: '空段(双 /)' },
{ value: 'foo/', note: '结尾多余的 /' },
{ value: '.hidden', note: '隐藏项(点开头)' },
{ value: 'foo/./bar', note: '路径中间的 . 段' },
{ value: 'foo\\bar', note: '反斜杠分段' },
{ value: 'a\u0000b', note: 'NUL 控制字符' },
{ value: 'a\u001fb', note: '其它控制字符' },
{ value: '中文目录', note: '非 ASCII 段' },
{ value: 'a'.repeat(120), note: '超长段(path 语义**不限长** → 放行)' },
];
describe('relPath 校验差分守卫(r186)', () => {
it('三个 path 语义入口对同一串输入判定一致', () => {
for (const { value, note } of PATH_CASES) {
const verdicts = PATH_ENTRIES.map((e) => ({
label: e.label,
v: verdict(() => e.check(value)),
}));
const first = verdicts[0].v;
for (const r of verdicts) {
expect(
r.v,
`入口 ${r.label} 对输入 ${JSON.stringify(value)} 的判定应与 ${verdicts[0].label} 一致`,
).toBe(first);
}
// 判定必须与 shared 的 validateRelPath(allowRoot=true)一致 ——
// 共享模块是唯一真相源,入口只是翻译层。
const sharedVerdict = validateRelPath(value, { allowRoot: true }) === null ? 'ok' : 'reject';
expect(first, `shared 与入口对输入 ${JSON.stringify(value)} 的判定应一致`).toBe(sharedVerdict);
expect(note.length).toBeGreaterThan(0);
}
});
it('根目录 = 空串:allowRoot=true 放行,allowRoot=false 拒收', () => {
for (const e of PATH_ENTRIES) {
expect(verdict(() => e.check(''))).toBe('ok');
}
// requireRelPath 是严格 path 语义(不许把 root 传给 IPC 当路径用)
expect(verdict(() => requireRelPath('ch', '', 'relPath'))).toBe('reject');
expect(validateRelPath('', { allowRoot: false })).not.toBeNull();
expect(validateRelPath('', { allowRoot: true })).toBeNull();
});
it('requireRelPath 与 shared validateRelPath(严格)判定一致', () => {
for (const { value } of PATH_CASES) {
const a = verdict(() => requireRelPath('ch', value, 'relPath'));
const b = validateRelPath(value) === null ? 'ok' : 'reject';
expect(a).toBe(b);
}
});
it('name 语义:requireFolderName 与 shared validateFolderName 判定一致', () => {
const nameCases: string[] = [
'foo',
' foo',
'foo ',
'..',
'.',
'.hidden',
'a/b',
'a\\b',
'a\u0000b',
'中文',
'a..b',
'a'.repeat(MAX_FOLDER_NAME_CHARS),
'a'.repeat(MAX_FOLDER_NAME_CHARS + 1),
];
for (const name of nameCases) {
const a = verdict(() => requireFolderName('ch', name, 'name'));
const b = validateFolderName(name) === null ? 'ok' : 'reject';
expect(`${name.slice(0, 12)}:${a}`).toBe(`${name.slice(0, 12)}:${b}`);
}
});
it('不变量 I1:name 严于 path ⇒ 能建的 name 一定能当 path 用', () => {
const names = ['foo', ' foo', '中文', 'a..b', 'a'.repeat(MAX_FOLDER_NAME_CHARS)];
for (const name of names) {
expect(validateFolderName(name)).toBeNull();
expect(validateRelPath(name, { allowRoot: true })).toBeNull();
}
// 反向不成立(也不该成立):path 放行的超长段,name 语义拒收
expect(validateRelPath('a'.repeat(MAX_FOLDER_NAME_CHARS + 1), { allowRoot: true })).toBeNull();
expect(validateFolderName('a'.repeat(MAX_FOLDER_NAME_CHARS + 1))).not.toBeNull();
});
it('裁决被钉住:历史上分叉的串现在有唯一答案', () => {
// 段首空白:统一放行(旧 A 拒 / 旧 B、C 放行 ⇒ 夹列得出来进不去)
expect(verdict(() => requireParentRelPath('ch', ' 资料', 'p'))).toBe('ok');
expect(verdict(() => assertTargetRelPath(' 资料', 'l'))).toBe('ok');
expect(verdict(() => assertLitTargetRelPath(' 资料', 'l'))).toBe('ok');
expect(verdict(() => requireRelPath('ch', ' 资料', 'p'))).toBe('ok');
// 含 '..' 但不是越界:放行(旧三份都拒 ⇒ 「v1..v2」这类夹列得出来进不去)
expect(verdict(() => requireRelPath('ch', 'v1..v2', 'p'))).toBe('ok');
expect(verdict(() => requireFolderName('ch', 'v1..v2', 'n'))).toBe('ok');
// 真正的越界:仍拒
expect(verdict(() => requireRelPath('ch', '..', 'p'))).toBe('reject');
expect(verdict(() => requireRelPath('ch', '../a', 'p'))).toBe('reject');
expect(verdict(() => requireRelPath('ch', 'a/../b', 'p'))).toBe('reject');
expect(verdict(() => requireParentRelPath('ch', '..', 'p'))).toBe('reject');
});
it('mkdir:叶子段走创建策略(≤100 字符),父段只走路径规则(不限长)', () => {
// 叶子超长 → 拒(app 自己不造超长名字)
expect(verdict(() => requireNewFolderRelPath('ch', 'a'.repeat(MAX_FOLDER_NAME_CHARS + 1), 'p'))).toBe(
'reject',
);
expect(verdict(() => requireNewFolderRelPath('ch', 'a'.repeat(MAX_FOLDER_NAME_CHARS), 'p'))).toBe(
'ok',
);
// 父段超长但已存在于磁盘 → 放行(不能因此拒绝「在里面建子夹」)
expect(
verdict(() => requireNewFolderRelPath('ch', `${'a'.repeat(150)}/b`, 'p')),
).toBe('ok');
});
it('核心段规则是唯一的过滤器:listSubdirs 用同一份判据(不变量 I2)', () => {
// 纯函数层:进不去的名字一律 false
expect(isValidRelPathSegment('正常')).toBe(true);
expect(isValidRelPathSegment(' 资料')).toBe(true);
expect(isValidRelPathSegment('v1..v2')).toBe(true);
expect(isValidRelPathSegment('.hidden')).toBe(false);
expect(isValidRelPathSegment('a\\b')).toBe(false);
expect(isValidRelPathSegment('a\u0000b')).toBe(false);
expect(isValidRelPathSegment('a\u001fb')).toBe(false);
expect(isValidRelPathSegment('..')).toBe(false);
});
});
describe('不变量 I2:列得出来的,一定能进得去(r186)', () => {
it('磁盘上「段首空白」的夹:列得出来,且列出来的 relPath 进得去', async () => {
const root = await fs.mkdtemp(join(tmpdir(), 'rkms-relpath-'));
try {
await fs.mkdir(join(root, 'notes', ' 资料'), { recursive: true });
await fs.mkdir(join(root, 'notes', '正常'), { recursive: true });
await fs.mkdir(join(root, 'notes', '.hidden'), { recursive: true });
const dirs = await listSubdirs(root, 'note', null);
const names = dirs.map((d) => d.name);
expect(names).toContain(' 资料');
expect(names).toContain('正常');
expect(names).not.toContain('.hidden'); // dot 项历来不列
// 关键:列出来的每个 relPath,都必须能被 IPC 的 path 入口接受 ——
// 否则用户点进去只会得到一个 VALIDATION 报错(夹里的笔记在夹视图
// 里看不到 = 数据在、界面找不到)。
for (const d of dirs) {
expect(verdict(() => requireParentRelPath('ch', d.relPath, 'parentRelPath'))).toBe('ok');
}
} finally {
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined);
}
});
it('listSubdirs 的过滤判据来自 shared/rel-path(不另写一份正则)', async () => {
const src = await fs.readFile(
new URL('../../../src/main/utils/safe-write.ts', import.meta.url),
'utf8',
);
// 判据若在 safe-write 里另写一份,两边迟早分叉 —— 分叉的后果就是
// 「列得出来、进不去」的夹(用户可见 bug)。
expect(src).toMatch(/isValidRelPathSegment/);
expect(src).toMatch(/from '\.\.\/\.\.\/shared\/rel-path'/);
});
});