Go e i formati di compressione
In questo articolo vediamo come usare questi package, come estenderli con Brotli e Zstandard e come scrivere codice di estrazione sicuro.
La libreria standard di Go offre un supporto alla compressione tra i più completi e coerenti: i package compress/gzip, compress/zlib, compress/flate, compress/bzip2 e compress/lzw per i codec, archive/zip e archive/tar per gli archivi. Il tutto è costruito sulle interfacce io.Reader e io.Writer, il che rende la compressione componibile con file, connessioni di rete, buffer e handler HTTP senza alcuno sforzo. In questo articolo vediamo come usare questi package, come estenderli con Brotli e Zstandard e come scrivere codice di estrazione sicuro.
Un'architettura basata su Reader e Writer
Tutti i codec della libreria standard seguono lo stesso schema: un costruttore NewWriter avvolge un io.Writer di destinazione e restituisce un writer che comprime ciò che riceve; un costruttore NewReader avvolge un io.Reader sorgente e restituisce un reader che decomprime. Questo significa che la compressione è sempre in streaming per costruzione: la compressione in memoria non è altro che il caso particolare in cui la destinazione è un bytes.Buffer.
Le varianti della famiglia DEFLATE corrispondono a package diversi:
compress/flateimplementa DEFLATE raw (RFC 1951), usato all'interno dei file ZIP.compress/zlibaggiunge l'intestazione e il checksum Adler-32 del formato zlib (RFC 1950).compress/gzipaggiunge l'intestazione Gzip con nome, timestamp e CRC-32 (RFC 1952).
Un dettaglio fondamentale: il writer di compressione mantiene dati in un buffer interno e scrive il trailer (il checksum finale) solo alla chiamata di Close. Dimenticare Close, o ignorarne l'errore, produce file troncati che falliscono in decompressione con unexpected EOF.
Compressione in memoria
package main
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"log"
"strings"
)
// GzipBytes comprime un slice di byte con il livello indicato
func GzipBytes(data []byte, level int) ([]byte, error) {
var buf bytes.Buffer
zw, err := gzip.NewWriterLevel(&buf, level)
if err != nil {
return nil, fmt.Errorf("livello non valido: %w", err)
}
if _, err := zw.Write(data); err != nil {
return nil, err
}
// Close scrive il trailer con CRC-32 e dimensione: va sempre controllato
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// GunzipBytes decomprime dati in formato Gzip
func GunzipBytes(data []byte) ([]byte, error) {
zr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer zr.Close()
return io.ReadAll(zr)
}
func main() {
input := []byte(strings.Repeat("Go rende la compressione semplice. ", 1000))
compressed, err := GzipBytes(input, gzip.BestCompression)
if err != nil {
log.Fatal(err)
}
restored, err := GunzipBytes(compressed)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d -> %d byte, identici: %v\n", len(input), len(compressed), bytes.Equal(input, restored))
}
Le costanti di livello sono condivise da flate, zlib e gzip: NoCompression (0), BestSpeed (1), BestCompression (9), DefaultCompression (-1, equivalente a 6) e HuffmanOnly (-2), che applica solo la codifica di Huffman senza cercare ripetizioni ed è utile per dati già poco ridondanti, per i quali la ricerca di corrispondenze sarebbe tempo sprecato.
Metadati dell'intestazione Gzip
Il formato Gzip prevede un'intestazione con il nome del file originale, un commento e la data di modifica. In Go questi campi sono esposti come campi della struct gzip.Header, incorporata sia in gzip.Writer sia in gzip.Reader. Vanno impostati prima della prima chiamata a Write.
package main
import (
"compress/gzip"
"fmt"
"io"
"log"
"os"
"path/filepath"
)
// GzipFile comprime un file preservando nome e data di modifica nell'intestazione
func GzipFile(source string) (err error) {
in, err := os.Open(source)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
out, err := os.Create(source + ".gz")
if err != nil {
return err
}
// Propaghiamo l'errore di chiusura del file se non ce ne sono stati altri
defer func() {
if cerr := out.Close(); err == nil {
err = cerr
}
}()
zw := gzip.NewWriter(out)
zw.Name = filepath.Base(source)
zw.ModTime = info.ModTime()
zw.Comment = "creato con compress/gzip"
if _, err = io.Copy(zw, in); err != nil {
return err
}
return zw.Close()
}
// PrintGzipHeader mostra i metadati di un file Gzip senza decomprimerlo tutto
func PrintGzipHeader(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
zr, err := gzip.NewReader(f)
if err != nil {
return err
}
defer zr.Close()
fmt.Printf("Nome: %s\nData: %s\nCommento: %s\n", zr.Name, zr.ModTime, zr.Comment)
return nil
}
func main() {
if err := GzipFile("report.csv"); err != nil {
log.Fatal(err)
}
if err := PrintGzipHeader("report.csv.gz"); err != nil {
log.Fatal(err)
}
}
Il gzip.Reader gestisce per default i file multi-membro, cioè più flussi Gzip concatenati, restituendoli come un unico flusso continuo. Se invece si vogliono leggere i membri separatamente, ad esempio per recuperare l'intestazione di ciascuno, si disattiva questo comportamento con zr.Multistream(false) e si chiama zr.Reset per passare al membro successivo.
Streaming con pipeline di Reader e Writer
Poiché tutto è un io.Reader o un io.Writer, le pipeline si costruiscono annidando i costruttori. L'esempio seguente legge un log compresso, filtra le righe con un bufio.Scanner e scrive il risultato in un nuovo file compresso, con un consumo di memoria costante.
package main
import (
"bufio"
"compress/gzip"
"log"
"os"
"strings"
)
// FilterGzipLog copia in un nuovo file compresso solo le righe che contengono needle
func FilterGzipLog(source, destination, needle string) error {
in, err := os.Open(source)
if err != nil {
return err
}
defer in.Close()
zr, err := gzip.NewReader(in)
if err != nil {
return err
}
defer zr.Close()
out, err := os.Create(destination)
if err != nil {
return err
}
defer out.Close()
zw, err := gzip.NewWriterLevel(out, gzip.BestSpeed)
if err != nil {
return err
}
bw := bufio.NewWriter(zw)
scanner := bufio.NewScanner(zr)
// Righe di log molto lunghe superano il limite di default di 64 KiB
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, needle) {
bw.WriteString(line)
bw.WriteByte('\n')
}
}
if err := scanner.Err(); err != nil {
return err
}
// L'ordine di chiusura è dall'esterno verso l'interno: buffer, compressore, file
if err := bw.Flush(); err != nil {
return err
}
if err := zw.Close(); err != nil {
return err
}
return out.Close()
}
func main() {
if err := FilterGzipLog("app.log.gz", "errors.log.gz", " ERROR "); err != nil {
log.Fatal(err)
}
}
Riutilizzare i compressori con sync.Pool
Creare un gzip.Writer ha un costo non trascurabile: alloca diverse centinaia di kilobyte per le tabelle interne. In un servizio che comprime migliaia di messaggi al secondo questo si traduce in pressione sul garbage collector. La soluzione idiomatica è riutilizzare i writer tramite sync.Pool e il metodo Reset, che reinizializza lo stato collegandolo a una nuova destinazione.
package compression
import (
"bytes"
"compress/gzip"
"sync"
)
var writerPool = sync.Pool{
New: func() any {
// Il livello scelto qui viene mantenuto anche dopo Reset
zw, _ := gzip.NewWriterLevel(nil, gzip.DefaultCompression)
return zw
},
}
// Compress comprime data riutilizzando i writer del pool
func Compress(data []byte) ([]byte, error) {
var buf bytes.Buffer
zw := writerPool.Get().(*gzip.Writer)
defer writerPool.Put(zw)
zw.Reset(&buf)
if _, err := zw.Write(data); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
Middleware HTTP per la compressione
Il package net/http decomprime automaticamente le risposte Gzip lato client (il Transport aggiunge Accept-Encoding: gzip e rimuove l'intestazione Content-Encoding dalla risposta), ma lato server la compressione va implementata. Un middleware che avvolge il http.ResponseWriter è la soluzione più pulita.
package main
import (
"compress/gzip"
"log"
"net/http"
"strconv"
"strings"
"sync"
)
var gzPool = sync.Pool{
New: func() any {
zw, _ := gzip.NewWriterLevel(nil, 5)
return zw
},
}
// gzipResponseWriter intercetta le scritture e le invia al compressore
type gzipResponseWriter struct {
http.ResponseWriter
zw *gzip.Writer
}
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
return w.zw.Write(b)
}
func (w *gzipResponseWriter) WriteHeader(status int) {
// La lunghezza originale non è più valida dopo la compressione
w.Header().Del("Content-Length")
w.ResponseWriter.WriteHeader(status)
}
// Flush permette agli handler in streaming di inviare subito i dati
func (w *gzipResponseWriter) Flush() {
w.zw.Flush()
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// acceptsGzip verifica se il client accetta gzip con qualità maggiore di zero
func acceptsGzip(header string) bool {
for _, part := range strings.Split(header, ",") {
name, params, _ := strings.Cut(strings.TrimSpace(part), ";")
name = strings.TrimSpace(name)
if !strings.EqualFold(name, "gzip") && name != "*" {
continue
}
q := 1.0
if v, ok := strings.CutPrefix(strings.TrimSpace(params), "q="); ok {
if parsed, err := strconv.ParseFloat(v, 64); err == nil {
q = parsed
}
}
return q > 0
}
return false
}
// Gzip è il middleware che comprime le risposte quando il client lo consente
func Gzip(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Accept-Encoding")
if !acceptsGzip(r.Header.Get("Accept-Encoding")) || r.Method == http.MethodHead {
next.ServeHTTP(w, r)
return
}
zw := gzPool.Get().(*gzip.Writer)
defer gzPool.Put(zw)
zw.Reset(w)
defer zw.Close()
w.Header().Set("Content-Encoding", "gzip")
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, zw: zw}, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/items", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"items":[` + strings.Repeat(`{"name":"item"},`, 999) + `{"name":"item"}]}`))
})
log.Println("Server in ascolto su :8080")
log.Fatal(http.ListenAndServe(":8080", Gzip(mux)))
}
Un middleware di produzione dovrebbe anche evitare di comprimere risposte molto piccole o con tipi MIME già compressi (immagini, video, archivi), decisione che richiede di bufferizzare i primi byte prima di scegliere. Se si usa Gin, il middleware github.com/gin-contrib/gzip implementa già queste logiche.
Brotli e Zstandard
La libreria standard non include né Brotli né Zstandard, ma l'ecosistema offre implementazioni in puro Go di ottima qualità, quindi senza dipendenze da cgo:
github.com/andybalholm/brotli, una traduzione in Go della libreria di riferimento di Google;github.com/klauspost/compress/zstd, parte di un modulo che include anche versioni ottimizzate e compatibili diflate,gzipezlib, spesso sensibilmente più veloci di quelle standard.
Entrambe seguono le stesse convenzioni della libreria standard, quindi il codice resta familiare.
package main
import (
"bytes"
"fmt"
"io"
"log"
"strings"
"github.com/andybalholm/brotli"
"github.com/klauspost/compress/zstd"
)
func brotliCompress(data []byte, quality int) ([]byte, error) {
var buf bytes.Buffer
bw := brotli.NewWriterLevel(&buf, quality)
if _, err := bw.Write(data); err != nil {
return nil, err
}
if err := bw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func brotliDecompress(data []byte) ([]byte, error) {
return io.ReadAll(brotli.NewReader(bytes.NewReader(data)))
}
// Encoder e decoder zstd sono sicuri per l'uso concorrente con EncodeAll e DecodeAll:
// conviene crearli una sola volta e riutilizzarli
var (
zstdEncoder, _ = zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedBetterCompression))
zstdDecoder, _ = zstd.NewReader(nil, zstd.WithDecoderMaxMemory(64<<20))
)
func main() {
input := []byte(strings.Repeat(`{"level":"info","msg":"request handled","status":200}`+"\n", 2000))
br, err := brotliCompress(input, brotli.BestCompression)
if err != nil {
log.Fatal(err)
}
restoredBr, err := brotliDecompress(br)
if err != nil {
log.Fatal(err)
}
zs := zstdEncoder.EncodeAll(input, nil)
restoredZs, err := zstdDecoder.DecodeAll(zs, nil)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Originale: %d byte\n", len(input))
fmt.Printf("Brotli: %d byte (ok: %v)\n", len(br), bytes.Equal(input, restoredBr))
fmt.Printf("Zstd: %d byte (ok: %v)\n", len(zs), bytes.Equal(input, restoredZs))
}
L'opzione WithDecoderMaxMemory merita attenzione: limita la memoria che il decoder può allocare e costituisce la prima linea di difesa contro input malevoli quando si decomprimono dati provenienti dall'esterno.
Archivi ZIP
Il package archive/zip permette di creare e leggere archivi ZIP. Ogni voce si aggiunge con Create o, per un controllo completo sui metadati, con CreateHeader; il writer restituito resta valido solo fino all'aggiunta della voce successiva. Da Go 1.22 il metodo AddFS aggiunge in un colpo solo il contenuto di un intero fs.FS.
package main
import (
"archive/zip"
"compress/flate"
"io"
"log"
"os"
)
// ZipDirectory crea un archivio ZIP con tutto il contenuto di una directory
func ZipDirectory(sourceDir, zipPath string) error {
out, err := os.Create(zipPath)
if err != nil {
return err
}
defer out.Close()
zw := zip.NewWriter(out)
// Sostituiamo il compressore DEFLATE di default con uno al livello massimo
zw.RegisterCompressor(zip.Deflate, func(w io.Writer) (io.WriteCloser, error) {
return flate.NewWriter(w, flate.BestCompression)
})
if err := zw.AddFS(os.DirFS(sourceDir)); err != nil {
zw.Close()
return err
}
// Close scrive la central directory: senza di essa l'archivio è illeggibile
if err := zw.Close(); err != nil {
return err
}
return out.Close()
}
func main() {
if err := ZipDirectory("./docs", "docs.zip"); err != nil {
log.Fatal(err)
}
}
Quando l'archivio contiene file già compressi, come immagini o video, conviene memorizzarli senza compressione impostando Method: zip.Store nel zip.FileHeader: si risparmia CPU senza perdere nulla in termini di spazio.
Estrazione sicura e zip slip
L'estrazione è la parte delicata. Un archivio malevolo può contenere voci con percorsi come ../../.ssh/authorized_keys che, uniti ingenuamente alla directory di destinazione con filepath.Join, finiscono fuori da essa. A partire da Go 1.20 la funzione filepath.IsLocal verifica in modo affidabile che un percorso sia relativo e non risalga oltre la radice; da Go 1.24 il tipo os.Root offre una protezione ancora più forte, impedendo anche le uscite tramite link simbolici. Oltre ai percorsi vanno limitati il numero di voci e la dimensione totale estratta, per difendersi dalle decompression bomb.
package main
import (
"archive/zip"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
)
var ErrArchiveTooLarge = errors.New("archivio troppo grande una volta estratto")
// UnzipSafely estrae un archivio ZIP proteggendosi da zip slip e decompression bomb
func UnzipSafely(zipPath, destination string, maxTotal int64) error {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer zr.Close()
if err := os.MkdirAll(destination, 0o755); err != nil {
return err
}
// os.Root impedisce qualsiasi accesso fuori dalla directory, anche tramite symlink
root, err := os.OpenRoot(destination)
if err != nil {
return err
}
defer root.Close()
var written int64
for _, f := range zr.File {
if !filepath.IsLocal(f.Name) {
return fmt.Errorf("percorso non sicuro nell'archivio: %q", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(filepath.Join(destination, f.Name), 0o755); err != nil {
return err
}
continue
}
// I symlink negli archivi sono un vettore di attacco: li ignoriamo
if f.Mode()&os.ModeSymlink != 0 {
continue
}
if err := os.MkdirAll(filepath.Join(destination, filepath.Dir(f.Name)), 0o755); err != nil {
return err
}
n, err := extractFile(root, f, maxTotal-written)
if err != nil {
return err
}
written += n
}
return nil
}
func extractFile(root *os.Root, f *zip.File, remaining int64) (int64, error) {
rc, err := f.Open()
if err != nil {
return 0, err
}
defer rc.Close()
out, err := root.OpenFile(f.Name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return 0, err
}
defer out.Close()
// Non ci fidiamo della dimensione dichiarata: leggiamo al massimo remaining+1 byte
n, err := io.Copy(out, io.LimitReader(rc, remaining+1))
if err != nil {
return n, err
}
if n > remaining {
return n, ErrArchiveTooLarge
}
return n, nil
}
func main() {
if err := UnzipSafely("upload.zip", "./extracted", 512<<20); err != nil {
log.Fatal(err)
}
}
Il pattern io.LimitReader(rc, remaining+1) è il modo idiomatico per imporre un tetto alla decompressione di qualsiasi formato: leggere un byte oltre il limite permette di distinguere un file che lo raggiunge esattamente da uno che lo supera.
Archivi TAR compressi
Il formato TAR non comprime: si limita a concatenare file e metadati Unix (permessi, proprietario, link). La compressione si ottiene componendo archive/tar con un codec, esattamente come fa la riga di comando con tar czf. L'esempio crea un .tar.gz percorrendo una directory con filepath.WalkDir.
package main
import (
"archive/tar"
"compress/gzip"
"io"
"io/fs"
"log"
"os"
"path/filepath"
)
// CreateTarGz archivia e comprime una directory preservando i permessi
func CreateTarGz(sourceDir, archivePath string) error {
out, err := os.Create(archivePath)
if err != nil {
return err
}
defer out.Close()
zw := gzip.NewWriter(out)
tw := tar.NewWriter(zw)
err = filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
info, err := d.Info()
if err != nil {
return err
}
rel, err := filepath.Rel(sourceDir, path)
if err != nil || rel == "." {
return err
}
hdr, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
// Nei TAR i separatori sono sempre slash, anche su Windows
hdr.Name = filepath.ToSlash(rel)
if d.IsDir() {
hdr.Name += "/"
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if !info.Mode().IsRegular() {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(tw, f)
return err
})
if err != nil {
return err
}
// Chiusura in ordine: prima l'archivio, poi il compressore, infine il file
if err := tw.Close(); err != nil {
return err
}
if err := zw.Close(); err != nil {
return err
}
return out.Close()
}
func main() {
if err := CreateTarGz("./project", "project.tar.gz"); err != nil {
log.Fatal(err)
}
}
Sostituendo gzip.NewWriter con zstd.NewWriter di klauspost si ottiene un .tar.zst senza toccare il resto del codice: è il vantaggio concreto di un design fondato su interfacce piccole e componibili. In lettura vale lo stesso schema, con tar.NewReader e un ciclo su Next, applicando a ogni hdr.Name gli stessi controlli visti per lo ZIP.
Conclusioni
In Go la compressione si riduce alla composizione di io.Reader e io.Writer: una volta compreso lo schema, passare da Gzip a Zstandard o da ZIP a TAR richiede di cambiare poche righe. Le insidie sono altrettanto ricorrenti: controllare sempre l'errore di Close sui writer, riutilizzare i compressori con sync.Pool nei percorsi critici, limitare la decompressione con io.LimitReader e validare i percorsi degli archivi con filepath.IsLocal o, meglio ancora, con os.Root.