// tests/unit/main/folder-name-dead-folder.test.ts // // 守 audit 2026-10-21 P1-1:**死夹**(建得出、进不去笔记的文件夹)。 // // 缺陷(修前): // 文件夹名的创建策略(shared/rel-path.ts 的 validateFolderName)只查 // 「单段 + 长度 ≤ 100 + 核心段规则」,**一条 Win32 规则都没有**; // 而 notes 域的**笔记文件名校验**对 relPath 的**每一段**都应用 Win32 // 规则(保留字符 / 控制字符 / 结尾带点 / 结尾带空格 / 保留设备名)。 // ⇒ 两套规则的差集就是「建得出、但里面放不了任何笔记」的夹: // // 'a.'(结尾带点) 'a '(结尾带空格) 'CON'(保留设备名) // 'a:b' 'a*b' 'a?b' 'a"b' 'a|b'(Win32 保留字符) // // 用户在侧栏「新建文件夹」能建成这些夹(validateFolderName 放行); // 但在这个夹里点「新建笔记」⇒ createNote 收 'a./Untitled 1.md' ⇒ // 段 'a.' 命中「结尾带点」⇒ ValidationError ⇒ **这个夹里永远建不了 // 笔记**(而笔记**搬得进去** —— move 走 assertTargetRelPath,只查核心 // 段规则)。'a:b' 这类更糟:连 fs.mkdir 都会失败 ⇒ 用户看到的是天书级 // IO 报错,而不是「这个名字不能用」。 // // 本测锁三条: // I3. 能用 name 建的夹,一定能放笔记(跨域不变量:notes 域最严); // I3'. 反过来:被拒的名字,notes 域也拒(⇒ 不会造出装不了东西的夹); // 单一真相源:Win32 规则只在 shared/win32-name.ts 写一次,三个调用点 // (notes 文件名 / literature 改名 / 文件夹名)都从那里取。 // // 同时守住**反面**风险:path 语义(指向磁盘上已存在的东西)**不得**因此 // 收紧 —— 磁盘上可能有这些名字(用户用别的工具建的),应用必须能看见 // 并进入它们,否则又变成 r186 的「列得出来进不去」。 import { describe, expect, it, vi } from 'vitest'; import { promises as fs } from 'fs'; vi.mock('electron', () => ({ shell: { trashItem: vi.fn(), showItemInFolder: vi.fn() }, app: { getPath: vi.fn(() => 'C:/tmp/rkms-dead-folder-test') }, })); vi.mock('../../../src/main/db/resources-repo', () => ({ deleteResource: vi.fn(), listResources: vi.fn(() => []), upsertResource: vi.fn(), })); import { requireFolderName, requireNewFolderRelPath } from '../../../src/main/ipc/folders'; import { normalizeAndValidateNoteFilename } from '../../../src/main/notes/filename-validator'; import { planLiteratureRename } from '../../../src/main/literature/rename-file'; import { validateFolderName, validateRelPath } from '../../../src/shared/rel-path'; /** 这些名字**建不出来 / 建了也没用** —— 必须在创建时被拒。 */ const DEAD_NAMES: { name: string; why: string }[] = [ { name: 'a.', why: '结尾带点 —— Win32 静默截断' }, { name: 'a ', why: '结尾带空格 —— Win32 静默截断' }, { name: 'CON', why: 'Windows 保留设备名' }, { name: 'con', why: 'Windows 保留设备名(大小写不敏感)' }, { name: 'com1', why: 'Windows 保留设备名' }, { name: 'lpt9', why: 'Windows 保留设备名' }, { name: 'con.txt', why: '保留设备名 + 扩展名(按词干判)' }, { name: 'a:b', why: 'Win32 保留字符(而且 mkdir 本身就会失败)' }, { name: 'a*b', why: 'Win32 保留字符' }, { name: 'a?b', why: 'Win32 保留字符' }, { name: 'a"b', why: 'Win32 保留字符' }, { name: 'a|b', why: 'Win32 保留字符' }, ]; /** 这些名字**正常** —— 必须放行,且能装笔记。 */ const OK_NAMES: string[] = [ 'foo', '资料', '会议纪要 2024', 'a..b', 'C++ 学习', 'v1.2 草稿', "O'Brien 访谈", '100%完成', '结果=最终', 'A&B', '子目录.子目录', 'COM10', // 保留设备名只到 com9 'con 夹', // 词干带空格 ⇒ 不是保留设备名 ]; /** 文献**文件名**(不是夹名)里建不出来的名字。 */ const LIT_DEAD_FILES: string[] = [ 'ab.pdf', 'CON.pdf', 'con.txt.pdf', 'a?.pdf', 'a|.pdf', 'a*.pdf', 'a".pdf', 'a:b.pdf', ]; describe('文件夹名:死夹守卫(audit 2026-10-21 P1-1)', () => { it('I3:能用 name 建的夹,一定能放笔记(notes 域最严 ⇒ 它过了就都过了)', () => { for (const name of OK_NAMES) { expect(validateFolderName(name), `名字 ${JSON.stringify(name)} 应当放行`).toBeNull(); // 在这个夹里新建笔记:createNote 收的是含目录的 relPath ⇒ // 文件名校验会逐段检查,**父目录段也在内** ⇒ 这里过了才叫「能放」。 expect( () => normalizeAndValidateNoteFilename(`${name}/Untitled.md`, 'filename'), `夹 ${JSON.stringify(name)} 里必须能建笔记`, ).not.toThrow(); } }); it('死夹名单:创建时必须被拒(mkdir / rename-dir 两条入口一致)', () => { for (const { name, why } of DEAD_NAMES) { expect(validateFolderName(name), `${name}(${why})应当被拒`).not.toBeNull(); expect(() => requireFolderName('ch', name, 'name'), `${name}(${why})IPC 入口应拒`).toThrow(); // mkdir 的叶子段也走同一份策略 expect( () => requireNewFolderRelPath('ch', `父/${name}`, 'relPath'), `${name}(${why})mkdir 叶子段应拒`, ).toThrow(); } }); it('I3 逆向:被拒的名字,notes 域也拒 ⇒ 不会造出装不了东西的夹', () => { for (const { name } of DEAD_NAMES) { expect( () => normalizeAndValidateNoteFilename(`${name}/Untitled.md`, 'filename'), `${name} 既然建不出来,notes 域也不该能建笔记`, ).toThrow(); } }); it('文献域同款:文件改名进不去这些名字(文献侧不留半套规则)', () => { for (const name of LIT_DEAD_FILES) { expect( () => planLiteratureRename('/tmp/old.pdf', name), `文献改名 ${name} 应被拒`, ).toThrow(); } // 对照:'a..pdf' 是**合法**文件名(点不在结尾)⇒ 不能连它一起拒。 expect(planLiteratureRename('/tmp/old.pdf', 'a..pdf').newFilename).toBe('a..pdf'); }); it('反面风险:path 语义**不得**收紧(磁盘上已有的这些名字必须进得去)', () => { // r186 的不变量 I2:「列得出来的,一定能进得去」。磁盘上可能有 // 外部工具建的 'a.' / 'CON' 目录 ⇒ path 语义必须放行,否则它们 // 会从列表里消失(数据在、界面找不到 = 更糟的洞)。 for (const { name } of DEAD_NAMES) { expect( validateRelPath(name, { allowRoot: true }), `path 语义必须放行 ${name}(只有**创建**策略才拒)`, ).toBeNull(); } }); it('单一真相源:Win32 规则只在 shared/win32-name.ts 写一次', async () => { const files = [ '../../../src/main/notes/filename-validator.ts', '../../../src/main/literature/rename-file.ts', '../../../src/shared/rel-path.ts', ]; for (const rel of files) { const src = await fs.readFile(new URL(rel, import.meta.url), 'utf8'); // 规则一旦有人在自己文件里再抄一份,三处迟早分叉 ⇒ 又出现 // 「一个域能建、另一个域不能建」的洞(这次的坑就是抄漏第三处)。 expect( src, `${rel} 不得自抄 Win32 规则常量`, ).not.toMatch(/const\s+\w*(FORBIDDEN|RESERVED|DEVICE|CONTROL_CHARS)\w*\s*=/); // 三处都得从 win32-name 取判据 expect(src, `${rel} 必须从 shared/win32-name 取判据`).toMatch(/win32-name/); } const win32 = await fs.readFile( new URL('../../../src/shared/win32-name.ts', import.meta.url), 'utf8', ); expect(win32).toMatch(/WIN32_FORBIDDEN_CHARS/); expect(win32).toMatch(/RESERVED_DEVICE_NAME/); }); });