| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452 |
- 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<string, unknown>),
- writeConfigValue: vi.fn(async () => undefined),
- };
- }
- let root: string;
- let skillsDir: string;
- let runtime: ReturnType<typeof makeRuntime>;
- 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<string> {
- 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);
- });
- });
|