import fs from 'node:fs/promises'; import { createWriteStream } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import AdmZip from 'adm-zip'; import { ZipArchive } from 'archiver'; import * as tar from 'tar'; import _7z from '7zip-min'; import { register } from './registry.js'; const MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024; export async function assertNoPathEscape(extractDir) { const resolvedRoot = await fs.realpath(extractDir); const entries = await fs.readdir(extractDir, { recursive: true, withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(entry.parentPath, entry.name); const real = entry.isSymbolicLink() ? await fs.realpath(fullPath) : fullPath; if (real !== resolvedRoot && !real.startsWith(resolvedRoot + path.sep)) { throw new Error('Archive entry escapes extraction directory'); } } } async function extractZip(inputPath, destDir) { const zip = new AdmZip(inputPath); let totalBytes = 0; for (const entry of zip.getEntries()) { const targetPath = path.join(destDir, entry.entryName); if (targetPath !== destDir && !targetPath.startsWith(destDir + path.sep)) { throw new Error('Archive entry escapes extraction directory'); } if (entry.isDirectory) { await fs.mkdir(targetPath, { recursive: true }); continue; } totalBytes += entry.header.size; if (totalBytes > MAX_EXTRACTED_BYTES) { throw new Error('Archive exceeds maximum extracted size'); } await fs.mkdir(path.dirname(targetPath), { recursive: true }); await fs.writeFile(targetPath, entry.getData()); } } function createZip(srcDir, outputPath, quality) { return new Promise((resolvePromise, reject) => { const output = createWriteStream(outputPath); const archive = new ZipArchive({ zlib: { level: quality ?? 6 } }); output.on('close', resolvePromise); archive.on('error', reject); archive.pipe(output); archive.directory(srcDir, false); archive.finalize(); }); } async function extractTarLike(inputPath, destDir) { // tar's extract auto-detects gzip compression from the file's magic bytes, // so the same function handles both plain .tar and .tar.gz input. await tar.extract({ file: inputPath, cwd: destDir }); } async function createTar(srcDir, outputPath) { const entries = await fs.readdir(srcDir); await tar.create({ file: outputPath, cwd: srcDir }, entries); } async function createTarGz(srcDir, outputPath, quality) { const entries = await fs.readdir(srcDir); await tar.create({ file: outputPath, cwd: srcDir, gzip: { level: quality ?? 6 } }, entries); } function bzip2Level(quality) { return Math.max(1, quality ?? 9); } async function extractTarBz2(inputPath, destDir) { const decompressDir = await fs.mkdtemp(path.join(os.tmpdir(), 'archive-bz2-')); try { await _7z.unpack(inputPath, decompressDir); const [tarName] = await fs.readdir(decompressDir); await tar.extract({ file: path.join(decompressDir, tarName), cwd: destDir }); } finally { await fs.rm(decompressDir, { recursive: true, force: true }); } } async function createTarBz2(srcDir, outputPath, quality) { const buildDir = await fs.mkdtemp(path.join(os.tmpdir(), 'archive-bz2-')); try { const tarPath = path.join(buildDir, 'archive.tar'); const entries = await fs.readdir(srcDir); await tar.create({ file: tarPath, cwd: srcDir }, entries); await _7z.cmd(['a', '-tbzip2', `-mx=${bzip2Level(quality)}`, outputPath, tarPath]); } finally { await fs.rm(buildDir, { recursive: true, force: true }); } } const EXTRACTORS = { zip: extractZip, tar: extractTarLike, 'tar.gz': extractTarLike, 'tar.bz2': extractTarBz2, }; const CREATORS = { zip: createZip, tar: createTar, 'tar.gz': createTarGz, 'tar.bz2': createTarBz2, }; async function convert(inputPath, outputPath, options, sourceFormat, targetFormat) { const { quality } = options ?? {}; const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'archive-convert-')); try { const extractDir = path.join(tmpDir, 'extracted'); await fs.mkdir(extractDir, { recursive: true }); await EXTRACTORS[sourceFormat](inputPath, extractDir); await assertNoPathEscape(extractDir); await CREATORS[targetFormat](extractDir, outputPath, quality); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } } export function registerArchiveConverters() { for (const sourceFormat of Object.keys(EXTRACTORS)) { for (const targetFormat of Object.keys(CREATORS)) { register({ family: 'archive', sourceFormat, targetFormat, convert: (inputPath, outputPath, options) => convert(inputPath, outputPath, options, sourceFormat, targetFormat), }); } } }