Compressão: ZIP, GZip, Brotli e Tar
Objective
Compressão em .NET é um conjunto de decorators sobre Stream (GZip, Deflate,
ZLib, Brotli) mais dois formatos de arquivo com APIs próprias (ZIP em
ZipArchive e ZipFile, tar em System.Formats.Tar). A maioria dos erros do
dia a dia não é sobre o algoritmo: um compressor que nunca foi descartado, um
"nível" que significa coisas diferentes em cada codec, um nome de entrada que
sai da pasta de destino e poucos kilobytes de entrada que viram gigabytes. Este
conceito cobre os codecs de stream, o que CompressionLevel compra de fato, ZIP
em memória e em disco incluindo as APIs assíncronas do .NET 10, tar e as regras
de extração que impedem um arquivo não confiável de causar estrago. O padrão
decorator em si está no conceito da hierarquia de Stream.
Use Cases
- Compactar com gzip uma resposta, um arquivo de log ou um payload antes de ir para a rede.
- Montar uma exportação ZIP em memória e devolvê-la de um endpoint.
- Descompactar um arquivo enviado por um usuário sem deixar que ele escreva fora de uma pasta.
- Empacotar uma árvore de diretórios como
.tar.gzpara um container Linux ou um artefato de CI.
Deep Dive
Os codecs são decorators, e o Dispose é o que fecha o arquivo
GZipStream, DeflateStream, ZLibStream e BrotliStream envolvem outro
stream. Passe CompressionMode.Decompress para ler, ou um CompressionLevel
para escrever. O compressor retém dados até ser descartado, e é o Dispose que
escreve o trailer. Todos os números abaixo são do .NET 10.0.0 (SDK 10.0.100),
macOS, com um payload de 785 KB de linhas JSON repetitivas.
csharpvar ms = new MemoryStream();
var g = new GZipStream(ms, CompressionMode.Compress, leaveOpen: true);
g.Write(data);
Console.WriteLine(ms.Length); // 39440 (ainda não é um gzip válido)
g.Dispose();
Console.WriteLine(ms.Length); // 50094 (cabeçalho, último bloco e CRC escritos)leaveOpen: true é como você mantém o stream interno utilizável depois que o
compressor some, o que é necessário com MemoryStream e ToArray(). Sem ele,
descartar o decorator descarta o MemoryStream também. Os primeiros bytes do
resultado foram 1F 8B 08, o número mágico do gzip mais o byte do método
deflate. Dar dados que não são gzip a um GZipStream em modo de descompressão
lançou InvalidDataException ("The archive entry was compressed using an
unsupported compression method").
Os quatro codecs são formatos diferentes, não quatro velocidades da mesma coisa.
GZip, ZLib e Deflate puro usam o mesmo algoritmo com enquadramentos diferentes
(saídas de 50094, 50082 e 50076 bytes em Optimal). Brotli é outro algoritmo e,
nesta entrada de texto, produziu 17242 bytes em Optimal, um terço do tamanho.
O que CompressionLevel significa
A documentação diz que os valores "não correspondem a níveis de compressão específicos; o objeto que implementa a compressão decide como tratá-los" (no original: "do not correspond to specific compression levels"). Medido no mesmo payload:
| Nível | gzip | brotli |
|---|---|---|
Fastest |
85049 bytes | 46550 bytes |
Optimal |
50094 bytes | 17242 bytes |
SmallestSize |
49844 bytes | 14926 bytes (uma execução: 633 ms, contra cerca de 2 ms em Optimal) |
No gzip, SmallestSize rendeu meio por cento sobre Optimal. No Brotli rendeu
13 por cento e custou duas ordens de grandeza em tempo. Então Optimal é o
padrão certo, Fastest para caminhos quentes em que CPU importa mais que bytes,
e SmallestSize só para dados comprimidos uma vez e servidos muitas (assets
estáticos em tempo de build). Bytes aleatórios não comprimem: 100000 bytes
aleatórios viraram 100053 com gzip. Formatos já comprimidos (JPEG, PNG, .docx,
um ZIP) se comportam igual, então pule o codec para eles.
Entrada truncada nem sempre é erro
Cortei um stream comprimido válido ao meio e descomprimi com CopyTo.
GZipStream, DeflateStream e BrotliStream terminaram sem exceção, devolvendo
350290, 350303 e 280633 dos 733390 bytes originais. A documentação que conferi
não descreve isso, então trate como comportamento observado desta versão do
runtime, não como contrato: se você precisa detectar um download cortado,
carregue o tamanho esperado ou um hash junto com os dados e compare depois da
leitura.
ZipArchive: modos, memória e entradas
ZipArchive tem três modos. Create escreve entradas em sequência e funcionou
sobre um stream sem seek. Read abre um arquivo existente. Update permite
Delete() e CreateEntry() num arquivo existente e grava as mudanças no
dispose; depois de apagar a.txt e criar b.txt, uma releitura mostrou só
b.txt. No modo Read, o stream da entrada é só para frente (Length lançou
NotSupportedException).
csharpusing var za = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true);
var e = za.CreateEntry("a.txt", CompressionLevel.Optimal);
using (var s = e.Open()) s.Write(bytes); // 10000 'a' -> zip de 136 bytes em memóriaCada entrada informa Length e CompressedLength: para 10000 bytes idênticos,
10000 e 28. Esses tamanhos vêm do cabeçalho do próprio arquivo, o que os torna
uma checagem antecipada útil e não uma garantia. Nomes de entrada são UTF-8
(ação/ü.txt sobreviveu a uma ida e volta). Name é o último segmento e
FullName é o caminho inteiro; para ../escaped.txt foram escaped.txt e
../escaped.txt.
O .NET 10 adicionou contrapartes assíncronas das operações pesadas (confirmado
em What's new in .NET libraries for .NET 10): ZipArchive.CreateAsync,
ZipArchiveEntry.OpenAsync, ZipFile.OpenAsync, ZipFile.OpenReadAsync,
ZipFile.CreateFromDirectoryAsync, ZipFile.ExtractToDirectoryAsync e os
equivalentes de ZipFileExtensions. A mesma página diz que o modo Update não
carrega mais todas as entradas na memória e que a extração é paralelizada.
Executei o caminho de criar, abrir e ler:
csharpawait using var za = await ZipArchive.CreateAsync(fs, ZipArchiveMode.Create,
leaveOpen: false, entryNameEncoding: null);
await using var s = await za.CreateEntry("hello.txt").OpenAsync();Ler de volta com ZipFile.OpenReadAsync imprimiu hello async. Não medi as
afirmações de desempenho.
Zip slip: onde a extração dá errado
Uma entrada chamada ../escaped.txt é uma entrada de arquivo legal. Um
extrator escrito à mão que faz Path.Combine(dest, entry.FullName) resolve para
a pasta pai; para /abs-escaped.txt, Path.Combine devolve o caminho absoluto e
descarta dest por completo. Os helpers do framework verificam isso: montei um
arquivo com ok/a.txt, ../escaped.txt, /abs-escaped.txt e
..\win-escaped.txt, e ZipFile.ExtractToDirectory lançou IOException
("Extracting Zip entry would have resulted in a file outside the specified
destination directory"), nada foi escrito fora da pasta, e ok/a.txt já tinha
sido extraído antes da falha. Ou seja, a extração não é transacional: extraia
para uma pasta temporária nova e mova em caso de sucesso. A entrada com
contrabarra é outra lição: no macOS o Name foi a string inteira (contrabarra é
um caractere comum lá), mas no Windows é separador. Só executei isto no macOS.
Se você extrai manualmente, resolva e compare, com o separador final para que
/data/out-evil não passe numa checagem de /data/out:
csharpvar full = Path.GetFullPath(Path.Combine(dest, entry.FullName));
var prefix = Path.GetFullPath(dest) + Path.DirectorySeparatorChar;
if (!full.StartsWith(prefix, StringComparison.Ordinal)) throw new InvalidDataException(entry.FullName);A API de tar tem a mesma proteção: TarFile.ExtractToDirectory num arquivo com
uma entrada ../tar-escaped.txt lançou IOException e não escreveu nada. Uma
diferença: o diretório de destino do tar precisou existir antes
(DirectoryNotFoundException caso contrário), enquanto ZipFile o criou.
Tar e a bomba de descompressão
System.Formats.Tar (.NET 7 em diante) escreve e lê tar, que não tem compressão
própria: para .tar.gz você empilha um GZipStream por baixo.
TarFile.CreateFromDirectoryAsync(src, gzip, includeBaseDirectory: false) e
ExtractToDirectoryAsync(gzip, dest, overwriteFiles: false) fizeram a ida e
volta de um diretório, e TarReader mostrou as entradas no formato Pax com
modos Unix (755 para o diretório, 644 para o arquivo). Esse formato e esses
modos são o motivo de tar servir melhor que ZIP para artefatos Linux.
Entrada comprimida também pode mentir sobre o custo. 512 MiB de zeros viraram
521844 bytes com SmallestSize; uma entrada ZIP de 64 MiB de zeros ocupou 65232
bytes. O limite não é a velocidade de descompressão, e sim memória e disco. Limite
o que você aceita contando enquanto copia:
csharpwhile ((n = src.Read(buf)) > 0)
if ((total += n) > limit) throw new InvalidDataException("too large"); // parou em 10546656 de 10485760 permitidosTrade-offs
- Brotli ganha do gzip em texto, gzip está em todo lugar. 17242 contra 50094
bytes aqui, mas um cliente ou ferramenta que só conhece gzip não lê Brotli.csharp
new BrotliStream(output, CompressionLevel.Optimal) // menor; confira se o consumidor suporta SmallestSizeé uma escolha de build. A execução com Brotli levou 633 ms para 785 KB.- ZIP é acesso aleatório, tar é uma sequência. ZIP busca uma entrada pelo
diretório central; um
.tar.gzprecisa ser descomprimido desde o início, mas mantém modos Unix e funciona bem em streaming. ExtractToDirectoryé seguro para nomes e inseguro para volume. Ele barra fugas de caminho, mas não limita número de entradas nem tamanho total, e pode deixar um resultado parcial.- ZIP em memória é simples até o arquivo ficar grande. O modo
Updateem particular guarda estado; useCreateem streaming quando puder. - Não confie no tamanho do cabeçalho como limite. Conte os bytes enquanto lê.