import { unzipSync } from 'fflate'; import type { SkillFileEntry } from '$lib/api'; /** Mirror hub/src/agent/skillStore.ts limits (ADR-0018). */ const MAX_SKILL_FILES = 512; const MAX_SKILL_BYTES = 16 * 1024 * 1024; const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; export interface ParsedSkillZip { readonly name: string; readonly description: string | null; readonly files: readonly SkillFileEntry[]; } /** * Parse a skill package zip. Root of the archive IS the skill directory * (must contain SKILL.md directly — no wrapping folder). */ export function parseSkillZip(data: Uint8Array): ParsedSkillZip { let entries: Record; try { entries = unzipSync(data, { filter: (file) => !file.name.endsWith('/'), }); } catch { throw new Error('无法解析 zip 文件'); } const files: SkillFileEntry[] = []; let totalBytes = 0; for (const [rawPath, bytes] of Object.entries(entries)) { const path = normalizeZipPath(rawPath); if (path === null) continue; totalBytes += bytes.byteLength; if (totalBytes > MAX_SKILL_BYTES) { throw new Error(`技能总大小超过 ${MAX_SKILL_BYTES / (1024 * 1024)} MiB 上限`); } if (files.length >= MAX_SKILL_FILES) { throw new Error(`技能文件数超过 ${MAX_SKILL_FILES} 上限`); } // Text-only alpha path — API encodes content as UTF-8 strings. try { files.push({ path, content: decodeUtf8Strict(bytes) }); } catch { throw new Error(`文件不是合法 UTF-8 文本:${path}`); } } if (files.length === 0) { throw new Error('zip 为空或不含可用文件'); } const manifest = files.find((f) => f.path === 'SKILL.md'); if (!manifest) { throw new Error('zip 根目录必须包含 SKILL.md(根即 skill 目录,不要多包一层文件夹)'); } const name = parseFrontmatterField(manifest.content, 'name'); if (name === null || name === '') { throw new Error('SKILL.md frontmatter 缺少 name'); } if (!SKILL_NAME_PATTERN.test(name)) { throw new Error('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头'); } const description = parseFrontmatterField(manifest.content, 'description'); files.sort((a, b) => a.path.localeCompare(b.path)); return { name, description, files }; } function normalizeZipPath(raw: string): string | null { let path = raw.replace(/\\/g, '/'); // Drop zip noise and absolute/parent escapes before other checks. if (path.startsWith('__MACOSX/') || path.includes('/__MACOSX/')) return null; const base = path.split('/').pop() ?? ''; if (base === '.DS_Store' || base.startsWith('._')) return null; if (path.startsWith('/') || path.includes('\0')) { throw new Error(`非法文件路径:${raw}`); } // Strip a single leading "./" if (path.startsWith('./')) path = path.slice(2); const parts = path.split('/').filter((p) => p !== '' && p !== '.'); if (parts.length === 0 || parts.some((p) => p === '..')) { throw new Error(`非法文件路径:${raw}`); } return parts.join('/'); } function parseFrontmatterField(manifest: string, key: string): string | null { const match = new RegExp(`^${key}:\\s*['"]?([^'"\\r\\n]+)['"]?\\s*$`, 'm').exec(manifest); return match ? match[1]!.trim() : null; } function decodeUtf8Strict(bytes: Uint8Array): string { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); }