12k
All articles

在 Node 中不借助第三方库读写 ZIP 文件

使用 Node.js 26.8.0 的实验性 node:zlib API 读写 ZIP 文件,了解流式处理示例、zip-slip 防护、大小限制及旧版 Node.js 的替代方案。

OpenReplay Team
OpenReplay Team
在 Node 中不借助第三方库读写 ZIP 文件

从 Node.js 26.8.0 开始,内置的 node:zlib 模块可以直接读写 ZIP 归档,不再需要 adm-zip、archiver 和 yauzl。该 API 目前处于实验阶段。

大多数接收文件上传或构建发布产物的 Node 项目至少依赖一个 ZIP 包,通常是两个:用 yauzl 读取,用 archiver 写入。两者都是第三方代码,却要处理不受信任的二进制输入。

该功能随 2026 年 8 月 26 日发布的 Node.js 26.8.0 推出,对应 PR #64339。ZIP 归档 API 仍是实验性功能:首次调用其中任何部分都会打印一条实验性警告,但单纯导入 node:zlib 不会触发。目前还没有任何 Node 24 LTS 版本包含该功能,截至 24.21.0,24.x 的更新日志中都没有出现这些 ZIP 类。这与其他可替代 npm 包的 Node.js 内置 API 的发展路径一致。该 API 较新且仍在变化,上线前请在 node:zlib 文档中核对确切的函数签名。

要点速览

  • Node.js 26.8.0 通过 ZipFile、ZipBuffer、ZipEntry 和 createZipArchive() 为 node:zlib 新增了实验性的 ZIP 读写支持。
  • ZipEntry.content() 会把整个成员文件加载到内存,而 contentIterator() 以 Buffer 分块的形式流式读取。
  • setMaxZipContentSize() 为 content() 等缓冲式读取设置默认上限。它不限制 contentIterator(),后者通过每次调用时传入的 maxSize 选项单独设置上限。
  • ZIP API 不会拒绝包含 ../ 或绝对路径的条目名,因此每个解压程序都需要自行实现 zip-slip 检查。
  • 在 Node 24 LTS 上,继续使用 yauzl 读取、archiver 写入。
包用途内置替代方案
adm-zip在内存中读写完整归档ZipBuffer、ZipFile、createZipArchive()
yauzl流式读取ZipFile + contentIterator()
archiver流式写入ZipEntry.create()/createStream() + createZipArchive()

如何在 Node.js 中用 ZipFile 读取 ZIP 文件?

要在 Node.js 中读取磁盘上的 ZIP 归档,先用 ZipFile.open() 打开它,遍历其中的条目,再对需要的成员调用 content()。ZipFile 基于文件描述符工作:它会直接跳转到你请求的成员,仅在需要时才从磁盘读取,并且读取后不会保留成员内容。ZipBuffer 则针对已经位于内存中的归档(例如保存在 Buffer 中的上传文件)完成同样的工作,并且直接从该内存读取,无需复制。ZipEntry 表示单个成员。

// Requires Node 26.8.0+ (experimental)
import { ZipFile } from 'node:zlib';

const zip = await ZipFile.open('upload.zip');
try {
  for await (const entry of zip.values()) {
    console.log(entry.name, entry.size);
  }
  if (zip.has('README.md')) {
    const readme = await zip.get('README.md');
    const buf = await readme.content({ maxSize: 1024 * 1024 });
    console.log(buf.toString('utf8'));
  }
} finally {
  await zip.close();
}

当归档中不存在指定名称的成员时,zip.get() 会以 ERR_ZIP_ENTRY_NOT_FOUND 拒绝(reject),因此示例先调用 zip.has() 进行检查。zipFile.values() 返回一个迭代器,其中每一项都是一个解析为 ZipEntry 的 Promise,这正是这里适合使用 for await 的原因。每个条目都提供 name 和 size,后者是以字节为单位的解压后大小。这些异步调用也都有同步版本(openSync()、contentSync()、valuesSync()),例外的是流式 API:contentIterator() 和 ZipEntry.createStream() 没有对应的同步版本。

使用 contentIterator() 流式处理大型归档

要在 Node.js 中读取较大的 ZIP 成员,请使用 contentIterator()。它以一系列 Buffer 分块的形式返回解压后的内容,因此完整的成员文件无需一次性驻留在内存中。由于它是异步可迭代对象,你可以直接将其传给 pipeline()。这种基于拉取(pull-based)的模型与面向 Web 开发者的流工作原理中介绍的模型相同。

// Requires Node 26.8.0+ (experimental)
import { ZipFile, setMaxZipContentSize } from 'node:zlib';
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { safeDestination } from './safe-destination.js';

