skillService.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. import { mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
  2. import { tmpdir } from 'node:os';
  3. import { join, resolve } from 'node:path';
  4. import { crc32 } from 'node:zlib';
  5. import { afterEach, beforeEach, describe, expect, it, vi, type TestContext } from 'vitest';
  6. import {
  7. assertSafeRelativePath,
  8. normalizeSkillName,
  9. parseFrontmatter,
  10. SkillService,
  11. } from './skillService';
  12. const SKILL_MD = ['---', 'name: demo-skill', 'description: 一个用于测试的技能', '---', '', '# demo', ''].join('\n');
  13. interface RawZipEntry {
  14. name: string;
  15. data?: string | Buffer;
  16. /** Unix mode,写在 external file attributes 高 16 位;0o120777 即符号链接 */
  17. mode?: number;
  18. }
  19. /**
  20. * 手搓 stored(不压缩)zip。
  21. * 不能用 yazl:它自己就拒绝构造 `../`、绝对路径这类条目,而安全测试恰恰需要它们。
  22. */
  23. function makeZip(entries: RawZipEntry[]): Buffer {
  24. const locals: Buffer[] = [];
  25. const centrals: Buffer[] = [];
  26. let offset = 0;
  27. for (const entry of entries) {
  28. const isDir = entry.name.endsWith('/');
  29. const data = isDir
  30. ? Buffer.alloc(0)
  31. : Buffer.isBuffer(entry.data)
  32. ? entry.data
  33. : Buffer.from(entry.data ?? '', 'utf8');
  34. const nameBuf = Buffer.from(entry.name, 'utf8');
  35. const crc = isDir ? 0 : crc32(data);
  36. const mode = entry.mode ?? (isDir ? 0o40755 : 0o100644);
  37. const local = Buffer.alloc(30);
  38. local.writeUInt32LE(0x04034b50, 0);
  39. local.writeUInt16LE(20, 4);
  40. local.writeUInt16LE(0, 6);
  41. local.writeUInt16LE(0, 8); // method: stored
  42. local.writeUInt16LE(0, 10);
  43. local.writeUInt16LE(0x21, 12);
  44. local.writeUInt32LE(crc >>> 0, 14);
  45. local.writeUInt32LE(data.length, 18);
  46. local.writeUInt32LE(data.length, 22);
  47. local.writeUInt16LE(nameBuf.length, 26);
  48. local.writeUInt16LE(0, 28);
  49. locals.push(local, nameBuf, data);
  50. const central = Buffer.alloc(46);
  51. central.writeUInt32LE(0x02014b50, 0);
  52. central.writeUInt16LE((0x03 << 8) | 20, 4); // version made by: unix
  53. central.writeUInt16LE(20, 6);
  54. central.writeUInt16LE(0, 8);
  55. central.writeUInt16LE(0, 10);
  56. central.writeUInt16LE(0, 12);
  57. central.writeUInt16LE(0x21, 14);
  58. central.writeUInt32LE(crc >>> 0, 16);
  59. central.writeUInt32LE(data.length, 20);
  60. central.writeUInt32LE(data.length, 24);
  61. central.writeUInt16LE(nameBuf.length, 28);
  62. central.writeUInt16LE(0, 30);
  63. central.writeUInt16LE(0, 32);
  64. central.writeUInt16LE(0, 34);
  65. central.writeUInt16LE(0, 36);
  66. central.writeUInt32LE((mode << 16) >>> 0, 38);
  67. central.writeUInt32LE(offset, 42);
  68. centrals.push(central, nameBuf);
  69. offset += local.length + nameBuf.length + data.length;
  70. }
  71. const centralDir = Buffer.concat(centrals);
  72. const eocd = Buffer.alloc(22);
  73. eocd.writeUInt32LE(0x06054b50, 0);
  74. eocd.writeUInt16LE(entries.length, 8);
  75. eocd.writeUInt16LE(entries.length, 10);
  76. eocd.writeUInt32LE(centralDir.length, 12);
  77. eocd.writeUInt32LE(offset, 16);
  78. return Buffer.concat([...locals, centralDir, eocd]);
  79. }
  80. function makeRuntime(skills: Array<{ name: string; path: string }> = []) {
  81. return {
  82. listSkills: vi.fn().mockResolvedValue(
  83. skills.map((skill) => ({
  84. name: skill.name,
  85. description: 'd',
  86. path: skill.path,
  87. scope: 'user',
  88. enabled: true,
  89. cwd: '',
  90. })),
  91. ),
  92. setSkillEnabled: vi.fn().mockResolvedValue(true),
  93. readConfig: vi.fn(async () => ({}) as Record<string, unknown>),
  94. writeConfigValue: vi.fn(async () => undefined),
  95. };
  96. }
  97. let root: string;
  98. let skillsDir: string;
  99. let runtime: ReturnType<typeof makeRuntime>;
  100. let service: SkillService;
  101. beforeEach(async () => {
  102. root = await mkdtemp(join(tmpdir(), 'zsjz-skill-'));
  103. skillsDir = join(root, 'skills');
  104. await mkdir(skillsDir, { recursive: true });
  105. runtime = makeRuntime();
  106. service = new SkillService(runtime, { skillsDir });
  107. });
  108. afterEach(async () => {
  109. await rm(root, { recursive: true, force: true });
  110. });
  111. async function writeSkillSource(dir: string, content = SKILL_MD): Promise<string> {
  112. await mkdir(dir, { recursive: true });
  113. await writeFile(join(dir, 'SKILL.md'), content, 'utf8');
  114. return dir;
  115. }
  116. describe('normalizeSkillName', () => {
  117. it('归一化为小写连字符命名', () => {
  118. expect(normalizeSkillName('My Skill_Name')).toBe('my-skill-name');
  119. expect(normalizeSkillName(' demo--skill ')).toBe('demo-skill');
  120. });
  121. it('拒绝空名与超长名', () => {
  122. expect(() => normalizeSkillName('***')).toThrow(/非法/);
  123. expect(() => normalizeSkillName('a'.repeat(65))).toThrow(/非法/);
  124. });
  125. });
  126. describe('parseFrontmatter', () => {
  127. it('读取 name 与 description', () => {
  128. expect(parseFrontmatter(SKILL_MD)).toEqual({ name: 'demo-skill', description: '一个用于测试的技能' });
  129. });
  130. it('支持引号与缺失字段', () => {
  131. expect(parseFrontmatter('---\nname: "quoted"\n---\n')).toEqual({ name: 'quoted', description: null });
  132. expect(parseFrontmatter('# 没有 frontmatter')).toEqual({ name: null, description: null });
  133. });
  134. });
  135. describe('installFromFolder', () => {
  136. it('按 frontmatter 的 name 落盘', async () => {
  137. const source = await writeSkillSource(join(root, 'src'));
  138. await service.installFromFolder(source);
  139. const installed = join(skillsDir, 'demo-skill', 'SKILL.md');
  140. expect((await stat(installed)).isFile()).toBe(true);
  141. expect(await readFile(installed, 'utf8')).toContain('demo-skill');
  142. expect(runtime.listSkills).toHaveBeenCalledWith({ forceReload: true });
  143. });
  144. it('缺 SKILL.md 时拒绝', async () => {
  145. const source = join(root, 'empty');
  146. await mkdir(source, { recursive: true });
  147. await writeFile(join(source, 'readme.md'), 'x', 'utf8');
  148. await expect(service.installFromFolder(source)).rejects.toThrow(/缺少 SKILL\.md/);
  149. });
  150. it('frontmatter 缺 description 时拒绝', async () => {
  151. const source = await writeSkillSource(join(root, 'no-desc'), '---\nname: no-desc\n---\n# x\n');
  152. await expect(service.installFromFolder(source)).rejects.toThrow(/缺少 description/);
  153. });
  154. it('重名需显式覆盖', async () => {
  155. const source = await writeSkillSource(join(root, 'src'));
  156. await service.installFromFolder(source);
  157. await expect(service.installFromFolder(source)).rejects.toThrow(/已存在/);
  158. await service.installFromFolder(source, { overwrite: true });
  159. expect((await stat(join(skillsDir, 'demo-skill', 'SKILL.md'))).isFile()).toBe(true);
  160. });
  161. it('安装失败时清掉暂存目录', async () => {
  162. const source = join(root, 'bad');
  163. await mkdir(source, { recursive: true });
  164. await writeFile(join(source, 'notes.md'), 'x', 'utf8');
  165. await expect(service.installFromFolder(source)).rejects.toThrow();
  166. expect(await readdir(skillsDir)).toEqual([]);
  167. });
  168. it('拒绝源目录里的链接(Windows 用 junction)', async (ctx: TestContext) => {
  169. const outside = join(root, 'outside');
  170. await mkdir(outside, { recursive: true });
  171. await writeFile(join(outside, 'secret.md'), 'secret', 'utf8');
  172. const source = await writeSkillSource(join(root, 'src'));
  173. try {
  174. await symlink(outside, join(source, 'link-dir'), 'junction');
  175. } catch {
  176. ctx.skip();
  177. return;
  178. }
  179. await expect(service.installFromFolder(source)).rejects.toThrow(/符号链接/);
  180. });
  181. });
  182. describe('installFromZip', () => {
  183. it('SKILL.md 在 zip 根目录时可直接安装', async () => {
  184. await service.installFromZip(makeZip([{ name: 'SKILL.md', data: SKILL_MD }]));
  185. expect((await stat(join(skillsDir, 'demo-skill', 'SKILL.md'))).isFile()).toBe(true);
  186. });
  187. it('带一层外壳目录时安装内层', async () => {
  188. await service.installFromZip(
  189. makeZip([
  190. { name: 'wrapper/SKILL.md', data: SKILL_MD },
  191. { name: 'wrapper/references/a.md', data: 'ref' },
  192. ]),
  193. );
  194. expect((await stat(join(skillsDir, 'demo-skill', 'SKILL.md'))).isFile()).toBe(true);
  195. expect((await stat(join(skillsDir, 'demo-skill', 'references', 'a.md'))).isFile()).toBe(true);
  196. expect(await stat(join(skillsDir, 'wrapper')).catch(() => null)).toBeNull();
  197. });
  198. // 下面几例里 yauzl 会先拦下非法条目名,我方 assertSafeRelativePath 是第二道防线(单独测)
  199. it('拒绝 ../ 路径穿越条目,且不会有文件逃逸', async () => {
  200. const zip = makeZip([
  201. { name: 'SKILL.md', data: SKILL_MD },
  202. { name: '../evil.md', data: 'pwned' },
  203. ]);
  204. await expect(service.installFromZip(zip)).rejects.toThrow();
  205. expect(await stat(join(root, 'evil.md')).catch(() => null)).toBeNull();
  206. expect(await readdir(skillsDir)).toEqual([]);
  207. });
  208. it('拒绝嵌套的 ../ 穿越条目', async () => {
  209. const zip = makeZip([
  210. { name: 'SKILL.md', data: SKILL_MD },
  211. { name: 'docs/../../evil.md', data: 'pwned' },
  212. ]);
  213. await expect(service.installFromZip(zip)).rejects.toThrow();
  214. expect(await stat(join(root, 'evil.md')).catch(() => null)).toBeNull();
  215. });
  216. it('拒绝反斜杠形式的穿越条目', async () => {
  217. const zip = makeZip([
  218. { name: 'SKILL.md', data: SKILL_MD },
  219. { name: '..\\evil.md', data: 'pwned' },
  220. ]);
  221. await expect(service.installFromZip(zip)).rejects.toThrow();
  222. });
  223. it('拒绝绝对路径条目', async () => {
  224. const zip = makeZip([
  225. { name: 'SKILL.md', data: SKILL_MD },
  226. { name: '/etc/passwd', data: 'root' },
  227. ]);
  228. await expect(service.installFromZip(zip)).rejects.toThrow();
  229. });
  230. it('拒绝 Windows 盘符绝对路径条目', async () => {
  231. const zip = makeZip([
  232. { name: 'SKILL.md', data: SKILL_MD },
  233. { name: 'C:/Windows/evil.md', data: 'x' },
  234. ]);
  235. await expect(service.installFromZip(zip)).rejects.toThrow();
  236. });
  237. it('拒绝 __MACOSX 元数据', async () => {
  238. const zip = makeZip([
  239. { name: 'SKILL.md', data: SKILL_MD },
  240. { name: '__MACOSX/._SKILL.md', data: 'junk' },
  241. ]);
  242. await expect(service.installFromZip(zip)).rejects.toThrow(/__MACOSX/);
  243. });
  244. it('拒绝白名单外的文件类型', async () => {
  245. const zip = makeZip([
  246. { name: 'SKILL.md', data: SKILL_MD },
  247. { name: 'scripts/payload.exe', data: 'MZ' },
  248. ]);
  249. await expect(service.installFromZip(zip)).rejects.toThrow(/不允许的文件类型/);
  250. });
  251. it('拒绝符号链接条目', async () => {
  252. const zip = makeZip([
  253. { name: 'SKILL.md', data: SKILL_MD },
  254. { name: 'link.md', data: '../../../etc/passwd', mode: 0o120777 },
  255. ]);
  256. await expect(service.installFromZip(zip)).rejects.toThrow(/符号链接/);
  257. });
  258. it('拒绝超过单文件 2 MiB 的条目', async () => {
  259. const zip = makeZip([
  260. { name: 'SKILL.md', data: SKILL_MD },
  261. { name: 'assets/big.md', data: Buffer.alloc(2 * 1024 * 1024 + 10, 0x61) },
  262. ]);
  263. await expect(service.installFromZip(zip)).rejects.toThrow(/2 MiB/);
  264. });
  265. it('zip 内没有 SKILL.md 时拒绝', async () => {
  266. await expect(service.installFromZip(makeZip([{ name: 'notes.md', data: 'x' }]))).rejects.toThrow(
  267. /SKILL\.md/,
  268. );
  269. });
  270. it('空 zip 被拒绝', async () => {
  271. await expect(service.installFromZip(makeZip([]))).rejects.toThrow(/没有可用文件/);
  272. });
  273. });
  274. describe('remove', () => {
  275. it('删除用户 skill', async () => {
  276. const source = await writeSkillSource(join(root, 'src'));
  277. await service.installFromFolder(source);
  278. await service.remove({ name: 'demo-skill' });
  279. expect(await stat(join(skillsDir, 'demo-skill')).catch(() => null)).toBeNull();
  280. });
  281. it('按 UI 回传的 SKILL.md 路径删除整个 skill 目录', async () => {
  282. const source = await writeSkillSource(join(root, 'src'));
  283. await service.installFromFolder(source);
  284. await service.remove({ path: join(skillsDir, 'demo-skill', 'SKILL.md') });
  285. expect(await stat(join(skillsDir, 'demo-skill')).catch(() => null)).toBeNull();
  286. });
  287. it('拒绝删除内置目录', async () => {
  288. const systemDir = join(skillsDir, '.system', 'imagegen');
  289. await mkdir(systemDir, { recursive: true });
  290. await writeFile(join(systemDir, 'SKILL.md'), SKILL_MD, 'utf8');
  291. await expect(service.remove({ path: systemDir })).rejects.toThrow(/内置或暂存目录/);
  292. expect((await stat(systemDir)).isDirectory()).toBe(true);
  293. });
  294. it('拒绝越界路径与多级路径', async () => {
  295. await expect(service.remove({ path: root })).rejects.toThrow(/skills 目录/);
  296. await expect(service.remove({ path: join(skillsDir, 'a', 'b') })).rejects.toThrow(/一级子目录/);
  297. await expect(service.remove({})).rejects.toThrow(/path 或 name/);
  298. });
  299. it('删除后清掉 config 里的残留条目(path 与 name 两种形态都要清)', async () => {
  300. const source = await writeSkillSource(join(root, 'src'));
  301. const runtimeWithConfig = makeRuntime();
  302. const otherEntry = { path: join(skillsDir, 'other', 'SKILL.md'), enabled: true };
  303. runtimeWithConfig.readConfig = vi.fn(async () => ({
  304. skills: {
  305. config: [
  306. { path: join(skillsDir, 'demo-skill', 'SKILL.md'), enabled: false },
  307. { name: 'demo-skill', enabled: false },
  308. otherEntry,
  309. ],
  310. },
  311. }));
  312. runtimeWithConfig.writeConfigValue = vi.fn(async () => undefined);
  313. const svc = new SkillService(runtimeWithConfig, { skillsDir });
  314. await svc.installFromFolder(source);
  315. await svc.remove({ name: 'demo-skill' });
  316. // 不清残留的话,同名 skill 重新装回来会直接是禁用状态
  317. expect(runtimeWithConfig.writeConfigValue).toHaveBeenCalledWith('skills.config', [otherEntry]);
  318. });
  319. it('没有 skills.config 时不写配置', async () => {
  320. const source = await writeSkillSource(join(root, 'src'));
  321. const bareRuntime = makeRuntime();
  322. bareRuntime.readConfig = vi.fn(async () => ({}));
  323. bareRuntime.writeConfigValue = vi.fn(async () => undefined);
  324. const svc = new SkillService(bareRuntime, { skillsDir });
  325. await svc.installFromFolder(source);
  326. await svc.remove({ name: 'demo-skill' });
  327. expect(bareRuntime.writeConfigValue).not.toHaveBeenCalled();
  328. });
  329. });
  330. describe('list', () => {
  331. it('标记内置 skill 为不可管理', async () => {
  332. const svc = new SkillService(
  333. makeRuntime([
  334. { name: 'imagegen', path: join(skillsDir, '.system', 'imagegen', 'SKILL.md') },
  335. { name: 'demo-skill', path: join(skillsDir, 'demo-skill', 'SKILL.md') },
  336. ]),
  337. { skillsDir },
  338. );
  339. const items = await svc.list();
  340. expect(items.find((item) => item.name === 'imagegen')?.managed).toBe(false);
  341. expect(items.find((item) => item.name === 'demo-skill')?.managed).toBe(true);
  342. });
  343. });
  344. describe('setEnabled', () => {
  345. it('按 name 归一化后转发给原生 RPC', async () => {
  346. await expect(service.setEnabled({ name: 'Demo Skill' }, false)).resolves.toBe(true);
  347. expect(runtime.setSkillEnabled).toHaveBeenCalledWith({ name: 'demo-skill' }, false);
  348. });
  349. });
  350. describe('read', () => {
  351. it('返回 SKILL.md 正文与文件清单', async () => {
  352. const source = await writeSkillSource(join(root, 'src'));
  353. await writeFile(join(source, 'notes.md'), 'n', 'utf8');
  354. await service.installFromFolder(source);
  355. const result = await service.read({ name: 'demo-skill' });
  356. expect(result.content).toContain('demo-skill');
  357. expect(result.files.map((file) => file.path).sort()).toEqual(['SKILL.md', 'notes.md']);
  358. });
  359. it('内置 skill 可查(path 给目录或 SKILL.md 都认),但仍不可删改', async () => {
  360. const systemDir = join(skillsDir, '.system', 'imagegen');
  361. await mkdir(systemDir, { recursive: true });
  362. await writeFile(join(systemDir, 'SKILL.md'), SKILL_MD, 'utf8');
  363. // Codex 的 skills/list 回的是 SKILL.md 文件路径,UI 原样回传
  364. for (const path of [join(systemDir, 'SKILL.md'), systemDir]) {
  365. const result = await service.read({ path });
  366. expect(result.dir).toBe(resolve(systemDir));
  367. expect(result.content).toContain('demo-skill');
  368. }
  369. await expect(service.remove({ path: join(systemDir, 'SKILL.md') })).rejects.toThrow(/内置或暂存目录/);
  370. });
  371. it('越界路径与缺 SKILL.md 仍被拒绝', async () => {
  372. await expect(service.read({ path: root })).rejects.toThrow(/skills 目录/);
  373. await expect(service.read({ path: join(skillsDir, 'nope') })).rejects.toThrow(/SKILL\.md/);
  374. await expect(service.read({})).rejects.toThrow(/path 或 name/);
  375. });
  376. });
  377. describe('assertSafeRelativePath(第二道防线)', () => {
  378. it('放行正常相对路径并归一化分隔符', () => {
  379. expect(assertSafeRelativePath('references/a.md')).toBe('references/a.md');
  380. expect(assertSafeRelativePath('references\\a.md')).toBe('references/a.md');
  381. });
  382. it.each([
  383. ['../evil.md', /穿越/],
  384. ['docs/../../evil.md', /穿越/],
  385. ['..\\evil.md', /穿越/],
  386. ['/etc/passwd', /绝对路径/],
  387. ['//server/share/x.md', /绝对路径/],
  388. ['C:/Windows/evil.md', /绝对路径/],
  389. ['__MACOSX/._SKILL.md', /__MACOSX/],
  390. ['.staging-abc/SKILL.md', /非法/],
  391. ['', /非法/],
  392. ])('拒绝 %s', (input, expected) => {
  393. expect(() => assertSafeRelativePath(input)).toThrow(expected);
  394. });
  395. });