Files
waggle-os/packages/marketplace/tests/multi-source.test.ts
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

294 lines
11 KiB
TypeScript

/**
* Multi-source skill resolver — unit tests (steal #11).
*
* Covers the ordered grammar (each accepted form + every rejected form),
* GitHub main→master fallback, SHA-256 enforcement, SSRF propagation (the
* injected guard's rejection must surface), and the zip-slip guard.
*
* No network + no adm-zip: the fetcher and the zip extractor are injected.
*/
import { describe, it, expect } from 'vitest';
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import {
resolveSkillSource,
classifySource,
isSafeZipEntry,
SkillSourceError,
type FetchFn,
type ZipEntry,
} from '../src/index';
// ── Fake responses ───────────────────────────────────────────────────
function textResponse(body: string, ok = true, status = ok ? 200 : 404): Response {
const bytes = new TextEncoder().encode(body);
return {
ok,
status,
statusText: ok ? 'OK' : 'Not Found',
async arrayBuffer() { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); },
async text() { return body; },
} as unknown as Response;
}
function binResponse(buf: Buffer, ok = true, status = 200): Response {
return {
ok,
status,
statusText: 'OK',
async arrayBuffer() { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); },
} as unknown as Response;
}
/** A fetcher that maps exact URLs → responses; unknown URLs 404. */
function fetcherFor(map: Record<string, Response>): FetchFn {
return async (url) => map[url] ?? textResponse('', false, 404);
}
const SKILL = `---
name: demo-skill
description: A demo skill for tests.
---
Do the thing.
`;
const runtimeRequire = createRequire(import.meta.url);
// ── Grammar classification ───────────────────────────────────────────
describe('classifySource', () => {
it('accepts a direct SKILL.md URL on a non-github host', () => {
expect(classifySource('https://example.com/path/SKILL.md')).toBe('skill-md-url');
expect(classifySource('https://raw.githubusercontent.com/o/r/main/SKILL.md')).toBe('skill-md-url');
});
it('accepts GitHub URLs', () => {
expect(classifySource('https://github.com/owner/repo')).toBe('github-url');
expect(classifySource('https://github.com/owner/repo/blob/main/SKILL.md')).toBe('github-url');
expect(classifySource('https://github.com/owner/repo/tree/main/skills/demo')).toBe('github-url');
});
it('accepts owner/repo[#subpath] shorthand', () => {
expect(classifySource('owner/repo')).toBe('owner-repo');
expect(classifySource('owner/repo#skills/demo')).toBe('owner-repo');
});
it('accepts a .zip URL', () => {
expect(classifySource('https://example.com/pkg.zip')).toBe('zip-url');
});
it('rejects local paths, git-ssh, tar, and arbitrary URLs', () => {
expect(classifySource('./local/SKILL.md')).toBeNull();
expect(classifySource('/etc/passwd')).toBeNull();
expect(classifySource('../up/SKILL.md')).toBeNull();
expect(classifySource('~/skills/SKILL.md')).toBeNull();
expect(classifySource('git@github.com:owner/repo.git')).toBeNull();
expect(classifySource('ssh://git@github.com/owner/repo')).toBeNull();
expect(classifySource('file:///etc/passwd')).toBeNull();
expect(classifySource('https://example.com/pkg.tar.gz')).toBeNull();
expect(classifySource('https://example.com/pkg.tgz')).toBeNull();
expect(classifySource('https://example.com/arbitrary')).toBeNull();
expect(classifySource('owner/repo/extra/segments')).toBeNull();
expect(classifySource('owner/repo#../escape')).toBeNull();
expect(classifySource('')).toBeNull();
});
});
// ── Resolution: markdown-yielding sources ────────────────────────────
describe('resolveSkillSource — markdown sources', () => {
it('resolves a direct SKILL.md URL', async () => {
const url = 'https://example.com/SKILL.md';
const res = await resolveSkillSource(url, { fetchImpl: fetcherFor({ [url]: textResponse(SKILL) }) });
expect(res.sourceType).toBe('skill-md-url');
expect(res.content).toContain('name: demo-skill');
expect(res.resolvedUrl).toBe(url);
});
it('resolves a GitHub blob URL to raw.githubusercontent.com', async () => {
const raw = 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md';
const res = await resolveSkillSource('https://github.com/owner/repo/blob/main/SKILL.md', {
fetchImpl: fetcherFor({ [raw]: textResponse(SKILL) }),
});
expect(res.resolvedUrl).toBe(raw);
expect(res.content).toContain('demo-skill');
});
it('resolves a GitHub tree URL by appending SKILL.md', async () => {
const raw = 'https://raw.githubusercontent.com/owner/repo/main/skills/demo/SKILL.md';
const res = await resolveSkillSource('https://github.com/owner/repo/tree/main/skills/demo', {
fetchImpl: fetcherFor({ [raw]: textResponse(SKILL) }),
});
expect(res.resolvedUrl).toBe(raw);
});
it('resolves owner/repo#subpath shorthand', async () => {
const raw = 'https://raw.githubusercontent.com/owner/repo/main/skills/demo/SKILL.md';
const res = await resolveSkillSource('owner/repo#skills/demo', {
fetchImpl: fetcherFor({ [raw]: textResponse(SKILL) }),
});
expect(res.sourceType).toBe('owner-repo');
expect(res.resolvedUrl).toBe(raw);
});
it('falls back from main to master for a bare repo', async () => {
const master = 'https://raw.githubusercontent.com/owner/repo/master/SKILL.md';
// main 404s, master succeeds
const res = await resolveSkillSource('owner/repo', {
fetchImpl: fetcherFor({ [master]: textResponse(SKILL) }),
});
expect(res.resolvedUrl).toBe(master);
});
it('throws when no candidate returns content', async () => {
await expect(
resolveSkillSource('owner/repo', { fetchImpl: fetcherFor({}) }),
).rejects.toThrow(SkillSourceError);
});
it('rejects an unsupported source', async () => {
await expect(resolveSkillSource('/etc/passwd')).rejects.toThrow(/Unsupported skill source/);
});
});
// ── SHA-256 enforcement ──────────────────────────────────────────────
describe('resolveSkillSource — sha256', () => {
const url = 'https://example.com/SKILL.md';
it('accepts a matching sha256', async () => {
const sha = createHash('sha256').update(SKILL, 'utf-8').digest('hex');
const res = await resolveSkillSource(url, {
sha256: sha,
fetchImpl: fetcherFor({ [url]: textResponse(SKILL) }),
});
expect(res.content).toContain('demo-skill');
});
it('hard-fails on a sha256 mismatch', async () => {
await expect(
resolveSkillSource(url, {
sha256: 'deadbeef'.repeat(8),
fetchImpl: fetcherFor({ [url]: textResponse(SKILL) }),
}),
).rejects.toThrow(/SHA-256 mismatch/);
});
});
// ── SSRF propagation ─────────────────────────────────────────────────
describe('resolveSkillSource — SSRF', () => {
it('propagates the injected guard rejection (private-IP URL blocked)', async () => {
const guardBlocked: FetchFn = async () => {
throw new Error('Blocked egress to private address 10.0.0.5');
};
await expect(
resolveSkillSource('https://internal.example.com/SKILL.md', { fetchImpl: guardBlocked }),
).rejects.toThrow(/Blocked egress/);
});
});
// ── Zip source + zip-slip guard ──────────────────────────────────────
function entry(name: string, data = SKILL, isDirectory = false): ZipEntry {
return { entryName: name, isDirectory, getData: () => Buffer.from(data, 'utf-8') };
}
describe('isSafeZipEntry', () => {
it('accepts normal nested paths', () => {
expect(isSafeZipEntry('SKILL.md')).toBe(true);
expect(isSafeZipEntry('skills/demo/SKILL.md')).toBe(true);
});
it('rejects traversal and absolute entries', () => {
expect(isSafeZipEntry('../SKILL.md')).toBe(false);
expect(isSafeZipEntry('a/../../etc/passwd')).toBe(false);
expect(isSafeZipEntry('/etc/passwd')).toBe(false);
expect(isSafeZipEntry('C:\\Windows\\system32')).toBe(false);
expect(isSafeZipEntry('')).toBe(false);
});
});
describe('resolveSkillSource — zip', () => {
const url = 'https://example.com/pkg.zip';
const zipBytes = Buffer.from('PK-fake-zip');
it('uses the patched bundled zip parser for a real archive', async () => {
const packageMeta = runtimeRequire('adm-zip/package.json') as { version: string };
const [major, minor] = packageMeta.version.split('.').map(Number);
expect(major > 0 || minor >= 6, `adm-zip ${packageMeta.version} includes CVE-2026-39244`).toBe(true);
const AdmZip = runtimeRequire('adm-zip') as new () => {
addFile(name: string, content: Buffer): void;
toBuffer(): Buffer;
};
const archive = new AdmZip();
archive.addFile('SKILL.md', Buffer.from(SKILL, 'utf-8'));
const realZip = archive.toBuffer();
const res = await resolveSkillSource(url, {
fetchImpl: fetcherFor({ [url]: binResponse(realZip) }),
});
expect(res.content).toContain('name: demo-skill');
});
it('extracts the shallowest SKILL.md from a zip', async () => {
const res = await resolveSkillSource(url, {
fetchImpl: fetcherFor({ [url]: binResponse(zipBytes) }),
zipExtractor: () => [
entry('nested/deep/SKILL.md', 'wrong'),
entry('SKILL.md', SKILL),
entry('README.md', 'ignored'),
],
});
expect(res.sourceType).toBe('zip-url');
expect(res.content).toContain('demo-skill');
});
it('rejects a zip with a traversal entry (zip-slip)', async () => {
await expect(
resolveSkillSource(url, {
fetchImpl: fetcherFor({ [url]: binResponse(zipBytes) }),
zipExtractor: () => [entry('../../evil.md'), entry('SKILL.md', SKILL)],
}),
).rejects.toThrow(/path traversal/);
});
it('rejects a zip with an absolute entry', async () => {
await expect(
resolveSkillSource(url, {
fetchImpl: fetcherFor({ [url]: binResponse(zipBytes) }),
zipExtractor: () => [entry('/etc/passwd'), entry('SKILL.md', SKILL)],
}),
).rejects.toThrow(/path traversal/);
});
it('skips junk entries and errors when no SKILL.md is present', async () => {
await expect(
resolveSkillSource(url, {
fetchImpl: fetcherFor({ [url]: binResponse(zipBytes) }),
zipExtractor: () => [entry('__MACOSX/SKILL.md'), entry('.DS_Store'), entry('README.md')],
}),
).rejects.toThrow(/No SKILL.md/);
});
it('enforces sha256 over the zip bytes', async () => {
const sha = createHash('sha256').update(zipBytes).digest('hex');
const ok = await resolveSkillSource(url, {
sha256: sha,
fetchImpl: fetcherFor({ [url]: binResponse(zipBytes) }),
zipExtractor: () => [entry('SKILL.md', SKILL)],
});
expect(ok.content).toContain('demo-skill');
await expect(
resolveSkillSource(url, {
sha256: 'ab'.repeat(32),
fetchImpl: fetcherFor({ [url]: binResponse(zipBytes) }),
zipExtractor: () => [entry('SKILL.md', SKILL)],
}),
).rejects.toThrow(/SHA-256 mismatch/);
});
});