136 lines
5.3 KiB
JavaScript
136 lines
5.3 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 * as tar from 'tar';
|
|
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/);
|
|
});
|
|
});
|
|
|
|
describe('archive converters — tar / tar.gz', () => {
|
|
it('registers tar and tar.gz as targets for each other', () => {
|
|
expect(listTargetFormats('tar')).toContain('tar.gz');
|
|
expect(listTargetFormats('tar.gz')).toContain('tar');
|
|
});
|
|
|
|
it('converts zip -> tar, preserving nested paths and content', async () => {
|
|
const inputPath = path.join(tmpDir, 'for-tar.zip');
|
|
buildZipFixture(inputPath);
|
|
const outputPath = path.join(tmpDir, 'output.tar');
|
|
const entry = resolve('zip', 'tar');
|
|
|
|
await entry.convert(inputPath, outputPath);
|
|
|
|
const listDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tar-check-'));
|
|
await tar.extract({ file: outputPath, cwd: listDir });
|
|
expect(await fs.readFile(path.join(listDir, 'hello.txt'), 'utf8')).toBe('hello world');
|
|
expect(await fs.readFile(path.join(listDir, 'nested', 'inner.txt'), 'utf8')).toBe('nested content');
|
|
await fs.rm(listDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('converts tar -> tar.gz and back to tar, preserving content', async () => {
|
|
const tarPath = path.join(tmpDir, 'roundtrip.tar');
|
|
await tar.create({ file: tarPath, cwd: srcFixtureDir }, ['hello.txt', 'nested']);
|
|
|
|
const gzPath = path.join(tmpDir, 'roundtrip.tar.gz');
|
|
await resolve('tar', 'tar.gz').convert(tarPath, gzPath, { quality: 9 });
|
|
|
|
const backToTarPath = path.join(tmpDir, 'roundtrip-back.tar');
|
|
await resolve('tar.gz', 'tar').convert(gzPath, backToTarPath);
|
|
|
|
const listDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tar-gz-check-'));
|
|
await tar.extract({ file: backToTarPath, cwd: listDir });
|
|
expect(await fs.readFile(path.join(listDir, 'hello.txt'), 'utf8')).toBe('hello world');
|
|
await fs.rm(listDir, { recursive: true, force: true });
|
|
});
|
|
});
|