12k
All articles

Ler e escrever ficheiros ZIP em Node sem uma biblioteca

Leia e crie arquivos ZIP com a API experimental node:zlib do Node.js 26.8.0. Veja exemplos de streaming, proteção contra zip-slip e alternativas para versões anteriores.

OpenReplay Team
OpenReplay Team
Ler e escrever ficheiros ZIP em Node sem uma biblioteca

A partir do Node.js 26.8.0, o módulo nativo node:zlib consegue ler e escrever arquivos ZIP por si só, pelo que o adm-zip, o archiver e o yauzl deixam de ser necessários. A API é experimental.

A maioria dos projetos Node que aceitam uploads ou geram artefactos de release inclui pelo menos um pacote ZIP, muitas vezes dois: o yauzl para leitura e o archiver para escrita. Ambos são código de terceiros que processa input binário não fidedigno.

A funcionalidade foi lançada no release do Node.js 26.8.0 a 26 de agosto de 2026, através do PR #64339. A API de arquivos ZIP é experimental. A primeira chamada a qualquer parte da API emite um aviso de funcionalidade experimental, mas importar apenas o node:zlib não o faz. Até ao momento, nenhum release do Node 24 LTS a inclui: as classes ZIP não constam dos changelogs da versão 24.x até à 24.21.0. Isto segue o mesmo padrão das outras APIs nativas do Node.js que substituem pacotes npm. A API é recente e ainda está em evolução, por isso confirme as assinaturas exatas na documentação do node:zlib antes de passar para produção.

Pontos-chave

  • O Node.js 26.8.0 acrescentou ao node:zlib suporte experimental para leitura e escrita de ZIP através de ZipFile, ZipBuffer, ZipEntry e createZipArchive().
  • ZipEntry.content() carrega um membro inteiro para memória, enquanto contentIterator() o transmite em streaming como chunks de Buffer.
  • setMaxZipContentSize() define o limite predefinido para leituras em buffer, como content(). Não limita contentIterator(), que aceita, em vez disso, a sua própria opção maxSize por chamada.
  • A API ZIP não rejeita nomes de entradas que contenham ../ ou caminhos absolutos, pelo que cada extrator precisa da sua própria verificação contra zip-slip.
  • No Node 24 LTS, mantenha o yauzl para leitura e o archiver para escrita.
PacoteFunçãoSubstituto nativo
adm-zipLer/escrever arquivos completos em memóriaZipBuffer, ZipFile, createZipArchive()
yauzlLeitura em streamingZipFile + contentIterator()
archiverEscrita em streamingZipEntry.create()/createStream() + createZipArchive()

Como ler um ficheiro ZIP em Node.js com ZipFile?

Para ler um arquivo ZIP em disco em Node.js, abra-o com ZipFile.open(), itere sobre as suas entradas e chame content() no membro de que precisa. O ZipFile funciona através de um file descriptor: salta diretamente para o membro pedido, lê-o do disco apenas quando necessário e não retém o conteúdo do membro depois disso. O ZipBuffer desempenha a mesma função para um arquivo que já tem em memória, como um upload guardado num Buffer, e lê dessa memória sem a copiar. O ZipEntry representa um único membro.

// 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() é rejeitado com ERR_ZIP_ENTRY_NOT_FOUND quando o arquivo não tem nenhum membro com esse nome, por isso o exemplo verifica primeiro zip.has(). zipFile.values() devolve um iterador em que cada item é uma Promise que resolve para um ZipEntry. É por isso que for await é adequado aqui. Cada entrada expõe name e size, que corresponde ao tamanho descomprimido em bytes. As chamadas assíncronas têm também versões síncronas (openSync(), contentSync(), valuesSync()). As exceções são as APIs de streaming: contentIterator() e ZipEntry.createStream() não têm equivalente síncrono.

Streaming de arquivos grandes com contentIterator()

