Java e i formati di compressione
Il JDK copre in modo nativo Gzip, DEFLATE e ZIP con un'API stabile da decenni, e Apache Commons Compress estende lo stesso modello a tutti gli altri formati.
Java include il supporto alla compressione fin dalle prime versioni, con il package java.util.zip: Gzip, DEFLATE e zlib tramite Deflater e Inflater, archivi ZIP e JAR, checksum CRC-32, Adler-32 e CRC-32C. Per tutto il resto, come TAR, bzip2, XZ, Brotli e Zstandard, l'ecosistema offre librerie mature come Apache Commons Compress, zstd-jni e Brotli4j. In questo articolo vediamo come usare questi strumenti con Java moderno, dalla compressione in memoria agli stream, dal file system ZIP all'estrazione sicura degli archivi.
Le classi di java.util.zip
Il package si articola su due livelli. Al livello più basso ci sono Deflater e Inflater, che incapsulano la libreria zlib nativa e lavorano su array di byte o ByteBuffer. Al livello superiore ci sono gli stream decoratori, che si integrano con l'architettura di java.io:
GZIPOutputStreameGZIPInputStreamper il formato Gzip;DeflaterOutputStreameInflaterInputStreamper il formato zlib, o per DEFLATE raw configurando ilDeflaterconnowrap;ZipOutputStreameZipInputStreamper scrivere e leggere archivi ZIP in streaming, oltre aZipFileper l'accesso casuale alle voci.
Poiché Deflater e Inflater allocano memoria nativa fuori dall'heap, è essenziale chiamarne il metodo end() in un blocco finally quando non servono più: affidarsi al garbage collector significa trattenere memoria nativa per un tempo indefinito, con effetti visibili nei servizi che comprimono molti messaggi.
Compressione Gzip in memoria
Per comprimere un array di byte in memoria si combina GZIPOutputStream con un ByteArrayOutputStream. Il trailer Gzip, con CRC-32 e dimensione originale, viene scritto solo alla chiusura dello stream: per questo il risultato va letto dopo la fine del blocco try-with-resources.
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
public final class GzipBytes {
private GzipBytes() {
}
public static byte[] compress(byte[] data) {
var buffer = new ByteArrayOutputStream(Math.max(64, data.length / 4));
try (var gzip = new GZIPOutputStream(buffer)) {
gzip.write(data);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
// Lo stream è chiuso: il trailer Gzip è stato scritto
return buffer.toByteArray();
}
public static byte[] decompress(byte[] data) {
try (var gzip = new GZIPInputStream(new ByteArrayInputStream(data))) {
return gzip.readAllBytes();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public static void main(String[] args) {
byte[] input = "Java comprime da sempre. ".repeat(2000).getBytes(StandardCharsets.UTF_8);
byte[] compressed = compress(input);
byte[] restored = decompress(compressed);
System.out.printf("%d -> %d byte, identici: %b%n",
input.length, compressed.length, Arrays.equals(input, restored));
}
}
Impostare il livello di compressione
Un limite storico di GZIPOutputStream è l'assenza di un costruttore che accetti il livello di compressione. Il Deflater interno è però un campo protected chiamato def, quindi lo si può configurare in una sottoclasse. È un idioma diffuso e perfettamente legittimo.
import java.io.IOException;
import java.io.OutputStream;
import java.util.zip.Deflater;
import java.util.zip.GZIPOutputStream;
/**
* GZIPOutputStream con livello e strategia di compressione configurabili.
*/
public class ConfigurableGzipOutputStream extends GZIPOutputStream {
public ConfigurableGzipOutputStream(OutputStream out, int level) throws IOException {
this(out, level, Deflater.DEFAULT_STRATEGY);
}
public ConfigurableGzipOutputStream(OutputStream out, int level, int strategy) throws IOException {
// Buffer di 64 KiB invece dei 512 byte di default: meno chiamate native
super(out, 64 * 1024);
def.setLevel(level);
def.setStrategy(strategy);
}
}
Le costanti disponibili sono Deflater.NO_COMPRESSION (0), BEST_SPEED (1), BEST_COMPRESSION (9) e DEFAULT_COMPRESSION (-1, equivalente a 6). Anche la dimensione del buffer merita attenzione: il default di 512 byte è un retaggio del passato e aumentarlo migliora sensibilmente le prestazioni con file di grandi dimensioni.
Deflater e Inflater a basso livello
Quando servono il formato zlib o DEFLATE raw, oppure un controllo fine sul processo, si usano direttamente Deflater e Inflater. Il parametro nowrap del costruttore elimina intestazione e checksum zlib, producendo il DEFLATE raw usato nei file ZIP e in protocolli come WebSocket con l'estensione permessage-deflate.
import java.io.ByteArrayOutputStream;
import java.util.zip.DataFormatException;
import java.util.zip.Deflater;
import java.util.zip.Inflater;
public final class RawDeflate {
private RawDeflate() {
}
public static byte[] deflate(byte[] input, int level, boolean nowrap) {
var deflater = new Deflater(level, nowrap);
try {
deflater.setInput(input);
deflater.finish();
var out = new ByteArrayOutputStream(input.length / 2 + 64);
var buffer = new byte[8192];
// deflate va chiamato finché il compressore non segnala di aver finito
while (!deflater.finished()) {
int n = deflater.deflate(buffer);
out.write(buffer, 0, n);
}
return out.toByteArray();
} finally {
// Libera la memoria nativa di zlib
deflater.end();
}
}
public static byte[] inflate(byte[] input, boolean nowrap, int maxOutput) throws DataFormatException {
var inflater = new Inflater(nowrap);
try {
inflater.setInput(input);
var out = new ByteArrayOutputStream();
var buffer = new byte[8192];
while (!inflater.finished()) {
int n = inflater.inflate(buffer);
if (n == 0 && (inflater.needsInput() || inflater.needsDictionary())) {
throw new DataFormatException("Flusso compresso troncato o incompleto");
}
out.write(buffer, 0, n);
// Limite contro le decompression bomb
if (out.size() > maxOutput) {
throw new DataFormatException("Output oltre il limite di " + maxOutput + " byte");
}
}
return out.toByteArray();
} finally {
inflater.end();
}
}
public static void main(String[] args) throws DataFormatException {
byte[] data = "permessage-deflate usa DEFLATE raw. ".repeat(500).getBytes();
byte[] zlib = deflate(data, Deflater.DEFAULT_COMPRESSION, false);
byte[] raw = deflate(data, Deflater.DEFAULT_COMPRESSION, true);
System.out.printf("zlib: %d byte, raw: %d byte%n", zlib.length, raw.length);
System.out.println(new String(inflate(raw, true, 1 << 20)).length() == data.length);
}
}
Quando si usa nowrap in decompressione, la documentazione di Inflater raccomanda di fornire un byte fittizio aggiuntivo in coda all'input in alcuni casi limite della libreria zlib. Nella pratica, con le versioni moderne del JDK e flussi completi, questo accorgimento raramente è necessario, ma va tenuto presente se si osservano errori sporadici di fine flusso.
Streaming di file di grandi dimensioni
Gli stream decoratori permettono di comprimere e decomprimere file di qualsiasi dimensione con un consumo di memoria costante. Il metodo transferTo, disponibile da Java 9, elimina la necessità di scrivere a mano il ciclo di copia.
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.Deflater;
import java.util.zip.GZIPInputStream;
public final class GzipFiles {
private static final int BUFFER_SIZE = 64 * 1024;
private GzipFiles() {
}
public static void compress(Path source, Path destination, int level) throws IOException {
try (var in = Files.newInputStream(source);
var out = new ConfigurableGzipOutputStream(Files.newOutputStream(destination), level)) {
in.transferTo(out);
}
}
public static void decompress(Path source, Path destination) throws IOException {
try (var in = new GZIPInputStream(Files.newInputStream(source), BUFFER_SIZE);
var out = Files.newOutputStream(destination)) {
in.transferTo(out);
}
}
/**
* Copia in un nuovo file compresso solo le righe che contengono il testo indicato.
*/
public static long filterLines(Path source, Path destination, String needle) throws IOException {
long matched = 0;
try (var reader = new BufferedReader(new InputStreamReader(
new GZIPInputStream(Files.newInputStream(source), BUFFER_SIZE), StandardCharsets.UTF_8));
var writer = new BufferedWriter(new OutputStreamWriter(
new ConfigurableGzipOutputStream(Files.newOutputStream(destination), Deflater.BEST_SPEED),
StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.contains(needle)) {
writer.write(line);
writer.newLine();
matched++;
}
}
}
return matched;
}
public static void main(String[] args) throws IOException {
compress(Path.of("app.log"), Path.of("app.log.gz"), Deflater.BEST_COMPRESSION);
long errors = filterLines(Path.of("app.log.gz"), Path.of("errors.log.gz"), " ERROR ");
System.out.println("Righe di errore: " + errors);
}
}
Chiudere lo stream più esterno chiude a cascata tutti quelli interni: è sufficiente dichiarare nel try-with-resources solo gli stream di più alto livello. Un aspetto da conoscere è che GZIPInputStream legge automaticamente i file multi-membro, cioè più flussi Gzip concatenati, restituendoli come un flusso unico.
Creare archivi ZIP
La classe ZipOutputStream scrive un archivio ZIP voce per voce: si apre una voce con putNextEntry, se ne scrive il contenuto e la si chiude con closeEntry. Il livello di compressione si imposta con setLevel e vale per tutte le voci successive; il metodo per singola voce si sceglie con ZipEntry.setMethod.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.Set;
import java.util.zip.CRC32;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
public final class ZipCreator {
// Estensioni di file già compressi
private static final Set<String> STORED = Set.of("jpg", "jpeg", "png", "gif", "webp", "mp4", "zip", "gz", "pdf");
private ZipCreator() {
}
public static void zipDirectory(Path sourceDir, Path zipPath) throws IOException {
try (var zos = new ZipOutputStream(Files.newOutputStream(zipPath));
var paths = Files.walk(sourceDir)) {
zos.setLevel(Deflater.BEST_COMPRESSION);
for (Path path : (Iterable<Path>) paths.filter(Files::isRegularFile).sorted()::iterator) {
// Nei file ZIP il separatore è sempre lo slash
String name = sourceDir.relativize(path).toString().replace('\\', '/');
var entry = new ZipEntry(name);
entry.setLastModifiedTime(FileTime.from(Files.getLastModifiedTime(path).toInstant()));
if (STORED.contains(extension(name))) {
// Le voci STORED richiedono dimensione e CRC noti prima della scrittura
byte[] content = Files.readAllBytes(path);
var crc = new CRC32();
crc.update(content);
entry.setMethod(ZipEntry.STORED);
entry.setSize(content.length);
entry.setCompressedSize(content.length);
entry.setCrc(crc.getValue());
zos.putNextEntry(entry);
zos.write(content);
} else {
zos.putNextEntry(entry);
Files.copy(path, zos);
}
zos.closeEntry();
}
}
}
private static String extension(String name) {
int dot = name.lastIndexOf('.');
return dot < 0 ? "" : name.substring(dot + 1).toLowerCase();
}
public static void main(String[] args) throws IOException {
zipDirectory(Path.of("docs"), Path.of("docs.zip"));
}
}
Il vincolo sulle voci STORED è una conseguenza del formato: ZipOutputStream scrive l'intestazione locale prima dei dati e, per le voci non compresse, non può usare il data descriptor che segue i dati. Per questo dimensione e CRC devono essere noti in anticipo. Per file grandi conviene calcolarli con una prima lettura in streaming invece di caricare il file in memoria.
Il file system ZIP
Dal JDK 7 esiste un FileSystemProvider per gli archivi ZIP, che permette di trattare un archivio come un file system e di usarlo con le normali API di java.nio.file. È particolarmente comodo per aggiungere, sostituire o leggere singoli file all'interno di un archivio esistente senza riscriverlo a mano.
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Map;
public final class ZipFileSystemDemo {
public static void main(String[] args) throws IOException {
Path zip = Path.of("bundle.zip").toAbsolutePath();
// create=true crea l'archivio se non esiste
Map<String, String> env = Map.of("create", "true");
try (var fs = FileSystems.newFileSystem(URI.create("jar:" + zip.toUri()), env)) {
Path config = fs.getPath("/config/app.properties");
Files.createDirectories(config.getParent());
Files.writeString(config, "app.name=demo\napp.version=1.0\n", StandardCharsets.UTF_8);
Files.copy(Path.of("README.md"), fs.getPath("/README.md"), StandardCopyOption.REPLACE_EXISTING);
// Lettura con le stesse API usate per il file system ordinario
try (var stream = Files.walk(fs.getPath("/"))) {
stream.filter(Files::isRegularFile).forEach(p -> System.out.println(p));
}
}
// Alla chiusura del FileSystem l'archivio viene riscritto su disco
}
}
Estrazione sicura degli archivi ZIP
La vulnerabilità nota come zip slip, scoperta nel 2018 in decine di librerie Java, nasce da codice di estrazione che concatena ingenuamente la directory di destinazione con il nome della voce. Un nome come ../../../home/app/.bashrc porta il file fuori dalla destinazione. Con NIO la difesa è semplice: si normalizza il percorso risultante e si verifica con startsWith che resti sotto la directory di destinazione. A questa verifica vanno aggiunti limiti su numero di voci e dimensione estratta.
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
public final class SafeUnzip {
public record Limits(int maxEntries, long maxTotalBytes, double maxRatio) {
public static Limits defaults() {
return new Limits(10_000, 1L << 30, 200.0);
}
}
public static final class UnsafeArchiveException extends IOException {
private static final long serialVersionUID = 1L;
public UnsafeArchiveException(String message) {
super(message);
}
}
private SafeUnzip() {
}
public static void extract(Path zipPath, Path destination, Limits limits) throws IOException {
Path root = destination.toAbsolutePath().normalize();
Files.createDirectories(root);
try (var zip = new ZipFile(zipPath.toFile())) {
if (zip.size() > limits.maxEntries()) {
throw new UnsafeArchiveException("Troppe voci: " + zip.size());
}
long written = 0;
var entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
Path target = root.resolve(entry.getName()).normalize();
// Il controllo che impedisce lo zip slip
if (!target.startsWith(root)) {
throw new UnsafeArchiveException("Percorso non sicuro: " + entry.getName());
}
long size = entry.getSize();
long compressed = entry.getCompressedSize();
if (size > 0 && compressed > 0 && (double) size / compressed > limits.maxRatio()) {
throw new UnsafeArchiveException("Rapporto di compressione sospetto: " + entry.getName());
}
if (entry.isDirectory()) {
Files.createDirectories(target);
continue;
}
Files.createDirectories(target.getParent());
try (InputStream in = zip.getInputStream(entry);
OutputStream out = Files.newOutputStream(target)) {
written += copyWithLimit(in, out, limits.maxTotalBytes() - written);
}
}
}
}
// Conta i byte reali: le dimensioni dichiarate nelle intestazioni possono essere falsificate
private static long copyWithLimit(InputStream in, OutputStream out, long remaining) throws IOException {
var buffer = new byte[64 * 1024];
long total = 0;
int n;
while ((n = in.read(buffer)) != -1) {
total += n;
if (total > remaining) {
throw new UnsafeArchiveException("Dimensione totale estratta oltre il limite");
}
out.write(buffer, 0, n);
}
return total;
}
public static void main(String[] args) throws IOException {
extract(Path.of("upload.zip"), Path.of("extracted"), Limits.defaults());
}
}
Si noti che Path.startsWith confronta componenti del percorso e non stringhe: /tmp/out-evil non risulta sotto /tmp/out, un errore classico di chi usa String.startsWith. Dalle versioni più recenti del JDK, inoltre, ZipFile rifiuta di default alcune anomalie strutturali, ma il controllo sui percorsi resta responsabilità dell'applicazione.
TAR, XZ e Zstandard con Apache Commons Compress
Per tutti i formati non coperti dal JDK la libreria di riferimento è Apache Commons Compress, che offre un'API uniforme per TAR, CPIO, 7z, bzip2, XZ, LZ4, Zstandard e altri. Alcuni codec delegano a librerie aggiuntive: XZ richiede org.tukaani:xz e Zstandard richiede com.github.luben:zstd-jni. Le dipendenze Maven sono:
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.28.0</version>
</dependency>
<dependency>
<groupId>com.github.luben</groupId>
<artifactId>zstd-jni</artifactId>
<version>1.5.7-24</version>
</dependency>
</dependencies>
Le versioni indicate sono quelle disponibili al momento della scrittura e conviene sempre verificare l'ultima release su Maven Central: nel caso di zstd-jni, in particolare, le versioni precedenti alla 1.5.7-14 sono interessate da diverse vulnerabilità nella validazione dei parametri.
L'esempio seguente crea un archivio .tar.zst di una directory e lo rilegge, applicando anche qui la verifica sui percorsi.
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.commons.compress.compressors.zstandard.ZstdCompressorInputStream;
import org.apache.commons.compress.compressors.zstandard.ZstdCompressorOutputStream;
public final class TarZstd {
private TarZstd() {
}
public static void create(Path sourceDir, Path archive, int level) throws IOException {
try (var out = new TarArchiveOutputStream(new ZstdCompressorOutputStream(
new BufferedOutputStream(Files.newOutputStream(archive)), level));
var paths = Files.walk(sourceDir)) {
// Supporto a nomi lunghi e file oltre 8 GiB con le estensioni POSIX
out.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);
out.setBigNumberMode(TarArchiveOutputStream.BIGNUMBER_POSIX);
for (Path path : (Iterable<Path>) paths.sorted()::iterator) {
if (path.equals(sourceDir)) {
continue;
}
String name = sourceDir.relativize(path).toString().replace('\\', '/');
var entry = new TarArchiveEntry(path, name);
out.putArchiveEntry(entry);
if (Files.isRegularFile(path)) {
Files.copy(path, out);
}
out.closeArchiveEntry();
}
out.finish();
}
}
public static void extract(Path archive, Path destination) throws IOException {
Path root = destination.toAbsolutePath().normalize();
try (var in = new TarArchiveInputStream(new ZstdCompressorInputStream(
new BufferedInputStream(Files.newInputStream(archive))))) {
TarArchiveEntry entry;
while ((entry = in.getNextEntry()) != null) {
Path target = root.resolve(entry.getName()).normalize();
if (!target.startsWith(root)) {
throw new IOException("Percorso non sicuro: " + entry.getName());
}
// Link simbolici, hard link e file speciali vengono ignorati
if (entry.isDirectory()) {
Files.createDirectories(target);
} else if (entry.isFile()) {
Files.createDirectories(target.getParent());
Files.copy(in, target);
}
}
}
}
public static void main(String[] args) throws IOException {
create(Path.of("project"), Path.of("project.tar.zst"), 10);
extract(Path.of("project.tar.zst"), Path.of("restored"));
}
}
Sostituendo ZstdCompressorOutputStream con GzipCompressorOutputStream, o semplicemente con GZIPOutputStream del JDK, si ottiene un .tar.gz senza modificare il resto del codice. Per Brotli, invece, Commons Compress offre solo la decompressione; per comprimere si usa la libreria Brotli4j, che incapsula l'implementazione nativa di Google.
Compressione delle risposte HTTP
Lato client, il java.net.http.HttpClient introdotto con Java 11 non decomprime automaticamente le risposte: bisogna inviare l'intestazione Accept-Encoding e gestire la decompressione esplicitamente in base a Content-Encoding.
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.zip.GZIPInputStream;
import java.util.zip.InflaterInputStream;
public final class CompressedHttpClient {
private static final HttpClient CLIENT = HttpClient.newHttpClient();
public static String get(String url) throws IOException, InterruptedException {
var request = HttpRequest.newBuilder(URI.create(url))
.header("Accept-Encoding", "gzip, deflate")
.GET()
.build();
HttpResponse<InputStream> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofInputStream());
String encoding = response.headers().firstValue("Content-Encoding").orElse("identity");
// Scegliamo il decoder in base alla codifica dichiarata dal server
InputStream body = switch (encoding.toLowerCase()) {
case "gzip" -> new GZIPInputStream(response.body());
case "deflate" -> new InflaterInputStream(response.body());
default -> response.body();
};
try (body) {
return new String(body.readAllBytes(), StandardCharsets.UTF_8);
}
}
public static void main(String[] args) throws Exception {
String html = get("https://example.com/");
System.out.println("Caratteri ricevuti: " + html.length());
}
}
Lato server, i framework come Spring Boot abilitano la compressione con una semplice proprietà (server.compression.enabled=true), configurando soglia minima e tipi MIME tramite server.compression.min-response-size e server.compression.mime-types.
Conclusioni
Il JDK copre in modo nativo Gzip, DEFLATE e ZIP con un'API stabile da decenni, e Apache Commons Compress estende lo stesso modello a tutti gli altri formati. Le accortezze da ricordare sono specifiche dell'ecosistema: liberare sempre la memoria nativa di Deflater e Inflater, usare buffer più grandi dei default, ricorrere alla sottoclasse di GZIPOutputStream per impostare il livello e validare i percorsi degli archivi con Path.normalize e Path.startsWith, contando i byte effettivamente estratti per difendersi dalle decompression bomb.