124 lines
3.9 KiB
JavaScript
124 lines
3.9 KiB
JavaScript
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)
|
|
}))
|
|
}
|