Para ler membros ZIP de grandes dimensões em Node.js, utilize contentIterator(). Este método entrega o conteúdo descomprimido como uma série de chunks de Buffer, pelo que o membro completo nunca tem de estar todo em memória ao mesmo tempo. Como é um async iterable, pode passá-lo diretamente para pipeline(). O modelo baseado em pull é o mesmo abordado em como funcionam as streams para programadores 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() protege contra zip bombs: um upload pequeno quando comprimido pode expandir-se até um tamanho enorme. Esta função define o limite máximo predefinido, ao nível do módulo, para leituras em buffer como content(). A documentação indica que contentIterator() não está sujeito a esse valor predefinido, porque o streaming nunca efetua uma única alocação de grandes dimensões. A opção maxSize por chamada em content() recusa qualquer entrada cujo tamanho descomprimido declarado seja superior ao valor indicado. Se a omitir, content() utiliza o limite do módulo devolvido por getMaxZipContentSize(). contentIterator() aceita a sua própria opção maxSize, que, por predefinição, não tem limite.

As leituras em streaming têm uma salvaguarda própria. Um PR posterior de hardening explica que o descodificador de streaming nunca produz mais bytes do que o tamanho descomprimido declarado do membro. Se os dados tentarem expandir-se para além desse tamanho, é lançado imediatamente ERR_ZIP_ENTRY_CORRUPT. É por isso que o ciclo acima verifica entry.size antes de iniciar o streaming.

Como criar um arquivo ZIP a partir de uma pasta?

Para criar um arquivo ZIP em Node.js, crie um ZipEntry por ficheiro e passe a lista a createZipArchive(), que converte essas entradas numa readable stream com os bytes do arquivo. Em seguida, encaminhe essa stream para um ficheiro. Primeiro, recolha os ficheiros com fs.readdir e a sua opção 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));
}

Como a função utiliza lstat, ignora os symlinks em vez de os seguir. De seguida, serialize as entradas:

// 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() recebe primeiro o nome do ficheiro e depois os dados. A versão síncrona está documentada como zlib.ZipEntry.createSync(filename, data, options). Este exemplo carrega cada ficheiro para buffer antes de o adicionar. Para ficheiros grandes, utilize antes ZipEntry.createStream(). Este método comprime a origem enquanto createZipArchive() escreve o arquivo, pelo que o ficheiro completo nunca tem de ser mantido em memória. Uma entrada em streaming só funciona uma vez: depois de createZipArchive() a ter escrito, o seu conteúdo fica consumido e não pode voltar a ser lido.

Pastas aninhadas e proteção contra zip-slip

Os caminhos dentro de um arquivo ZIP utilizam barras normais (/), independentemente do sistema operativo que os criou. A especificação do formato de ficheiro ZIP exige-o na secção sobre nomes de ficheiros (4.4.17). Por isso, collectFiles converte path.sep em /, de modo que um caminho Windows como assets\img\logo.png é guardado como assets/img/logo.png. Os nomes de entradas que terminam em / são diretórios.

O zip-slip é um ataque de path traversal em que um arquivo malicioso contém uma entrada com um nome como ../../etc/passwd, e um extrator ingénuo escreve-a fora do diretório de destino. A API nativa transmite esses nomes exatamente como aparecem no arquivo, sem qualquer sanitização nem erro, por isso a verificação é da sua responsabilidade. Resolva cada nome relativamente ao destino e recuse tudo o que fique fora dele:

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

A função rejeita tanto o traversal com ../ como nomes absolutos, como /etc/passwd. Chame-a para cada entry.name antes de escrever o que quer que seja. As entradas de symlink exigem a mesma cautela. A documentação avisa que qualquer extrator que respeite uma entrada de symlink criará um link real no disco, pelo que não é possível confiar no destino para onde esse link aponta. A abordagem mais simples é ignorar as entradas em que entry.isSymlink é verdadeiro, tal como faz o ciclo de streaming acima.

Em versões mais antigas do Node ou LTS: mantenha o yauzl e o archiver

