import { mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { crc32 } from 'node:zlib'; import { afterEach, beforeEach, describe, expect, it, vi, type TestContext } from 'vitest'; import { assertSafeRelativePath, normalizeSkillName, parseFrontmatter, SkillService, } from './skillService'; const SKILL_MD = ['---', 'name: demo-skill', 'description: 一个用于测试的技能', '---', '', '# demo', ''].join('\n'); interface RawZipEntry { name: string; data?: string | Buffer; /** Unix mode,写在 external file attributes 高 16 位;0o120777 即符号链接 */ mode?: number; } /** * 手搓 stored(不压缩)zip。 * 不能用 yazl:它自己就拒绝构造 `../`、绝对路径这类条目,而安全测试恰恰需要它们。 */ function makeZip(entries: RawZipEntry[]): Buffer { const locals: Buffer[] = []; const centrals: Buffer[] = []; let offset = 0; for (const entry of entries) { const isDir = entry.name.endsWith('/'); const data = isDir ? Buffer.alloc(0) : Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data ?? '', 'utf8'); const nameBuf = Buffer.from(entry.name, 'utf8'); const crc = isDir ? 0 : crc32(data); const mode = entry.mode ?? (isDir ? 0o40755 : 0o100644); const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0, 6); local.writeUInt16LE(0, 8); // method: stored local.writeUInt16LE(0, 10); local.writeUInt16LE(0x21, 12); local.writeUInt32LE(crc >>> 0, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(nameBuf.length, 26); local.writeUInt16LE(0, 28); locals.push(local, nameBuf, data); const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE((0x03 << 8) | 20, 4); // version made by: unix central.writeUInt16LE(20, 6); central.writeUInt16LE(0, 8); central.writeUInt16LE(0, 10); central.writeUInt16LE(0, 12); central.writeUInt16LE(0x21, 14); central.writeUInt32LE(crc >>> 0, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(nameBuf.length, 28); central.writeUInt16LE(0, 30); central.writeUInt16LE(0, 32); central.writeUInt16LE(0, 34); central.writeUInt16LE(0, 36); central.writeUInt32LE((mode << 16) >>> 0, 38); central.writeUInt32LE(offset, 42); centrals.push(central, nameBuf); offset += local.length + nameBuf.length + data.length; } const centralDir = Buffer.concat(centrals); const eocd = Buffer.alloc(22); eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(entries.length, 8); eocd.writeUInt16LE(entries.length, 10); eocd.writeUInt32LE(centralDir.length, 12); eocd.writeUInt32LE(offset, 16); return Buffer.concat([...locals, centralDir, eocd]); } function makeRuntime(skills: Array<{ name: string; path: string }> = []) { return { listSkills: vi.fn().mockResolvedValue( skills.map((skill) => ({ name: skill.name, description: 'd', path: skill.path, scope: 'user', enabled: true, cwd: '', })), ), setSkillEnabled: vi.fn().mockResolvedValue(true), readConfig: vi.fn(async () => ({}) as Record), writeConfigValue: vi.fn(async () => undefined), }; } let root: string; let skillsDir: string; let runtime: ReturnType; let service: SkillService; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'zsjz-skill-')); skillsDir = join(root, 'skills'); await mkdir(skillsDir, { recursive: true }); runtime = makeRuntime(); service = new SkillService(runtime, { skillsDir }); }); afterEach(async () => { await rm(root, { recursive: true, force: true }); }); async function writeSkillSource(dir: string, content = SKILL_MD): Promise { await mkdir(dir, { recursive: true }); await writeFile(join(dir, 'SKILL.md'), content, 'utf8'); return dir; } describe('normalizeSkillName', () => { it('归一化为小写连字符命名', () => { expect(normalizeSkillName('My Skill_Name')).toBe('my-skill-name'); expect(normalizeSkillName(' demo--skill ')).toBe('demo-skill'); }); it('拒绝空名与超长名', () => { expect(() => normalizeSkillName('***')).toThrow(/非法/); expect(() => normalizeSkillName('a'.repeat(65))).toThrow(/非法/); }); }); describe('parseFrontmatter', () => { it('读取 name 与 description', () => { expect(parseFrontmatter(SKILL_MD)).toEqual({ name: 'demo-skill', description: '一个用于测试的技能' }); }); it('支持引号与缺失字段', () => { expect(parseFrontmatter('---\nname: "quoted"\n---\n')).toEqual({ name: 'quoted', description: null }); expect(parseFrontmatter('# 没有 frontmatter')).toEqual({ name: null, description: null }); }); }); describe('installFromFolder', () => { it('按 frontmatter 的 name 落盘', async () => { const source = await writeSkillSource(join(root, 'src')); await service.installFromFolder(source); const installed = join(skillsDir, 'demo-skill', 'SKILL.md'); expect((await stat(installed)).isFile()).toBe(true); expect(await readFile(installed, 'utf8')).toContain('demo-skill'); expect(runtime.listSkills).toHaveBeenCalledWith({ forceReload: true }); }); it('缺 SKILL.md 时拒绝', async () => { const source = join(root, 'empty'); await mkdir(source, { recursive: true }); await writeFile(join(source, 'readme.md'), 'x', 'utf8'); await expect(service.installFromFolder(source)).rejects.toThrow(/缺少 SKILL\.md/); }); it('frontmatter 缺 description 时拒绝', async () => { const source = await writeSkillSource(join(root, 'no-desc'), '---\nname: no-desc\n---\n# x\n'); await expect(service.installFromFolder(source)).rejects.toThrow(/缺少 description/); }); it('重名需显式覆盖', async () => { const source = await writeSkillSource(join(root, 'src')); await service.installFromFolder(source); await expect(service.installFromFolder(source)).rejects.toThrow(/已存在/); await service.installFromFolder(source, { overwrite: true }); expect((await stat(join(skillsDir, 'demo-skill', 'SKILL.md'))).isFile()).toBe(true); }); it('安装失败时清掉暂存目录', async () => { const source = join(root, 'bad'); await mkdir(source, { recursive: true }); await writeFile(join(source, 'notes.md'), 'x', 'utf8'); await expect(service.installFromFolder(source)).rejects.toThrow(); expect(await readdir(skillsDir)).toEqual([]); }); it('拒绝源目录里的链接(Windows 用 junction)', async (ctx: TestContext) => { const outside = join(root, 'outside'); await mkdir(outside, { recursive: true }); await writeFile(join(outside, 'secret.md'), 'secret', 'utf8'); const source = await writeSkillSource(join(root, 'src')); try { await symlink(outside, join(source, 'link-dir'), 'junction'); } catch { ctx.skip(); return; } await expect(service.installFromFolder(source)).rejects.toThrow(/符号链接/); }); }); describe('installFromZip', () => { it('SKILL.md 在 zip 根目录时可直接安装', async () => { await service.installFromZip(makeZip([{ name: 'SKILL.md', data: SKILL_MD }])); expect((await stat(join(skillsDir, 'demo-skill', 'SKILL.md'))).isFile()).toBe(true); }); it('带一层外壳目录时安装内层', async () => { await service.installFromZip( makeZip([ { name: 'wrapper/SKILL.md', data: SKILL_MD }, { name: 'wrapper/references/a.md', data: 'ref' }, ]), ); expect((await stat(join(skillsDir, 'demo-skill', 'SKILL.md'))).isFile()).toBe(true); expect((await stat(join(skillsDir, 'demo-skill', 'references', 'a.md'))).isFile()).toBe(true); expect(await stat(join(skillsDir, 'wrapper')).catch(() => null)).toBeNull(); }); // 下面几例里 yauzl 会先拦下非法条目名,我方 assertSafeRelativePath 是第二道防线(单独测) it('拒绝 ../ 路径穿越条目,且不会有文件逃逸', async () => { const zip = makeZip([ { name: 'SKILL.md', data: SKILL_MD }, { name: '../evil.md', data: 'pwned' }, ]); await expect(service.installFromZip(zip)).rejects.toThrow(); expect(await stat(join(root, 'evil.md')).catch(() => null)).toBeNull(); expect(await readdir(skillsDir)).toEqual([]); }); it('拒绝嵌套的 ../ 穿越条目', async () => { const zip = makeZip([ { name: 'SKILL.md', data: SKILL_MD }, { name: 'docs/../../evil.md', data: 'pwned' }, ]); await expect(service.installFromZip(zip)).rejects.toThrow(); expect(await stat(join(root, 'evil.md')).catch(() => null)).toBeNull(); }); it('拒绝反斜杠形式的穿越条目', async () => { const zip = makeZip([ { name: 'SKILL.md', data: SKILL_MD }, { name: '..\\evil.md', data: 'pwned' }, ]); await expect(service.installFromZip(zip)).rejects.toThrow(); }); it('拒绝绝对路径条目', async () => { const zip = makeZip([ { name: 'SKILL.md', data: SKILL_MD }, { name: '/etc/passwd', data: 'root' }, ]); await expect(service.installFromZip(zip)).rejects.toThrow(); }); it('拒绝 Windows 盘符绝对路径条目', async () => { const zip = makeZip([ { name: 'SKILL.md', data: SKILL_MD }, { name: 'C:/Windows/evil.md', data: 'x' }, ]); await expect(service.installFromZip(zip)).rejects.toThrow(); }); it('拒绝 __MACOSX 元数据', async () => { const zip = makeZip([ { name: 'SKILL.md', data: SKILL_MD }, { name: '__MACOSX/._SKILL.md', data: 'junk' }, ]); await expect(service.installFromZip(zip)).rejects.toThrow(/__MACOSX/); }); it('拒绝白名单外的文件类型', async () => { const zip = makeZip([ { name: 'SKILL.md', data: SKILL_MD }, { name: 'scripts/payload.exe', data: 'MZ' }, ]); await expect(service.installFromZip(zip)).rejects.toThrow(/不允许的文件类型/); }); it('拒绝符号链接条目', async () => { const zip = makeZip([ { name: 'SKILL.md', data: SKILL_MD }, { name: 'link.md', data: '../../../etc/passwd', mode: 0o120777 }, ]); await expect(service.installFromZip(zip)).rejects.toThrow(/符号链接/); }); it('拒绝超过单文件 2 MiB 的条目', async () => { const zip = makeZip([ { name: 'SKILL.md', data: SKILL_MD }, { name: 'assets/big.md', data: Buffer.alloc(2 * 1024 * 1024 + 10, 0x61) }, ]); await expect(service.installFromZip(zip)).rejects.toThrow(/2 MiB/); }); it('zip 内没有 SKILL.md 时拒绝', async () => { await expect(service.installFromZip(makeZip([{ name: 'notes.md', data: 'x' }]))).rejects.toThrow( /SKILL\.md/, ); }); it('空 zip 被拒绝', async () => { await expect(service.installFromZip(makeZip([]))).rejects.toThrow(/没有可用文件/); }); }); describe('remove', () => { it('删除用户 skill', async () => { const source = await writeSkillSource(join(root, 'src')); await service.installFromFolder(source); await service.remove({ name: 'demo-skill' }); expect(await stat(join(skillsDir, 'demo-skill')).catch(() => null)).toBeNull(); }); it('按 UI 回传的 SKILL.md 路径删除整个 skill 目录', async () => { const source = await writeSkillSource(join(root, 'src')); await service.installFromFolder(source); await service.remove({ path: join(skillsDir, 'demo-skill', 'SKILL.md') }); expect(await stat(join(skillsDir, 'demo-skill')).catch(() => null)).toBeNull(); }); it('拒绝删除内置目录', async () => { const systemDir = join(skillsDir, '.system', 'imagegen'); await mkdir(systemDir, { recursive: true }); await writeFile(join(systemDir, 'SKILL.md'), SKILL_MD, 'utf8'); await expect(service.remove({ path: systemDir })).rejects.toThrow(/内置或暂存目录/); expect((await stat(systemDir)).isDirectory()).toBe(true); }); it('拒绝越界路径与多级路径', async () => { await expect(service.remove({ path: root })).rejects.toThrow(/skills 目录/); await expect(service.remove({ path: join(skillsDir, 'a', 'b') })).rejects.toThrow(/一级子目录/); await expect(service.remove({})).rejects.toThrow(/path 或 name/); }); it('删除后清掉 config 里的残留条目(path 与 name 两种形态都要清)', async () => { const source = await writeSkillSource(join(root, 'src')); const runtimeWithConfig = makeRuntime(); const otherEntry = { path: join(skillsDir, 'other', 'SKILL.md'), enabled: true }; runtimeWithConfig.readConfig = vi.fn(async () => ({ skills: { config: [ { path: join(skillsDir, 'demo-skill', 'SKILL.md'), enabled: false }, { name: 'demo-skill', enabled: false }, otherEntry, ], }, })); runtimeWithConfig.writeConfigValue = vi.fn(async () => undefined); const svc = new SkillService(runtimeWithConfig, { skillsDir }); await svc.installFromFolder(source); await svc.remove({ name: 'demo-skill' }); // 不清残留的话,同名 skill 重新装回来会直接是禁用状态 expect(runtimeWithConfig.writeConfigValue).toHaveBeenCalledWith('skills.config', [otherEntry]); }); it('没有 skills.config 时不写配置', async () => { const source = await writeSkillSource(join(root, 'src')); const bareRuntime = makeRuntime(); bareRuntime.readConfig = vi.fn(async () => ({})); bareRuntime.writeConfigValue = vi.fn(async () => undefined); const svc = new SkillService(bareRuntime, { skillsDir }); await svc.installFromFolder(source); await svc.remove({ name: 'demo-skill' }); expect(bareRuntime.writeConfigValue).not.toHaveBeenCalled(); }); }); describe('list', () => { it('标记内置 skill 为不可管理', async () => { const svc = new SkillService( makeRuntime([ { name: 'imagegen', path: join(skillsDir, '.system', 'imagegen', 'SKILL.md') }, { name: 'demo-skill', path: join(skillsDir, 'demo-skill', 'SKILL.md') }, ]), { skillsDir }, ); const items = await svc.list(); expect(items.find((item) => item.name === 'imagegen')?.managed).toBe(false); expect(items.find((item) => item.name === 'demo-skill')?.managed).toBe(true); }); }); describe('setEnabled', () => { it('按 name 归一化后转发给原生 RPC', async () => { await expect(service.setEnabled({ name: 'Demo Skill' }, false)).resolves.toBe(true); expect(runtime.setSkillEnabled).toHaveBeenCalledWith({ name: 'demo-skill' }, false); }); }); describe('read', () => { it('返回 SKILL.md 正文与文件清单', async () => { const source = await writeSkillSource(join(root, 'src')); await writeFile(join(source, 'notes.md'), 'n', 'utf8'); await service.installFromFolder(source); const result = await service.read({ name: 'demo-skill' }); expect(result.content).toContain('demo-skill'); expect(result.files.map((file) => file.path).sort()).toEqual(['SKILL.md', 'notes.md']); }); it('内置 skill 可查(path 给目录或 SKILL.md 都认),但仍不可删改', async () => { const systemDir = join(skillsDir, '.system', 'imagegen'); await mkdir(systemDir, { recursive: true }); await writeFile(join(systemDir, 'SKILL.md'), SKILL_MD, 'utf8'); // Codex 的 skills/list 回的是 SKILL.md 文件路径,UI 原样回传 for (const path of [join(systemDir, 'SKILL.md'), systemDir]) { const result = await service.read({ path }); expect(result.dir).toBe(resolve(systemDir)); expect(result.content).toContain('demo-skill'); } await expect(service.remove({ path: join(systemDir, 'SKILL.md') })).rejects.toThrow(/内置或暂存目录/); }); it('越界路径与缺 SKILL.md 仍被拒绝', async () => { await expect(service.read({ path: root })).rejects.toThrow(/skills 目录/); await expect(service.read({ path: join(skillsDir, 'nope') })).rejects.toThrow(/SKILL\.md/); await expect(service.read({})).rejects.toThrow(/path 或 name/); }); }); describe('assertSafeRelativePath(第二道防线)', () => { it('放行正常相对路径并归一化分隔符', () => { expect(assertSafeRelativePath('references/a.md')).toBe('references/a.md'); expect(assertSafeRelativePath('references\\a.md')).toBe('references/a.md'); }); it.each([ ['../evil.md', /穿越/], ['docs/../../evil.md', /穿越/], ['..\\evil.md', /穿越/], ['/etc/passwd', /绝对路径/], ['//server/share/x.md', /绝对路径/], ['C:/Windows/evil.md', /绝对路径/], ['__MACOSX/._SKILL.md', /__MACOSX/], ['.staging-abc/SKILL.md', /非法/], ['', /非法/], ])('拒绝 %s', (input, expected) => { expect(() => assertSafeRelativePath(input)).toThrow(expected); }); });