import { inflateRawSync } from 'node:zlib' /** * ZIP 엔트리 경로를 안전하게 정리한다. * @param {string} value - ZIP 내부 경로 * @returns {string} 정리된 경로 */ const normalizeZipEntryPath = (value) => String(value || '') .replace(/\\/g, '/') .replace(/^\/+/g, '') .split('/') .filter((part) => part && part !== '.' && part !== '..') .join('/') /** * ZIP 종료 레코드 위치를 찾는다. * @param {Buffer} buffer - ZIP 파일 버퍼 * @returns {number} 종료 레코드 위치 */ const findEndOfCentralDirectoryOffset = (buffer) => { const signature = 0x06054b50 const minOffset = Math.max(0, buffer.length - 65557) for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) { if (buffer.readUInt32LE(offset) === signature) { return offset } } return -1 } /** * ZIP 엔트리 데이터를 압축 해제한다. * @param {Buffer} buffer - 전체 ZIP 버퍼 * @param {Object} entry - 중앙 디렉터리 엔트리 * @returns {Buffer} 압축 해제된 데이터 */ const readZipEntryData = (buffer, entry) => { const localHeaderOffset = entry.localHeaderOffset if (buffer.readUInt32LE(localHeaderOffset) !== 0x04034b50) { throw new Error('INVALID_ZIP_LOCAL_HEADER') } const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26) const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28) const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength const compressedData = buffer.subarray(dataOffset, dataOffset + entry.compressedSize) if (entry.compressionMethod === 0) { return Buffer.from(compressedData) } if (entry.compressionMethod === 8) { return inflateRawSync(compressedData) } throw new Error('UNSUPPORTED_ZIP_COMPRESSION') } /** * ZIP 파일 버퍼를 파일 엔트리 목록으로 읽는다. * @param {Buffer} buffer - ZIP 파일 버퍼 * @returns {Array<{ path: string, data: Buffer }>} 파일 엔트리 목록 */ export const readZipBufferEntries = (buffer) => { if (!Buffer.isBuffer(buffer) || buffer.length < 22) { throw new Error('INVALID_ZIP_FILE') } const endOffset = findEndOfCentralDirectoryOffset(buffer) if (endOffset < 0) { throw new Error('INVALID_ZIP_FILE') } const entryCount = buffer.readUInt16LE(endOffset + 10) const centralDirectorySize = buffer.readUInt32LE(endOffset + 12) const centralDirectoryOffset = buffer.readUInt32LE(endOffset + 16) const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize if (centralDirectoryOffset < 0 || centralDirectoryEnd > buffer.length) { throw new Error('INVALID_ZIP_FILE') } const entries = [] let offset = centralDirectoryOffset for (let index = 0; index < entryCount; index += 1) { if (buffer.readUInt32LE(offset) !== 0x02014b50) { throw new Error('INVALID_ZIP_CENTRAL_DIRECTORY') } const compressionMethod = buffer.readUInt16LE(offset + 10) const compressedSize = buffer.readUInt32LE(offset + 20) const uncompressedSize = buffer.readUInt32LE(offset + 24) const nameLength = buffer.readUInt16LE(offset + 28) const extraLength = buffer.readUInt16LE(offset + 30) const commentLength = buffer.readUInt16LE(offset + 32) const localHeaderOffset = buffer.readUInt32LE(offset + 42) const rawPath = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8') const normalizedPath = normalizeZipEntryPath(rawPath) const isDirectory = rawPath.replace(/\\/g, '/').endsWith('/') if (normalizedPath && !isDirectory && !normalizedPath.startsWith('__MACOSX/')) { entries.push({ path: normalizedPath, compressionMethod, compressedSize, uncompressedSize, localHeaderOffset }) } offset += 46 + nameLength + extraLength + commentLength } return entries.map((entry) => ({ path: entry.path, data: readZipEntryData(buffer, entry) })) }