Files
convert/test/converters/archive.test.js
T
anthonyandClaude Sonnet 5 d68694ead8 feat(archive): add zip converter with compression level and zip-slip protection
archiver v8 (installed) uses a class-based API (`ZipArchive`) rather than
the factory-function API most examples online still describe — adjusted
createZip() to match after the first test run surfaced the mismatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 12:49:46 +02:00

97 lines
3.6 KiB
JavaScript

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/);
});
});