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.

Go di Gabriele Romanato

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/flate implementa DEFLATE raw (RFC 1951), usato all'interno dei file ZIP.
  • compress/zlib aggiunge l'intestazione e il checksum Adler-32 del formato zlib (RFC 1950).
  • compress/gzip aggiunge 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 di flate, gzip e zlib, 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.