import { deflateRawSync } from 'node:zlib' const crcTable = Array.from({ length: 256 }, (_, index) => { let value = index for (let bit = 0; bit < 8; bit += 1) { value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1 } return value >>> 0 }) /** * CRC32 값을 계산한다. * @param {Buffer} input - 원본 데이터 * @returns {number} CRC32 */ const calculateCrc32 = (input) => { let crc = 0xffffffff for (const byte of input) { crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8) } return (crc ^ 0xffffffff) >>> 0 } /** * ZIP DOS 날짜/시간 값을 만든다. * @param {Date} date - 기준 날짜 * @returns {{ dosDate: number, dosTime: number }} DOS 날짜/시간 */ const createDosDateTime = (date) => { const year = Math.max(date.getFullYear(), 1980) return { dosTime: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2), dosDate: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate() } } /** * ZIP 엔트리 경로를 정리한다. * @param {string} pathValue - 엔트리 경로 * @returns {string} 정리된 경로 */ const normalizeZipPath = (pathValue) => String(pathValue || '') .replace(/\\/g, '/') .replace(/^\/+/g, '') .replace(/\.\.+/g, '.') /** * ZIP 로컬 파일 헤더를 만든다. * @param {Object} entry - 엔트리 정보 * @returns {Buffer} 로컬 파일 헤더 */ const createLocalFileHeader = (entry) => { const header = Buffer.alloc(30) header.writeUInt32LE(0x04034b50, 0) header.writeUInt16LE(20, 4) header.writeUInt16LE(0x0800, 6) header.writeUInt16LE(8, 8) header.writeUInt16LE(entry.dosTime, 10) header.writeUInt16LE(entry.dosDate, 12) header.writeUInt32LE(entry.crc32, 14) header.writeUInt32LE(entry.compressedSize, 18) header.writeUInt32LE(entry.uncompressedSize, 22) header.writeUInt16LE(entry.name.length, 26) header.writeUInt16LE(0, 28) return Buffer.concat([header, entry.name]) } /** * ZIP 중앙 디렉터리 헤더를 만든다. * @param {Object} entry - 엔트리 정보 * @returns {Buffer} 중앙 디렉터리 헤더 */ const createCentralDirectoryHeader = (entry) => { const header = Buffer.alloc(46) header.writeUInt32LE(0x02014b50, 0) header.writeUInt16LE(20, 4) header.writeUInt16LE(20, 6) header.writeUInt16LE(0x0800, 8) header.writeUInt16LE(8, 10) header.writeUInt16LE(entry.dosTime, 12) header.writeUInt16LE(entry.dosDate, 14) header.writeUInt32LE(entry.crc32, 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.offset, 42) return Buffer.concat([header, entry.name]) } /** * ZIP 종료 레코드를 만든다. * @param {number} entryCount - 엔트리 개수 * @param {number} centralDirectorySize - 중앙 디렉터리 크기 * @param {number} centralDirectoryOffset - 중앙 디렉터리 시작 위치 * @returns {Buffer} 종료 레코드 */ const createEndOfCentralDirectory = (entryCount, centralDirectorySize, centralDirectoryOffset) => { const endRecord = Buffer.alloc(22) endRecord.writeUInt32LE(0x06054b50, 0) endRecord.writeUInt16LE(0, 4) endRecord.writeUInt16LE(0, 6) endRecord.writeUInt16LE(entryCount, 8) endRecord.writeUInt16LE(entryCount, 10) endRecord.writeUInt32LE(centralDirectorySize, 12) endRecord.writeUInt32LE(centralDirectoryOffset, 16) endRecord.writeUInt16LE(0, 20) return endRecord } /** * 메모리에서 ZIP 파일 버퍼를 만든다. * @param {Array<{ path: string, data: Buffer | string }>} files - ZIP 파일 목록 * @returns {Buffer} ZIP 버퍼 */ export const createZipBuffer = (files) => { const localFileParts = [] const centralDirectoryParts = [] const entries = [] let offset = 0 for (const file of files) { const normalizedPath = normalizeZipPath(file.path) if (!normalizedPath) { continue } const rawData = Buffer.isBuffer(file.data) ? file.data : Buffer.from(String(file.data), 'utf8') const compressedData = deflateRawSync(rawData) const { dosDate, dosTime } = createDosDateTime(new Date()) const entry = { name: Buffer.from(normalizedPath, 'utf8'), crc32: calculateCrc32(rawData), compressedSize: compressedData.length, uncompressedSize: rawData.length, dosDate, dosTime, offset } const localHeader = createLocalFileHeader(entry) localFileParts.push(localHeader, compressedData) offset += localHeader.length + compressedData.length entries.push(entry) } for (const entry of entries) { centralDirectoryParts.push(createCentralDirectoryHeader(entry)) } const centralDirectory = Buffer.concat(centralDirectoryParts) const endRecord = createEndOfCentralDirectory(entries.length, centralDirectory.length, offset) return Buffer.concat([...localFileParts, centralDirectory, endRecord]) }