import { createReadStream, createWriteStream } from 'node:fs'; import { mkdir, readdir, stat } from 'node:fs/promises'; import path from 'node:path'; import { pipeline } from 'node:stream/promises'; import archiver from 'archiver'; import { canonicalExistingPath, canonicalPathsOverlap, canonicalProspectivePath, } from './path-safety.mjs'; const ARCHIVE_DATE = new Date('1980-01-01T00:00:00.000Z'); function compareCodePoints(left, right) { const leftPoints = Array.from(left, (value) => value.codePointAt(0)); const rightPoints = Array.from(right, (value) => value.codePointAt(0)); const length = Math.min(leftPoints.length, rightPoints.length); for (let index = 0; index < length; index += 1) { if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index]; } return leftPoints.length - rightPoints.length; } async function listFiles(root, relative = '') { const directory = path.join(root, relative); const entries = await readdir(directory, { withFileTypes: true }); const files = []; for (const entry of entries.sort((left, right) => compareCodePoints(left.name, right.name) )) { const child = path.posix.join( relative.split(path.sep).join('/'), entry.name ); if (entry.isDirectory()) files.push(...(await listFiles(root, child))); else if (entry.isFile()) files.push(child); else throw new Error( `Archive input contains an unsupported file type: ${child}` ); } return files; } export async function createStaticArchive(inputDirectory, outputFile) { const source = await canonicalExistingPath( inputDirectory, 'Archive input directory' ); const output = await canonicalProspectivePath(outputFile); if (canonicalPathsOverlap(source, output)) throw new Error('Archive output and input directory must not overlap.'); const sourceStat = await stat(source); if (!sourceStat.isDirectory()) throw new Error(`Archive input is not a directory: ${source}`); await mkdir(path.dirname(output), { recursive: true }); const archive = archiver('zip', { zlib: { level: 9 } }); const destination = createWriteStream(output, { flags: 'wx' }); archive.on('warning', (error) => { if (error.code !== 'ENOENT') destination.destroy(error); }); archive.on('error', (error) => destination.destroy(error)); const writing = pipeline(archive, destination); for (const relative of await listFiles(source)) { archive.append(createReadStream(path.join(source, relative)), { name: relative, date: ARCHIVE_DATE, mode: 0o644, }); } await archive.finalize(); await writing; return output; }