231 lines
8.9 KiB
JavaScript
231 lines
8.9 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 });
|
|
});
|
|
});
|
|
|
|
describe('archive converters — tar.bz2', () => {
|
|
it('registers tar.bz2 as a target for tar and vice versa', () => {
|
|
expect(listTargetFormats('tar')).toContain('tar.bz2');
|
|
expect(listTargetFormats('tar.bz2')).toContain('tar');
|
|
});
|
|
|
|
it(
|
|
'converts zip -> tar.bz2 -> tar, preserving content',
|
|
async () => {
|
|
const zipPath = path.join(tmpDir, 'for-bz2.zip');
|
|
buildZipFixture(zipPath);
|
|
|
|
const bz2Path = path.join(tmpDir, 'output.tar.bz2');
|
|
await resolve('zip', 'tar.bz2').convert(zipPath, bz2Path, { quality: 9 });
|
|
|
|
const backToTarPath = path.join(tmpDir, 'from-bz2.tar');
|
|
await resolve('tar.bz2', 'tar').convert(bz2Path, backToTarPath);
|
|
|
|
const listDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bz2-check-'));
|
|
await tar.extract({ file: backToTarPath, 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 });
|
|
},
|
|
20000
|
|
);
|
|
});
|
|
|
|
describe('archive converters — 7z and tar.7z', () => {
|
|
it('registers 7z and tar.7z as targets for each other and for zip', () => {
|
|
expect(listTargetFormats('zip')).toContain('7z');
|
|
expect(listTargetFormats('zip')).toContain('tar.7z');
|
|
expect(listTargetFormats('7z')).toContain('tar.7z');
|
|
});
|
|
|
|
it(
|
|
'converts zip -> 7z, preserving nested paths and content',
|
|
async () => {
|
|
const zipPath = path.join(tmpDir, 'for-7z.zip');
|
|
buildZipFixture(zipPath);
|
|
const sevenZPath = path.join(tmpDir, 'output.7z');
|
|
|
|
await resolve('zip', '7z').convert(zipPath, sevenZPath, { quality: 9 });
|
|
|
|
const listDir = await fs.mkdtemp(path.join(os.tmpdir(), '7z-check-'));
|
|
await resolve('7z', 'zip').convert(sevenZPath, path.join(listDir, 'roundtrip.zip'));
|
|
const rtZip = new AdmZip(path.join(listDir, 'roundtrip.zip'));
|
|
expect(rtZip.readAsText('hello.txt')).toBe('hello world');
|
|
expect(rtZip.readAsText('nested/inner.txt')).toBe('nested content');
|
|
await fs.rm(listDir, { recursive: true, force: true });
|
|
},
|
|
20000
|
|
);
|
|
|
|
it(
|
|
'converts zip -> tar.7z -> tar, preserving content',
|
|
async () => {
|
|
const zipPath = path.join(tmpDir, 'for-tar7z.zip');
|
|
buildZipFixture(zipPath);
|
|
const tar7zPath = path.join(tmpDir, 'output.tar.7z');
|
|
|
|
await resolve('zip', 'tar.7z').convert(zipPath, tar7zPath, { quality: 5 });
|
|
|
|
const backToTarPath = path.join(tmpDir, 'from-tar7z.tar');
|
|
await resolve('tar.7z', 'tar').convert(tar7zPath, backToTarPath);
|
|
|
|
const listDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tar7z-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 });
|
|
},
|
|
20000
|
|
);
|
|
|
|
it(
|
|
'produces a smaller-or-equal 7z at a higher compression level',
|
|
async () => {
|
|
const zipPath = path.join(tmpDir, 'for-7z-level.zip');
|
|
const zip = new AdmZip();
|
|
zip.addFile('big.txt', Buffer.from('ab'.repeat(100000)));
|
|
zip.writeZip(zipPath);
|
|
|
|
const lowPath = path.join(tmpDir, 'low.7z');
|
|
const highPath = path.join(tmpDir, 'high.7z');
|
|
|
|
await resolve('zip', '7z').convert(zipPath, lowPath, { quality: 0 });
|
|
await resolve('zip', '7z').convert(zipPath, highPath, { quality: 9 });
|
|
|
|
const [lowStat, highStat] = await Promise.all([fs.stat(lowPath), fs.stat(highPath)]);
|
|
expect(highStat.size).toBeLessThanOrEqual(lowStat.size);
|
|
},
|
|
20000
|
|
);
|
|
});
|