setMaxZipContentSize(32 * 1024 * 1024); // caps buffered content() reads
const MAX_ENTRY_BYTES = 2 * 1024 ** 3;

const zip = await ZipFile.open('large.zip');
try {
  for await (const entry of zip.values()) {
    if (entry.isSymlink) continue; // don't recreate links from untrusted archives
    const target = safeDestination('out', entry.name);
    if (entry.name.endsWith('/')) {
      await mkdir(target, { recursive: true });
      continue;
    }
    if (entry.size > MAX_ENTRY_BYTES) throw new Error(`Too large: ${entry.name}`);
    await mkdir(path.dirname(target), { recursive: true });
    await pipeline(entry.contentIterator(), createWriteStream(target));
  }
} finally {
  await zip.close();
}

setMaxZipContentSize() 用于防御 zip 炸弹(zip bomb):一个压缩后很小的上传文件,解压后可能膨胀到极大的体积。该函数为 content() 等缓冲式读取设置模块级的默认上限。文档指出,contentIterator() 不受该默认值约束,因为流式读取从不进行单次大块内存分配。content() 的单次调用 maxSize 选项会拒绝任何声明的解压后大小超过所传数值的条目;如果省略该选项,content() 会使用 getMaxZipContentSize() 返回的模块级上限。contentIterator() 也接受自己的 maxSize 选项,但默认不设上限。

流式读取另有一层独立的保护机制。一个后续的加固 PR 说明,流式解码器产出的字节数绝不会超过成员声明的解压后大小。如果数据试图膨胀到超出该大小,解码器会立即抛出 ERR_ZIP_ENTRY_CORRUPT。这也是上面的循环在开始流式读取之前先检查 entry.size 的原因。

如何将文件夹打包为 ZIP 归档?

要在 Node.js 中构建 ZIP 归档,为每个文件创建一个 ZipEntry,然后把列表传给 createZipArchive(),它会将这些条目转换为输出归档字节的可读流,再把该流通过管道写入文件即可。首先,使用 fs.readdir 及其 recursive 选项收集文件:

// collect-files.js
import { readdir, lstat } from 'node:fs/promises';
import path from 'node:path';

export async function collectFiles(root) {
  const files = [];
  for (const rel of await readdir(root, { recursive: true })) {
    const abs = path.join(root, rel);
    if (!(await lstat(abs)).isFile()) continue; // skips dirs and symlinks
    files.push({ abs, name: rel.split(path.sep).join('/') });
  }
  return files.sort((a, b) => a.name.localeCompare(b.name));
}

由于该函数使用了 lstat,它会跳过符号链接,而不是跟随它们。接下来,将条目序列化:

// Requires Node 26.8.0+ (experimental)
import { readFile } from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { ZipEntry, createZipArchive } from 'node:zlib';
import { collectFiles } from './collect-files.js';

const entries = [];
for (const f of await collectFiles('dist')) {
  entries.push(await ZipEntry.create(f.name, await readFile(f.abs)));
}
const archive = await createZipArchive(entries);
await pipeline(archive, createWriteStream('release.zip'));

ZipEntry.create() 的参数依次为文件名和数据。其同步版本在文档中记为 zlib.ZipEntry.createSync(filename, data, options)。本示例会先把每个文件完整读入缓冲区再添加。对于大文件,请改用 ZipEntry.createStream():它会在 createZipArchive() 写入归档的同时压缩源数据,因此无需把整个文件保存在内存中。流式条目只能使用一次:createZipArchive() 写入之后,其内容即被消耗,无法再次读取。

嵌套文件夹与 Zip-Slip 防护

无论由哪种操作系统创建,ZIP 归档内的路径都使用正斜杠。ZIP 文件格式规范在关于文件名的章节(4.4.17)中对此作了要求。因此,collectFiles 会将 path.sep 转换为 /,这样 Windows 路径 assets\img\logo.png 会被存储为 assets/img/logo.png。以 / 结尾的条目名表示目录。

Zip-slip 是一种路径穿越(path traversal)攻击:恶意归档中包含名为类似 ../../etc/passwd 的条目,而实现简单的解压程序会将其写到目标目录之外。内置 API 会原样传递归档中的此类名称,既不做清理也不报错,因此检查工作需要由你自己完成。将每个名称相对于目标目录进行解析,并拒绝任何落在目标目录之外的路径:

// safe-destination.js
import path from 'node:path';

export function safeDestination(destDir, entryName) {
  if (entryName.includes('\0')) throw new Error(`NUL byte in ${entryName}`);
  const root = path.resolve(destDir);
  const target = path.resolve(root, entryName);
  if (target !== root && !target.startsWith(root + path.sep)) {
    throw new Error(`Blocked entry outside ${root}: ${entryName}`);
  }
  return target;
}

