278 lines
12 KiB
TypeScript
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'/);
|
|
});
|
|
});
|