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 { 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(); }); } const EXTRACTORS = { zip: extractZip }; const CREATORS = { zip: createZip }; 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), }); } } }