该函数既能拦截 ../ 路径穿越,也能拦截 /etc/passwd 这样的绝对路径名。在写入任何内容之前,请对每个 entry.name 调用它。符号链接条目同样需要谨慎对待。文档警告称,任何遵循符号链接条目的解压程序都会在磁盘上创建真实的链接,而你无法信任该链接指向何处。最简单的做法是跳过 entry.isSymlink 为 true 的条目,正如上面的流式读取循环那样。

旧版 Node 或 LTS:继续使用 yauzl 和 archiver

如果你运行的是 Node 24 LTS 或更早版本,请继续使用 yauzl 进行读取、archiver 进行写入。Current 版本线上的新功能可能会被向后移植(backport)到仍处于活跃维护期的 LTS 版本线,因此在移除这些包之前,请先查看 24.x 的更新日志。在该 API 出现在你生产环境所用的 Node 版本中之前,请保留这些包。通过运行时检查,共享工具可以选择正确的代码路径:

const [major, minor] = process.versions.node.split('.').map(Number);
const hasNativeZip = major > 26 || (major === 26 && minor >= 8);

下面是内置 API 所取代的 archiver 代码。Archiver 8 移除了默认导出,因此这里使用具名的 ZipArchive 类;在 archiver 7 及更早版本中,请改用 import archiver from 'archiver' 和 archiver('zip'):

import { ZipArchive } from 'archiver'; // archiver 8+
import { createWriteStream } from 'node:fs';
import { once } from 'node:events';

const output = createWriteStream('release.zip');
const closed = once(output, 'close');
const archive = new ZipArchive();
archive.pipe(output);
archive.directory('dist/', false);
await archive.finalize();
await closed; // release.zip is fully written and closed

finalize() 在 archiver 完成数据生成时即会 resolve,而不是在文件刷新到磁盘并关闭之后。等待输出流的 close 事件,才能确保紧接着读取 release.zip 是安全的。

总结

在 Node 26.8.0 及更高版本中,node:zlib 可以胜任日常的 ZIP 处理工作:用 ZipFile 读取,用 contentIterator() 流式处理大型成员,用 ZipEntry 和 createZipArchive() 写入。这涵盖了 adm-zip、archiver 和 yauzl 原本承担的工作。由于该 API 仍处于实验阶段,请固定你的 Node 版本,保留 zip-slip 防护和大小上限,并在每次升级时重新查阅 node:zlib 文档。一个不错的起步方式是:在 hasNativeZip 检查的保护下,先替换一条读取路径,例如上传文件的解压。在生产环境的版本线支持该 API 之前,请保留基于第三方包的回退方案。

常见问题

在 Node 中调用 createZipArchive() 时,如果两个条目同名会怎样?

createZipArchive() 会把两个条目都写入。它按照接收顺序写入条目,且不检查名称是否重复,因此归档中会出现重复项,而大多数解压工具会保留最后出现的那一份。ZipBuffer 和 ZipFile 上的 add() 方法则不同:它们会替换已存在的同名条目。如果你的文件列表中可能出现重复路径,请在序列化之前先去重。

node:zlib 能写入大于 4 GB 的 ZIP 归档吗?

可以。一旦条目数量、或任何偏移量或大小超出了原始 ZIP 格式中 16 位和 32 位字段的表示范围,createZipArchive() 会自动切换到 Zip64 结构。这涵盖了超过 4 GB 的成员或归档,以及条目数超过 65,535 的归档。你无需为此设置任何选项;ZipBuffer.toBuffer() 在序列化条目时也会以同样的方式切换到 Zip64。

使用 node:zlib 读取 ZIP 条目时,应该处理哪些错误码?

需要处理 ERR_ZIP_ENTRY_TOO_LARGE(条目声明的大小超过 maxSize 时抛出)和 ERR_ZIP_ENTRY_CORRUPT(内容的 CRC-32 校验和错误,或其长度与声明大小不符时抛出)。请求归档中不存在的名称会得到 ERR_ZIP_ENTRY_NOT_FOUND(ZipFile.get() 会以该错误 reject)。经过 PR 65016 的加固之后,本地文件头与中央目录头不一致的归档会以 ERR_ZIP_INVALID_ARCHIVE 被拒绝。

Node 内置的 ZIP API 使用哪些压缩方法?

node:zlib 文档列出的压缩条目所用方法为 deflate 和 Zstandard。条目也可以不经压缩直接存储。每个 ZipEntry 都有一个布尔类型的 compressed 属性:使用了上述任一方法时为 true,内容未经压缩存储时为 false。文档将该列表描述为当前状态,且该 API 仍处于实验阶段,因此请查阅你所用 Node 版本的文档。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.