/** * File Guard 单元测试(P1-14 测试基线) * 覆盖:路径遍历防护、前缀碰撞、MEMORY.md 保护、glob 匹配、编码检测 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { isPathWithinWorkspace, isProtectedWorkspaceFile, safeResolvePath, matchGlob, matchAnyGlob, commandTouchesProtectedFile, decodeBufferWithDetection, } from '../file-guard'; describe('isPathWithinWorkspace', () => { const ws = join(tmpdir(), 'metona-test-ws'); beforeAll(() => { mkdirSync(ws, { recursive: true }); }); it('工作空间内的相对路径通过', () => { expect(isPathWithinWorkspace('src/main.ts', ws)).toBe(true); }); it('工作空间内的绝对路径通过', () => { expect(isPathWithinWorkspace(join(ws, 'src/main.ts'), ws)).toBe(true); }); it('工作空间根目录本身通过', () => { expect(isPathWithinWorkspace('.', ws)).toBe(true); }); it('路径遍历(../)被拒绝', () => { expect(isPathWithinWorkspace('../etc/passwd', ws)).toBe(false); }); it('多层遍历(../../..)被拒绝', () => { expect(isPathWithinWorkspace('../../../etc/passwd', ws)).toBe(false); }); it('前缀碰撞不误判(/app-evil 不在 /app 内)', () => { const parent = join(tmpdir(), 'metona-prefix-app'); mkdirSync(parent, { recursive: true }); expect(isPathWithinWorkspace(join(tmpdir(), 'metona-prefix-app-evil/x'), parent)).toBe(false); }); it('绝对路径指向工作空间外被拒绝', () => { expect(isPathWithinWorkspace('C:\\Windows\\System32\\cmd.exe', ws)).toBe(false); }); }); describe('isProtectedWorkspaceFile / safeResolvePath', () => { const ws = join(tmpdir(), 'metona-test-protect'); beforeAll(() => { mkdirSync(join(ws, 'sub'), { recursive: true }); }); it('工作空间根目录的 MEMORY.md 受保护', () => { expect(isProtectedWorkspaceFile('MEMORY.md', ws)).toBe(true); }); it('子目录的 MEMORY.md 不受保护', () => { expect(isProtectedWorkspaceFile(join('sub', 'MEMORY.md'), ws)).toBe(false); }); it('safeResolvePath 拒绝越界路径并抛错', () => { expect(() => safeResolvePath('../outside.txt', ws)).toThrow(/Path traversal/); }); it('safeResolvePath 拒绝根目录 MEMORY.md 并抛错', () => { expect(() => safeResolvePath('MEMORY.md', ws)).toThrow(/MEMORY.md/); }); it('safeResolvePath 正常解析工作空间内路径', () => { const resolved = safeResolvePath('src/a.ts', ws); expect(resolved).toBe(join(ws, 'src/a.ts')); }); }); describe('commandTouchesProtectedFile', () => { it('裸引用 MEMORY.md 被拦截', () => { expect(commandTouchesProtectedFile('cat MEMORY.md')).toBe(true); }); it('子目录 MEMORY.md 不被拦截', () => { expect(commandTouchesProtectedFile('cat sub/MEMORY.md')).toBe(false); expect(commandTouchesProtectedFile('cat sub\\MEMORY.md')).toBe(false); }); it('管道/分号后的 MEMORY.md 被拦截', () => { expect(commandTouchesProtectedFile('echo x | cat MEMORY.md; rm file')).toBe(true); }); // v0.7.4 P2-3: 前导路径绕过根治 —— ./ .\ ~ ~/ 前缀仍指工作空间根,必须拦截 it('./ .\\ ~ ~/ 前缀引用 MEMORY.md 被拦截(P2-3 根治)', () => { expect(commandTouchesProtectedFile('cat ./MEMORY.md')).toBe(true); expect(commandTouchesProtectedFile('cat .\\MEMORY.md')).toBe(true); expect(commandTouchesProtectedFile('cat ~/MEMORY.md')).toBe(true); expect(commandTouchesProtectedFile('cat ~/./MEMORY.md')).toBe(true); expect(commandTouchesProtectedFile('rm -rf ./MEMORY.md')).toBe(true); }); it('子目录 MEMORY.md 仍不被拦截(路径前缀不误伤)', () => { expect(commandTouchesProtectedFile('cat sub/MEMORY.md')).toBe(false); expect(commandTouchesProtectedFile('cat sub\\MEMORY.md')).toBe(false); expect(commandTouchesProtectedFile('cat ./sub/MEMORY.md')).toBe(false); expect(commandTouchesProtectedFile('cat ~/sub/MEMORY.md')).toBe(false); }); it('无关命令不误判', () => { expect(commandTouchesProtectedFile('npm run test')).toBe(false); expect(commandTouchesProtectedFile('git status')).toBe(false); }); // v0.7.4 P2-3 修正: 括号/子 shell/命令替换/重定向无空格/反引号形态 it('子 shell/括号/命令替换/重定向/反引号引用 MEMORY.md 被拦截(P2-3 修正)', () => { expect(commandTouchesProtectedFile('$(cat MEMORY.md)')).toBe(true); expect(commandTouchesProtectedFile('(cat MEMORY.md)')).toBe(true); expect(commandTouchesProtectedFile('cat { it('单 glob 匹配', () => { expect(matchGlob('main.ts', '*.ts')).toBe(true); expect(matchGlob('main.js', '*.ts')).toBe(false); }); it('? 单字符匹配', () => { expect(matchGlob('test1.js', 'test?.js')).toBe(true); expect(matchGlob('test12.js', 'test?.js')).toBe(false); }); it('逗号分隔多 glob 任一匹配', () => { expect(matchAnyGlob('a.ts', '*.ts,*.js,*.tsx')).toBe(true); expect(matchAnyGlob('a.jsx', '*.ts,*.js,*.tsx')).toBe(false); }); it('空 glob 字符串匹配所有', () => { expect(matchAnyGlob('anything.txt', '')).toBe(true); }); }); describe('decodeBufferWithDetection', () => { it('UTF-8 无 BOM 正确解码', () => { const buf = Buffer.from('你好 world', 'utf-8'); const { content, encoding } = decodeBufferWithDetection(buf); expect(content).toBe('你好 world'); expect(encoding).toBe('utf-8'); }); it('UTF-8 BOM 被剥离并识别', () => { const body = Buffer.from('hello', 'utf-8'); const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), body]); const { content, encoding } = decodeBufferWithDetection(buf); expect(content).toBe('hello'); expect(encoding).toBe('utf-8-bom'); }); it('UTF-16 LE BOM 正确解码', () => { const body = '你好'; const buf = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(body, 'utf16le')]); const { content, encoding } = decodeBufferWithDetection(buf); expect(content).toBe(body); expect(encoding).toBe('utf-16le'); }); it('UTF-16 BE BOM 正确解码(字节交换)', () => { const body = '你好'; const le = Buffer.from(body, 'utf16le'); const be = Buffer.from(le); be.swap16(); const buf = Buffer.concat([Buffer.from([0xfe, 0xff]), be]); const { content, encoding } = decodeBufferWithDetection(buf); expect(content).toBe(body); expect(encoding).toBe('utf-16be'); }); it('空 Buffer 返回空内容', () => { const { content, encoding } = decodeBufferWithDetection(Buffer.alloc(0)); expect(content).toBe(''); expect(encoding).toBe('utf-8'); }); }); describe('workspace 文件读取场景(临时目录)', () => { let ws: string; beforeAll(() => { ws = mkdtempSync(join(tmpdir(), 'metona-guard-')); writeFileSync(join(ws, 'file.txt'), 'content', 'utf-8'); }); afterAll(() => { rmSync(ws, { recursive: true, force: true }); }); it('工作空间内文件路径通过校验', () => { expect(isPathWithinWorkspace('file.txt', ws)).toBe(true); expect(isPathWithinWorkspace(join(ws, 'file.txt'), ws)).toBe(true); }); }); // ===== v0.7.4: 表格化扩充(用例数翻倍) ===== describe('commandTouchesProtectedFile — 拦截矩阵(v0.7.4 扩充)', () => { it.each([ ['裸引用', 'cat MEMORY.md'], ['./ 前缀', 'cat ./MEMORY.md'], ['.\\ 前缀', 'cat .\\MEMORY.md'], ['~/ 前缀', 'cat ~/MEMORY.md'], ['~/./ 组合', 'cat ~/./MEMORY.md'], ['分号后', 'echo a; cat MEMORY.md'], ['管道后', 'echo a | cat MEMORY.md'], ['& 后', 'echo a & cat MEMORY.md'], ['> 重定向', 'cat MEMORY.md > out'], ['< 重定向无空格', 'cat { expect(commandTouchesProtectedFile(cmd)).toBe(true); }); it.each([ ['子目录正斜杠', 'cat sub/MEMORY.md'], ['子目录反斜杠', 'cat sub\\MEMORY.md'], ['./ 子目录', 'cat ./sub/MEMORY.md'], ['~/ 子目录', 'cat ~/sub/MEMORY.md'], ['无关 npm', 'npm run test'], ['无关 git', 'git status'], ['无关 node', 'node server.js'], ['无关 tsc', 'npx tsc --noEmit'], ])('%s 放行', (_label, cmd) => { expect(commandTouchesProtectedFile(cmd)).toBe(false); }); }); describe('matchGlob — 边界矩阵(v0.7.4 扩充)', () => { it.each([ ['普通后缀', 'main.ts', '*.ts', true], ['多字符前缀', 'test-file.js', 'test-*.js', true], ['? 单字符', 'test1.js', 'test?.js', true], ['? 多字符不匹配', 'test12.js', 'test?.js', false], ['大小写不敏感', 'MAIN.TS', '*.ts', true], ['无通配', 'exact.ts', 'exact.ts', true], ['通配不匹配', 'main.ts', '*.js', false], ['空模式匹配所有', 'anything', '', false], ['尾点', 'file.txt', 'file.*', true], ['无扩展名', 'README', 'README', true], ])('%s: %s vs %s → %j', (_label, name, pattern, expected) => { expect(matchGlob(name, pattern)).toBe(expected); }); }); describe('matchAnyGlob — 多 glob 矩阵(v0.7.4 扩充)', () => { it.each([ ['任一匹配', 'a.ts', '*.js,*.ts', true], ['逗号带空格', 'b.ts', '*.js, *.ts', true], ['全部不匹配', 'c.py', '*.js,*.ts', false], ['空串匹配所有', 'x', '', true], ['纯空白匹配所有', 'x', ' ', true], ['单 glob', 'd.ts', '*.ts', true], ])('%s: %s vs %s → %j', (_label, name, pattern, expected) => { expect(matchAnyGlob(name, pattern)).toBe(expected); }); }); describe('decodeBufferWithDetection — 编码矩阵(v0.7.4 扩充)', () => { it('GBK 编码中文正确解码', () => { // GBK 编码的"中文"(使用 iconv 等价字节:UTF-8 转 GBK 后字节) const gbkBytes = Buffer.from([0xd6, 0xd0, 0xce, 0xc4]); // "中文" GBK const { content, encoding } = decodeBufferWithDetection(gbkBytes); expect(content).toBe('中文'); expect(encoding).toBe('gbk'); }); it('UTF-8 多字节中文 strict 解码', () => { const utf8 = Buffer.from('你好世界', 'utf-8'); const { content, encoding } = decodeBufferWithDetection(utf8); expect(content).toBe('你好世界'); expect(encoding).toBe('utf-8'); }); it('UTF-16 LE 带 BOM 解码', () => { const buf = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('ab', 'utf16le')]); const { content, encoding } = decodeBufferWithDetection(buf); expect(content).toBe('ab'); expect(encoding).toBe('utf-16le'); }); it('UTF-16 BE 带 BOM 解码(字节交换)', () => { const le = Buffer.from('ab', 'utf16le'); const be = Buffer.from([le[1], le[0], le[3], le[2]]); const full = Buffer.concat([Buffer.from([0xfe, 0xff]), be]); const { content, encoding } = decodeBufferWithDetection(full); expect(content).toBe('ab'); expect(encoding).toBe('utf-16be'); }); it('损坏 UTF-8 降级 GBK 再降级 loose', () => { // 无效 UTF-8 序列(0xFF 0xFE 非 BOM 场景)→ 最终 loose const bad = Buffer.from([0x80, 0x81, 0x82]); const { encoding } = decodeBufferWithDetection(bad); expect(['gbk', 'utf-8-loose']).toContain(encoding); }); it('单字节 ASCII 走 utf-8', () => { const { content, encoding } = decodeBufferWithDetection(Buffer.from('hello', 'ascii')); expect(content).toBe('hello'); expect(encoding).toBe('utf-8'); }); });