Entry、事件、Stream 与背压

本章进入 yauzl 的核心循环:一次读取一个 Entry,为文件打开 Readable Stream,并用 pipeline() 安全地传输数据。

1. Entry 到底是什么

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) 才开始读取

2. 高频 Entry 字段

fileName

entry.fileName

表示 ZIP 内部路径,例如:

README.md
assets/logo.png
chapters/getting-started.md

这个名称来自上传文件,不可信。不能直接用它拼接服务器路径:

// 不安全示例:不要这样写
const outputPath = path.join(extractRoot, entry.fileName);

攻击者可能提交 ../../.env、反斜杠、绝对路径或其他特殊形式。安全解压章节会完整实现路径验证。

compressedSize

entry.compressedSize

表示 ZIP 内保存的数据大小。它可用于计算压缩比,但不能表示实际磁盘占用。

uncompressedSize

entry.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,并处理读取流错误。

compressionMethod

entry.compressionMethod

常见值:

含义
0 Stored,正文未压缩
8 Deflate

通常不需要自行调用 node:zlibopenReadStream() 会按 Entry 的压缩方法提供解压后的数据;遇到不支持的方法时,应将错误交给统一错误流程。

generalPurposeBitFlag

entry.generalPurposeBitFlag

这是 ZIP 格式中的一组标志位,可能描述加密、文件名编码等信息。普通业务无需手工解析全部位;需要记住,密码加密 ZIP 不是当前服务应该悄悄接受的普通输入,应明确拒绝或制定单独产品规则。

externalFileAttributes

entry.externalFileAttributes

可能携带 Unix 文件模式等属性,可用于识别符号链接,但解释方式与 ZIP 的创建平台有关。用户上传的 ZIP 不应原样恢复权限;当前 Markdown 服务更稳妥的初始策略是拒绝符号链接,只创建普通目录和普通文件。

crc32

entry.crc32

是 ZIP 中声明的 CRC-32 值。它不是数字签名,不能证明内容来源可信,也不能代替病毒扫描或 HTML 清理。

3. 如何识别目录 Entry

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 });

4. 把 openReadStream() 包装成 Promise

yauzl 使用 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 不代表读取完成。真正的数据会在下游消费它时逐步产生。

5. 使用 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(...) 会抛出错误,调用方可以进入统一清理逻辑。

6. 背压是什么

Readable 产生数据的速度可能高于磁盘或网络写入速度。如果不限制,内存中的待写数据会越来越多。

没有背压:
读取 100 MB/s → 内存不断堆积 → 写入 10 MB/s

有背压:
读取端根据下游能力暂停/继续 → 写入 10 MB/s

Node.js Stream 自带背压协议,pipeline() 会帮助我们正确使用它。

背压控制的是传输中的缓冲增长,不等于业务大小限制。即使每次只缓冲少量数据,如果允许一个 Entry 持续输出 500 GiB,仍然会写满磁盘。因此以下两者都要有:

Stream + pipeline   → 控制传输过程内存
大小和数量限制      → 控制业务允许的总资源

7. 为什么不能无控制地使用 async 事件监听器

下面的代码看起来会逐项等待,实际上有风险:

// 错误示例
zipFile.on("entry", async (entry) => {
  await processEntry(entry);
});

EventEmitter 不会等待 async listener 返回的 Promise。若 lazyEntries 没有开启,多个 entry 事件可能继续到来,导致:

即使开启 lazyEntries,也应该在监听器内部显式捕获异步失败,并且只在当前 Entry 成功完成后调用下一次 readEntry()

8. 顺序遍历 Entry 的 Promise 骨架

下面把事件 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。

9. 一个更完整的资源计数示例

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 解压后总大小超限");
  }
}

使用一个明确的状态对象,比在多个事件回调里散落几个可变变量更容易测试和复盘。

10. 什么时候可以把 Entry 读成 Buffer

并非任何 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);
}

使用原则:

11. 错误和关闭的边界

读取一个 ZIP 涉及多个可失败对象:

ZipFile
Entry ReadStream
文件 WriteStream
临时目录
客户端连接

处理原则:

  1. ZipFile 监听 error
  2. Entry Stream 交给 pipeline() 处理错误;
  3. 业务拒绝后停止调用 readEntry()
  4. 中途失败时调用 zipFile.close()
  5. 最外层 finally 清理临时目录;
  6. 客户端断开时还要传播取消信号。

不要在多个地方各自无条件执行响应和清理,否则可能出现二次响应或重复操作。后续章节会建立统一错误类型和一次性清理函数。

12. 本章小结

一个健康的 yauzl 读取循环应满足:

lazyEntries: true
  + 一次只处理一个 Entry
  + openReadStream() 获取正文
  + pipeline() 传递数据和背压
  + 当前项完成才 readEntry()
  + 所有错误进入同一个失败出口

这套结构稍显啰嗦,却能让大小统计、路径检查、取消和清理都有明确位置。

本章复盘题

  1. 收到 entry 事件时,Entry 的文件正文是否已经全部进入内存?
  2. compressedSizeuncompressedSize 分别表示什么?限制上传 ZIP 本身大小为什么还不够?
  3. 为什么 ZIP 中没有显式目录 Entry 时,仍然可能包含嵌套文件?
  4. pipeline() 与直接监听 data 事件手工写文件相比有哪些优势?
  5. 背压和文件大小上限解决的是同一个问题吗?
  6. 为什么 EventEmitter 的 async listener 可能产生并发和未处理错误?
  7. 顺序处理模式中,下一次 readEntry() 应在什么时候调用?
  8. 哪些文件可以考虑读取为 Buffer?前提条件是什么?

官方参考