import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import AdmZip from 'adm-zip'; import { registerArchiveConverters, assertNoPathEscape } from '../../src/converters/archive.js'; import { resolve, listTargetFormats } from '../../src/converters/registry.js'; let tmpDir; let srcFixtureDir; beforeAll(async () => { registerArchiveConverters(); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-archive-')); srcFixtureDir = path.join(tmpDir, 'src-fixture'); await fs.mkdir(path.join(srcFixtureDir, 'nested'), { recursive: true }); await fs.writeFile(path.join(srcFixtureDir, 'hello.txt'), 'hello world'); await fs.writeFile(path.join(srcFixtureDir, 'nested', 'inner.txt'), 'nested content'); }); afterAll(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); function buildZipFixture(destPath) { const zip = new AdmZip(); zip.addFile('hello.txt', Buffer.from('hello world')); zip.addFile('nested/inner.txt', Buffer.from('nested content')); zip.writeZip(destPath); } describe('archive converters — zip', () => { it('registers zip -> zip (same-format pairs are allowed for this family)', () => { expect(listTargetFormats('zip')).toContain('zip'); }); it('round-trips a zip fixture through zip -> zip', async () => { const inputPath = path.join(tmpDir, 'fixture.zip'); buildZipFixture(inputPath); const outputPath = path.join(tmpDir, 'output.zip'); const entry = resolve('zip', 'zip'); await entry.convert(inputPath, outputPath, { quality: 9 }); const outZip = new AdmZip(outputPath); const names = outZip .getEntries() .filter((e) => !e.isDirectory) .map((e) => e.entryName) .sort(); expect(names).toEqual(['hello.txt', 'nested/inner.txt']); expect(outZip.readAsText('hello.txt')).toBe('hello world'); expect(outZip.readAsText('nested/inner.txt')).toBe('nested content'); }); it('produces a smaller-or-equal zip at a higher compression level', async () => { const inputPath = path.join(tmpDir, 'fixture-for-level.zip'); const zip = new AdmZip(); zip.addFile('big.txt', Buffer.from('ab'.repeat(100000))); zip.writeZip(inputPath); const lowPath = path.join(tmpDir, 'low.zip'); const highPath = path.join(tmpDir, 'high.zip'); const entry = resolve('zip', 'zip'); await entry.convert(inputPath, lowPath, { quality: 0 }); await entry.convert(inputPath, highPath, { quality: 9 }); const [lowStat, highStat] = await Promise.all([fs.stat(lowPath), fs.stat(highPath)]); expect(highStat.size).toBeLessThanOrEqual(lowStat.size); }); }); describe('assertNoPathEscape', () => { it('does not throw for a directory with only well-behaved entries', async () => { await expect(assertNoPathEscape(srcFixtureDir)).resolves.toBeUndefined(); }); }); describe('archive converters — zip-slip protection', () => { it('rejects a zip whose entry path escapes the extraction directory', async () => { const maliciousPath = path.join(tmpDir, 'evil.zip'); const zip = new AdmZip(); zip.addFile('evil.txt', Buffer.from('pwned')); // adm-zip sanitizes entryName on addFile(), stripping "../" — mutate the // entry directly afterward to build a genuinely malicious fixture. zip.getEntries()[0].entryName = '../evil.txt'; zip.writeZip(maliciousPath); const outputPath = path.join(tmpDir, 'should-not-exist.zip'); const entry = resolve('zip', 'zip'); await expect(entry.convert(maliciousPath, outputPath)).rejects.toThrow(/escapes/); }); });