gongxiangp2/lib/zip-entry-reader.ts

384 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createReadStream } from 'fs'
import { mkdir, open, rm } from 'fs/promises'
import { randomUUID } from 'crypto'
import path from 'path'
import { pipeline } from 'stream/promises'
import { createInflateRaw } from 'zlib'
/**
* 任务-详情编辑用的流式 ZIP 读取器。
*
* 只依赖 Node 自带 zlib:读中央目录定位每个条目,再按条目把解压结果
* 流式写到磁盘。全程不把整个压缩包读进内存,历史素材的分组 ZIP
* (主图/SKU图/详情图)可能数百 MB 甚至更大。
*
* 只支持本系统自己打包出来的常规布局:
* - method 0(store)/ method 8(deflate)
* - UTF-8 文件名、数据描述符(flag bit 3)、ZIP64 扩展
* 不支持加密与其它压缩方法,遇到即报错。
*/
const EOCD_SIGNATURE = 0x06054b50
const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50
const ZIP64_EOCD_SIGNATURE = 0x06064b50
const CENTRAL_HEADER_SIGNATURE = 0x02014b50
const LOCAL_HEADER_SIGNATURE = 0x04034b50
export interface ZipEntryInfo {
/** 中央目录中的原始文件名 */
name: string
compressedSize: number
uncompressedSize: number
compressionMethod: number
localHeaderOffset: number
flags: number
}
interface ZipCentralDirectory {
entries: ZipEntryInfo[]
}
function readUInt16LE(buffer: Buffer, offset: number): number {
return buffer.readUInt16LE(offset)
}
function readUInt32LE(buffer: Buffer, offset: number): number {
return buffer.readUInt32LE(offset)
}
function readBigUInt64LE(buffer: Buffer, offset: number): number {
const value = buffer.readBigUInt64LE(offset)
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error('ZIP 内的偏移或大小超出安全范围')
}
return Number(value)
}
async function readSlice(
handle: Awaited<ReturnType<typeof open>>,
position: number,
length: number,
): Promise<Buffer> {
const buffer = Buffer.alloc(length)
const { bytesRead } = await handle.read(buffer, 0, length, position)
if (bytesRead !== length) {
throw new Error('压缩包已损坏:读取内容不完整')
}
return buffer
}
function findEocdSignature(tail: Buffer): number {
// EOCD 固定 22 字节起,注释最长 65535,从尾部向前扫签名。
for (let offset = tail.length - 22; offset >= 0; offset -= 1) {
if (readUInt32LE(tail, offset) === EOCD_SIGNATURE) {
return offset
}
}
throw new Error('文件不是有效的 ZIP 压缩包')
}
async function locateZip64Eocd(
handle: Awaited<ReturnType<typeof open>>,
eocdOffsetInTail: number,
tailStart: number,
fileSize: number,
): Promise<{ entryCount: number; cdSize: number; cdOffset: number } | null> {
if (eocdOffsetInTail < 20) {
return null
}
const locatorPosition = tailStart + eocdOffsetInTail - 20
if (locatorPosition < 0 || locatorPosition + 20 > fileSize) {
return null
}
const locator = await readSlice(handle, locatorPosition, 20)
if (readUInt32LE(locator, 0) !== ZIP64_EOCD_LOCATOR_SIGNATURE) {
return null
}
const zip64EocdOffset = readBigUInt64LE(locator, 8)
const zip64Eocd = await readSlice(handle, zip64EocdOffset, 56)
if (readUInt32LE(zip64Eocd, 0) !== ZIP64_EOCD_SIGNATURE) {
throw new Error('压缩包已损坏:ZIP64 目录定位失败')
}
const entryCount = readBigUInt64LE(zip64Eocd, 32)
const cdSize = readBigUInt64LE(zip64Eocd, 40)
const cdOffset = readBigUInt64LE(zip64Eocd, 48)
return { entryCount, cdSize, cdOffset }
}
async function readCentralDirectory(
zipPath: string,
): Promise<ZipCentralDirectory> {
const handle = await open(zipPath, 'r')
try {
const stats = await handle.stat()
if (!stats.isFile() || stats.size < 22) {
throw new Error('文件不是有效的 ZIP 压缩包')
}
const tailLength = Math.min(stats.size, 22 + 65535 + 20 + 56)
const tailStart = stats.size - tailLength
const tail = await readSlice(handle, tailStart, tailLength)
const eocdOffsetInTail = findEocdSignature(tail)
const eocdAbsolute = tailStart + eocdOffsetInTail
let entryCount = readUInt16LE(tail, eocdOffsetInTail + 10)
let cdSize = readUInt32LE(tail, eocdOffsetInTail + 12)
let cdOffset = readUInt32LE(tail, eocdOffsetInTail + 16)
if (
entryCount === 0xffff
|| cdSize === 0xffffffff
|| cdOffset === 0xffffffff
) {
const zip64 = await locateZip64Eocd(
handle,
eocdOffsetInTail,
tailStart,
stats.size,
)
if (!zip64) {
throw new Error('压缩包已损坏:ZIP64 目录缺失')
}
entryCount = zip64.entryCount
cdSize = zip64.cdSize
cdOffset = zip64.cdOffset
}
if (entryCount <= 0) {
return { entries: [] }
}
const directoryBuffer = await readSlice(handle, cdOffset, cdSize)
const entries: ZipEntryInfo[] = []
let cursor = 0
for (let index = 0; index < entryCount; index += 1) {
if (cursor + 46 > directoryBuffer.length) {
throw new Error('压缩包已损坏:中央目录不完整')
}
if (readUInt32LE(directoryBuffer, cursor) !== CENTRAL_HEADER_SIGNATURE) {
throw new Error('压缩包已损坏:中央目录条目签名异常')
}
const flags = readUInt16LE(directoryBuffer, cursor + 8)
const compressionMethod = readUInt16LE(directoryBuffer, cursor + 10)
let compressedSize = readUInt32LE(directoryBuffer, cursor + 20)
let uncompressedSize = readUInt32LE(directoryBuffer, cursor + 24)
const nameLength = readUInt16LE(directoryBuffer, cursor + 28)
const extraLength = readUInt16LE(directoryBuffer, cursor + 30)
const commentLength = readUInt16LE(directoryBuffer, cursor + 32)
let localHeaderOffset = readUInt32LE(directoryBuffer, cursor + 42)
const extraStart = cursor + 46 + nameLength
const extraEnd = extraStart + extraLength
if (extraEnd > directoryBuffer.length) {
throw new Error('压缩包已损坏:中央目录扩展字段不完整')
}
let extraCursor = extraStart
while (extraCursor + 4 <= extraEnd) {
const headerId = readUInt16LE(directoryBuffer, extraCursor)
const dataSize = readUInt16LE(directoryBuffer, extraCursor + 2)
if (headerId === 0x0001) {
let fieldCursor = extraCursor + 4
const fieldEnd = extraCursor + 4 + dataSize
if (uncompressedSize === 0xffffffff && fieldCursor + 8 <= fieldEnd) {
uncompressedSize = readBigUInt64LE(directoryBuffer, fieldCursor)
fieldCursor += 8
}
if (compressedSize === 0xffffffff && fieldCursor + 8 <= fieldEnd) {
compressedSize = readBigUInt64LE(directoryBuffer, fieldCursor)
fieldCursor += 8
}
if (localHeaderOffset === 0xffffffff && fieldCursor + 8 <= fieldEnd) {
localHeaderOffset = readBigUInt64LE(directoryBuffer, fieldCursor)
fieldCursor += 8
}
}
extraCursor += 4 + dataSize
}
const name = directoryBuffer.toString('utf8', cursor + 46, extraStart)
entries.push({
name,
compressedSize,
uncompressedSize,
compressionMethod,
localHeaderOffset,
flags,
})
cursor = extraEnd + commentLength
}
return { entries }
} finally {
await handle.close()
}
}
/** 列出 ZIP 内的条目(按中央目录顺序,跳过目录项)。 */
export async function listZipEntries(zipPath: string): Promise<ZipEntryInfo[]> {
const { entries } = await readCentralDirectory(zipPath)
return entries.filter((entry) => !entry.name.endsWith('/'))
}
/**
* 把 ZIP 条目解压到目标目录。
*
* 返回值保持中央目录顺序;文件名只保留最后一段路径,
* 拒绝空名和穿越命名,避免历史数据里的异常条目写出目录树。
*/
export async function extractZipEntriesToDirectory(
zipPath: string,
destinationDir: string,
): Promise<Array<{ entryName: string; filePath: string; size: number }>> {
const { entries } = await readCentralDirectory(zipPath)
await mkdir(destinationDir, { recursive: true })
const extracted: Array<{ entryName: string; filePath: string; size: number }> = []
for (const entry of entries) {
if (entry.name.endsWith('/')) {
continue
}
const normalized = entry.name.replace(/\\/g, '/')
const baseName = normalized.split('/').pop() || ''
if (!baseName || baseName === '.' || baseName === '..') {
throw new Error(`压缩包含异常文件名,拒绝处理: ${entry.name}`)
}
if ((entry.flags & 0x1) !== 0) {
throw new Error(`压缩包含加密文件,不支持编辑: ${baseName}`)
}
const outputPath = path.join(destinationDir, `${extracted.length}-${baseName}`)
const written = await extractSingleEntry(handle => extractByEntry(handle, entry, outputPath))
if (written !== entry.uncompressedSize) {
throw new Error(`压缩包已损坏:条目 ${baseName} 解压后大小不一致`)
}
extracted.push({
entryName: baseName,
filePath: outputPath,
size: entry.uncompressedSize,
})
}
return extracted
async function extractSingleEntry(
worker: (handle: Awaited<ReturnType<typeof open>>) => Promise<number>,
): Promise<number> {
const handle = await open(zipPath, 'r')
try {
return await worker(handle)
} finally {
await handle.close()
}
}
}
async function extractByEntry(
handle: Awaited<ReturnType<typeof open>>,
entry: ZipEntryInfo,
outputPath: string,
): Promise<number> {
const localHeader = await readSlice(handle, entry.localHeaderOffset, 30)
if (readUInt32LE(localHeader, 0) !== LOCAL_HEADER_SIGNATURE) {
throw new Error(`压缩包已损坏:条目 ${entry.name} 本地头异常`)
}
const nameLength = readUInt16LE(localHeader, 26)
const extraLength = readUInt16LE(localHeader, 28)
const dataStart = entry.localHeaderOffset + 30 + nameLength + extraLength
if (entry.compressionMethod === 0) {
// store:直接按范围拷贝原始字节
return copyRangeToFile(handle, dataStart, entry.compressedSize, outputPath)
}
if (entry.compressionMethod === 8) {
const source = createRangeReadStream(handle, dataStart, entry.compressedSize)
const inflater = createInflateRaw()
let inflatedBytes = 0
inflater.on('data', (chunk: Buffer) => {
inflatedBytes += chunk.length
})
const output = await createFileWriteStream(outputPath)
try {
await pipeline(source, inflater, output)
} catch (error) {
output.destroy()
throw error
}
return inflatedBytes
}
throw new Error(`压缩包含不支持的压缩方式(method ${entry.compressionMethod}),无法编辑`)
}
// ---- 以下为内部辅助,避免引入额外依赖 ----
async function copyRangeToFile(
handle: Awaited<ReturnType<typeof open>>,
start: number,
length: number,
outputPath: string,
): Promise<number> {
const CHUNK = 1024 * 1024
const output = await createFileWriteStream(outputPath)
let written = 0
try {
let position = start
while (written < length) {
const size = Math.min(CHUNK, length - written)
const buffer = await readSlice(handle, position, size)
await new Promise<void>((resolve, reject) => {
output.write(buffer, (error) => (error ? reject(error) : resolve()))
})
written += size
position += size
}
await new Promise<void>((resolve, reject) => {
output.end(() => resolve())
output.on('error', reject)
})
return written
} catch (error) {
output.destroy()
throw error
}
}
function createRangeReadStream(
handle: Awaited<ReturnType<typeof open>>,
start: number,
length: number,
) {
// fd 由外层 close;createReadStream 仅负责按窗口读取
return createReadStream('', {
fd: handle.fd,
autoClose: false,
start,
end: start + length - 1,
})
}
async function createFileWriteStream(outputPath: string) {
const { createWriteStream } = await import('fs')
return createWriteStream(outputPath, { flags: 'wx' })
}
/** 创建任务-详情编辑用的临时工作目录,返回路径与清理函数。 */
export async function createTaskDetailEditWorkspace(): Promise<{
dir: string
cleanup: () => Promise<void>
}> {
const { getUploadRootDir } = await import('@/lib/upload-paths')
const dir = path.join(getUploadRootDir(), 'temp', `task-detail-edit-${randomUUID()}`)
await mkdir(dir, { recursive: true })
return {
dir,
cleanup: () => rm(dir, { recursive: true, force: true }),
}
}