import * as fsp from 'fs/promises'; import * as path from 'path'; export interface ZipArchiveEntry { data: Buffer; path: string; } interface CentralDirectoryEntry { compressedSize: number; crc: number; data: Buffer; localHeaderOffset: number; name: Buffer; uncompressedSize: number; } const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50; const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50; const ZIP_UTF8_FLAG = 0x0800; const ZIP_STORE_METHOD = 0; const ZIP_VERSION = 20; const MAX_UINT32 = 0xffffffff; const crcTable = buildCrcTable(); export function createZipArchive(entries: ZipArchiveEntry[]): Buffer { const localParts: Buffer[] = []; const centralEntries: CentralDirectoryEntry[] = []; let offset = 0; for (const entry of entries) { const normalizedPath = normalizeZipPath(entry.path); const name = Buffer.from(normalizedPath, 'utf8'); const data = entry.data; if (name.length > 0xffff || data.length > MAX_UINT32 || offset > MAX_UINT32) { throw new Error('Data archive is too large for the portable ZIP format.'); } const crc = crc32(data); const localHeader = Buffer.alloc(30); localHeader.writeUInt32LE(ZIP_LOCAL_FILE_HEADER_SIGNATURE, 0); localHeader.writeUInt16LE(ZIP_VERSION, 4); localHeader.writeUInt16LE(ZIP_UTF8_FLAG, 6); localHeader.writeUInt16LE(ZIP_STORE_METHOD, 8); localHeader.writeUInt16LE(0, 10); localHeader.writeUInt16LE(0, 12); localHeader.writeUInt32LE(crc, 14); localHeader.writeUInt32LE(data.length, 18); localHeader.writeUInt32LE(data.length, 22); localHeader.writeUInt16LE(name.length, 26); localHeader.writeUInt16LE(0, 28); localParts.push(localHeader, name, data); centralEntries.push({ compressedSize: data.length, crc, data, localHeaderOffset: offset, name, uncompressedSize: data.length }); offset += localHeader.length + name.length + data.length; } const centralDirectoryOffset = offset; const centralParts = centralEntries.map((entry) => { const header = Buffer.alloc(46); header.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_SIGNATURE, 0); header.writeUInt16LE(ZIP_VERSION, 4); header.writeUInt16LE(ZIP_VERSION, 6); header.writeUInt16LE(ZIP_UTF8_FLAG, 8); header.writeUInt16LE(ZIP_STORE_METHOD, 10); header.writeUInt16LE(0, 12); header.writeUInt16LE(0, 14); header.writeUInt32LE(entry.crc, 16); header.writeUInt32LE(entry.compressedSize, 20); header.writeUInt32LE(entry.uncompressedSize, 24); header.writeUInt16LE(entry.name.length, 28); header.writeUInt16LE(0, 30); header.writeUInt16LE(0, 32); header.writeUInt16LE(0, 34); header.writeUInt16LE(0, 36); header.writeUInt32LE(0, 38); header.writeUInt32LE(entry.localHeaderOffset, 42); offset += header.length + entry.name.length; return Buffer.concat([header, entry.name]); }); const centralDirectorySize = offset - centralDirectoryOffset; if (centralEntries.length > 0xffff || centralDirectoryOffset > MAX_UINT32 || centralDirectorySize > MAX_UINT32) { throw new Error('Data archive is too large for the portable ZIP format.'); } const end = Buffer.alloc(22); end.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE, 0); end.writeUInt16LE(0, 4); end.writeUInt16LE(0, 6); end.writeUInt16LE(centralEntries.length, 8); end.writeUInt16LE(centralEntries.length, 10); end.writeUInt32LE(centralDirectorySize, 12); end.writeUInt32LE(centralDirectoryOffset, 16); end.writeUInt16LE(0, 20); return Buffer.concat([...localParts, ...centralParts, end]); } export function readZipArchive(data: Buffer): ZipArchiveEntry[] { const endOffset = findEndOfCentralDirectory(data); if (endOffset < 0) { throw new Error('The selected file is not a supported data archive.'); } const entryCount = data.readUInt16LE(endOffset + 10); const centralDirectoryOffset = data.readUInt32LE(endOffset + 16); const entries: ZipArchiveEntry[] = []; let offset = centralDirectoryOffset; for (let index = 0; index < entryCount; index += 1) { if (data.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) { throw new Error('The data archive directory is invalid.'); } const method = data.readUInt16LE(offset + 10); const compressedSize = data.readUInt32LE(offset + 20); const uncompressedSize = data.readUInt32LE(offset + 24); const nameLength = data.readUInt16LE(offset + 28); const extraLength = data.readUInt16LE(offset + 30); const commentLength = data.readUInt16LE(offset + 32); const localHeaderOffset = data.readUInt32LE(offset + 42); const entryPath = normalizeZipPath(data.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')); if (method !== ZIP_STORE_METHOD || compressedSize !== uncompressedSize) { throw new Error('Compressed data archives are not supported by this build.'); } if (data.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) { throw new Error('The data archive contains an invalid file entry.'); } const localNameLength = data.readUInt16LE(localHeaderOffset + 26); const localExtraLength = data.readUInt16LE(localHeaderOffset + 28); const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength; entries.push({ data: Buffer.from(data.subarray(dataOffset, dataOffset + compressedSize)), path: entryPath }); offset += 46 + nameLength + extraLength + commentLength; } return entries; } export async function extractZipEntries(entries: ZipArchiveEntry[], destinationPath: string): Promise { const destinationRoot = path.resolve(destinationPath); for (const entry of entries) { const targetPath = path.resolve(destinationRoot, entry.path); if (!targetPath.startsWith(destinationRoot + path.sep) && targetPath !== destinationRoot) { throw new Error('The data archive contains an unsafe path.'); } await fsp.mkdir(path.dirname(targetPath), { recursive: true }); await fsp.writeFile(targetPath, entry.data); } } function findEndOfCentralDirectory(data: Buffer): number { const minimumOffset = Math.max(0, data.length - 0xffff - 22); for (let offset = data.length - 22; offset >= minimumOffset; offset -= 1) { if (data.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) { return offset; } } return -1; } function normalizeZipPath(value: string): string { const normalized = value.replace(/\\/g, '/').replace(/^\/+/, ''); if (!normalized || normalized.split('/').some((part) => part === '..' || part === '')) { throw new Error('The data archive contains an unsafe path.'); } return normalized; } function buildCrcTable(): number[] { const table: number[] = []; for (let index = 0; index < 256; index += 1) { let value = index; for (let bit = 0; bit < 8; bit += 1) { value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; } table[index] = value >>> 0; } return table; } function crc32(data: Buffer): number { let crc = 0xffffffff; for (const byte of data) { crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); } return (crc ^ 0xffffffff) >>> 0; }