本章进入 yauzl 的核心循环:一次读取一个 Entry,为文件打开 Readable Stream,并用
pipeline()安全地传输数据。
ZIP 中每一个目录项都会对应一个 yauzl.Entry。它描述文件或目录的元数据,例如:
zipFile.on("entry", (entry) => {
console.log({
name: entry.fileName,
compressedSize: entry.compressedSize,
uncompressedSize: entry.uncompressedSize,
compressionMethod: entry.compressionMethod,
});
});
此时拿到的是 Central Directory 中的信息,不是完整文件正文。
Entry
├── fileName:归档内名称
├── compressedSize:压缩后的字节数
├── uncompressedSize:解压后的字节数
├── compressionMethod:压缩方法
├── crc32:归档声明的校验值
└── externalFileAttributes:外部文件属性
文件正文
└── 需要 openReadStream(entry) 才开始读取
fileNameentry.fileName
表示 ZIP 内部路径,例如:
README.md
assets/logo.png
chapters/getting-started.md
这个名称来自上传文件,不可信。不能直接用它拼接服务器路径:
// 不安全示例:不要这样写
const outputPath = path.join(extractRoot, entry.fileName);
攻击者可能提交 ../../.env、反斜杠、绝对路径或其他特殊形式。安全解压章节会完整实现路径验证。
compressedSizeentry.compressedSize
表示 ZIP 内保存的数据大小。它可用于计算压缩比,但不能表示实际磁盘占用。
uncompressedSizeentry.uncompressedSize
表示归档声明的解压后大小。处理前至少要检查:
const MAX_SINGLE_ENTRY_SIZE = 20 * 1024 * 1024;
if (entry.uncompressedSize > MAX_SINGLE_ENTRY_SIZE) {
throw new Error("单个文件解压后超过 20 MiB");
}
还要累计所有 Entry:
totalUncompressedSize += entry.uncompressedSize;
if (totalUncompressedSize > MAX_TOTAL_SIZE) {
throw new Error("ZIP 解压后的总大小超限");
}
元数据也可能来自损坏或恶意 ZIP,因此限制声明值之外,还应保留 yauzl 的 validateEntrySizes: true,并处理读取流错误。
compressionMethodentry.compressionMethod
常见值:
| 值 | 含义 |
|---|---|
0 |
Stored,正文未压缩 |
8 |
Deflate |
通常不需要自行调用 node:zlib。openReadStream() 会按 Entry 的压缩方法提供解压后的数据;遇到不支持的方法时,应将错误交给统一错误流程。
generalPurposeBitFlagentry.generalPurposeBitFlag
这是 ZIP 格式中的一组标志位,可能描述加密、文件名编码等信息。普通业务无需手工解析全部位;需要记住,密码加密 ZIP 不是当前服务应该悄悄接受的普通输入,应明确拒绝或制定单独产品规则。
externalFileAttributesentry.externalFileAttributes
可能携带 Unix 文件模式等属性,可用于识别符号链接,但解释方式与 ZIP 的创建平台有关。用户上传的 ZIP 不应原样恢复权限;当前 Markdown 服务更稳妥的初始策略是拒绝符号链接,只创建普通目录和普通文件。
crc32entry.crc32
是 ZIP 中声明的 CRC-32 值。它不是数字签名,不能证明内容来源可信,也不能代替病毒扫描或 HTML 清理。
ZIP 中常用末尾 / 表示目录:
function isDirectoryEntry(entry: yauzl.Entry): boolean {
return entry.fileName.endsWith("/");
}
目录 Entry 没有需要写入的正文,处理完目录后继续读下一个 Entry:
if (isDirectoryEntry(entry)) {
await createSafeDirectory(entry);
return;
}
并不是所有 ZIP 都显式包含目录 Entry。例如 ZIP 可能只有:
assets/logo.png
却没有单独的:
assets/
所以保存文件前仍要使用:
await mkdir(dirname(outputPath), { recursive: true });
openReadStream() 包装成 Promiseyauzl 使用 callback 返回 Entry 的读取流。可以建立一个小型适配函数:
import { Readable } from "node:stream";
import * as yauzl from "yauzl";
export function openEntryReadStream(
zipFile: yauzl.ZipFile,
entry: yauzl.Entry,
): Promise<Readable> {
return new Promise((resolve, reject) => {
zipFile.openReadStream(
entry,
(error, readStream) => {
if (error) {
reject(error);
return;
}
resolve(readStream);
},
);
});
}
使用:
const readStream = await openEntryReadStream(
zipFile,
entry,
);
获得 Stream 不代表读取完成。真正的数据会在下游消费它时逐步产生。
pipeline() 保存文件假设 outputPath 已经过严格的路径安全函数验证:
import { createWriteStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { pipeline } from "node:stream/promises";
async function writeEntryToDisk(
zipFile: yauzl.ZipFile,
entry: yauzl.Entry,
outputPath: string,
): Promise<void> {
await mkdir(dirname(outputPath), {
recursive: true,
});
const readStream = await openEntryReadStream(
zipFile,
entry,
);
const writeStream = createWriteStream(outputPath, {
// 如果目标意外存在就失败,避免静默覆盖。
flags: "wx",
});
await pipeline(readStream, writeStream);
}
pipeline() 的优势:
如果磁盘空间不足、权限不足或 ZIP 正文损坏,await pipeline(...) 会抛出错误,调用方可以进入统一清理逻辑。
Readable 产生数据的速度可能高于磁盘或网络写入速度。如果不限制,内存中的待写数据会越来越多。
没有背压:
读取 100 MB/s → 内存不断堆积 → 写入 10 MB/s
有背压:
读取端根据下游能力暂停/继续 → 写入 10 MB/s
Node.js Stream 自带背压协议,pipeline() 会帮助我们正确使用它。
背压控制的是传输中的缓冲增长,不等于业务大小限制。即使每次只缓冲少量数据,如果允许一个 Entry 持续输出 500 GiB,仍然会写满磁盘。因此以下两者都要有:
Stream + pipeline → 控制传输过程内存
大小和数量限制 → 控制业务允许的总资源
async 事件监听器下面的代码看起来会逐项等待,实际上有风险:
// 错误示例
zipFile.on("entry", async (entry) => {
await processEntry(entry);
});
EventEmitter 不会等待 async listener 返回的 Promise。若 lazyEntries 没有开启,多个 entry 事件可能继续到来,导致:
即使开启 lazyEntries,也应该在监听器内部显式捕获异步失败,并且只在当前 Entry 成功完成后调用下一次 readEntry()。
下面把事件 API 包装成一个可 await 的遍历函数:
import * as yauzl from "yauzl";
type EntryHandler = (
entry: yauzl.Entry,
) => Promise<void>;
export function forEachZipEntry(
zipFile: yauzl.ZipFile,
handleEntry: EntryHandler,
): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false;
const fail = (error: unknown): void => {
if (settled) {
return;
}
settled = true;
zipFile.close();
reject(error);
};
zipFile.once("error", fail);
zipFile.once("end", () => {
if (settled) {
return;
}
settled = true;
resolve();
});
zipFile.on("entry", (entry) => {
void handleEntry(entry)
.then(() => {
if (!settled) {
zipFile.readEntry();
}
})
.catch(fail);
});
// lazyEntries: true 时,必须主动启动第一次读取。
zipFile.readEntry();
});
}
使用:
const zipFile = await openZipFile(zipPath);
await forEachZipEntry(zipFile, async (entry) => {
if (isDirectoryEntry(entry)) {
await createSafeDirectory(entry);
return;
}
validateEntry(entry);
const outputPath = resolveSafeEntryPath(
extractionRoot,
entry.fileName,
);
await writeEntryToDisk(
zipFile,
entry,
outputPath,
);
});
这段代码有一个关键顺序:
处理当前 Entry 的 Promise 完成
↓
调用 readEntry()
↓
产生下一个 entry 事件
示例中的 validateEntry()、resolveSafeEntryPath() 和 createSafeDirectory() 必须由后续安全章节实现,不能把示例函数名当成 yauzl 自带 API。
interface ZipLimits {
maxEntries: number;
maxSingleEntrySize: number;
maxTotalUncompressedSize: number;
}
interface ZipUsage {
entryCount: number;
totalUncompressedSize: number;
}
function updateZipUsage(
entry: yauzl.Entry,
usage: ZipUsage,
limits: ZipLimits,
): void {
usage.entryCount += 1;
if (usage.entryCount > limits.maxEntries) {
throw new Error("ZIP Entry 数量超限");
}
if (
entry.uncompressedSize >
limits.maxSingleEntrySize
) {
throw new Error("ZIP 中存在过大的单文件");
}
usage.totalUncompressedSize +=
entry.uncompressedSize;
if (
usage.totalUncompressedSize >
limits.maxTotalUncompressedSize
) {
throw new Error("ZIP 解压后总大小超限");
}
}
使用一个明确的状态对象,比在多个事件回调里散落几个可变变量更容易测试和复盘。
并非任何 Buffer 都不能使用。对于已经严格限制的小型文本文件,例如最大 256 KiB 的 Markdown,可以读取到内存后交给 Markdown 渲染器。
但必须先定义上限,并在实际读取时继续计数。不能仅凭文件扩展名认为它一定很小。
一种小文件收集函数:
import { Buffer } from "node:buffer";
import { Readable } from "node:stream";
async function readSmallStream(
stream: Readable,
maxBytes: number,
): Promise<Buffer> {
const chunks: Buffer[] = [];
let totalBytes = 0;
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk);
totalBytes += buffer.length;
if (totalBytes > maxBytes) {
stream.destroy();
throw new Error("文件实际读取大小超限");
}
chunks.push(buffer);
}
return Buffer.concat(chunks, totalBytes);
}
使用原则:
pipeline() 到磁盘或对象存储;读取一个 ZIP 涉及多个可失败对象:
ZipFile
Entry ReadStream
文件 WriteStream
临时目录
客户端连接
处理原则:
ZipFile 监听 error;pipeline() 处理错误;readEntry();zipFile.close();finally 清理临时目录;不要在多个地方各自无条件执行响应和清理,否则可能出现二次响应或重复操作。后续章节会建立统一错误类型和一次性清理函数。
一个健康的 yauzl 读取循环应满足:
lazyEntries: true
+ 一次只处理一个 Entry
+ openReadStream() 获取正文
+ pipeline() 传递数据和背压
+ 当前项完成才 readEntry()
+ 所有错误进入同一个失败出口
这套结构稍显啰嗦,却能让大小统计、路径检查、取消和清理都有明确位置。
entry 事件时,Entry 的文件正文是否已经全部进入内存?compressedSize 和 uncompressedSize 分别表示什么?限制上传 ZIP 本身大小为什么还不够?pipeline() 与直接监听 data 事件手工写文件相比有哪些优势?EventEmitter 的 async listener 可能产生并发和未处理错误?readEntry() 应在什么时候调用?