Se utiliza o Node 24 LTS ou anterior, mantenha o yauzl para leitura e o archiver para escrita. As novas funcionalidades da linha Current podem ser alvo de backport para uma linha LTS ativa, por isso consulte os changelogs da versão 24.x antes de abandonar os pacotes. Até a API estar disponível na versão do Node que utiliza em produção, mantenha os pacotes. Uma verificação em runtime permite que ferramentas partilhadas escolham o caminho certo:

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

Este é o código do archiver que a funcionalidade nativa substitui. O archiver 8 deixou de ter export predefinido, pelo que utiliza a classe nomeada ZipArchive; no archiver 7 e anteriores, escreva antes import archiver from 'archiver' e 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() resolve quando o archiver termina de produzir dados, e não quando o ficheiro foi efetivamente escrito em disco (flush) e fechado. Aguardar pelo evento close da stream de saída garante que é seguro ler release.zip logo de seguida.

Conclusão

No Node 26.8.0 ou posterior, o node:zlib trata das tarefas ZIP do dia a dia: leitura com ZipFile, streaming de membros grandes com contentIterator() e escrita com ZipEntry e createZipArchive(). Isto cobre aquilo que o adm-zip, o archiver e o yauzl faziam. Como a API é experimental, fixe a versão do Node, mantenha a proteção contra zip-slip e os limites de tamanho, e volte a ler a documentação do node:zlib a cada atualização. Um bom primeiro passo é substituir um único fluxo de leitura, como a extração de uploads, protegido pela verificação hasNativeZip. Mantenha o fallback para os pacotes até que a sua linha de produção suporte a API.

Perguntas frequentes

O que acontece se duas entradas tiverem o mesmo nome quando chamo createZipArchive() em Node?

createZipArchive() escreve ambas as entradas. Escreve as entradas pela ordem em que as recebe e não verifica nomes repetidos, pelo que o arquivo acaba por conter duplicados, e a maioria das ferramentas de extração mantém a última cópia. Os métodos add() de ZipBuffer e ZipFile funcionam de forma diferente: substituem uma entrada existente com o mesmo nome. Se a sua lista de ficheiros puder repetir caminhos, remova os duplicados antes de serializar.

O node:zlib consegue escrever arquivos ZIP com mais de 4 GB?

Sim. createZipArchive() passa automaticamente para estruturas Zip64 assim que o número de entradas, ou qualquer offset ou tamanho, excede a capacidade dos campos ZIP originais de 16 e 32 bits. Isto abrange membros ou arquivos com mais de 4 GB e arquivos com mais de 65 535 entradas. Não é necessária nenhuma opção para isso, e ZipBuffer.toBuffer() muda para Zip64 da mesma forma ao serializar entradas.

Que códigos de erro devo tratar ao ler entradas ZIP com o node:zlib?

Trate ERR_ZIP_ENTRY_TOO_LARGE, lançado quando o tamanho declarado de uma entrada excede maxSize, e ERR_ZIP_ENTRY_CORRUPT, lançado quando o checksum CRC-32 do conteúdo está errado ou o seu comprimento difere do tamanho declarado. Pedir um nome que o arquivo não contém resulta em ERR_ZIP_ENTRY_NOT_FOUND (ZipFile.get() é rejeitado com este erro). Após o hardening do PR 65016, os arquivos cujos cabeçalhos locais e centrais não coincidem são rejeitados com ERR_ZIP_INVALID_ARCHIVE.

Que métodos de compressão utiliza a API ZIP nativa do Node?

A documentação do node:zlib indica deflate e Zstandard como métodos de compressão para entradas comprimidas. As entradas também podem ser armazenadas sem compressão. Cada ZipEntry tem uma propriedade booleana compressed: é verdadeira quando foi utilizado um dos métodos e falsa quando o conteúdo foi armazenado sem compressão. A documentação descreve esta lista como atual e a API é experimental, por isso consulte a documentação da sua versão do 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.