miniz-go/miniz.go¶
:material-github: View on GitHub · 385 lines · live source, included at build time
// Package minizgo provides ZIP archive creation and extraction plus raw DEFLATE
// compression helpers, with support for compressing archive entries in parallel.
// It is inspired by the miniz C library (github.com/richgel999/miniz).
package minizgo
import (
"archive/zip"
"bytes"
"compress/flate"
"context"
"errors"
"fmt"
"hash/crc32"
"io"
"math"
"runtime"
"sync"
)
// FileEntry represents a file to be added to a ZIP archive.
type FileEntry struct {
Name string
Data []byte
}
// ZipFile represents an extracted file from a ZIP archive.
type ZipFile struct {
Name string
Data []byte
Size int64
}
// CreateArchive creates a ZIP archive from a list of files.
func CreateArchive(files []FileEntry) ([]byte, error) {
if len(files) == 0 {
return nil, errors.New("no files provided")
}
var buf bytes.Buffer
w := zip.NewWriter(&buf)
for _, file := range files {
if file.Name == "" {
return nil, errors.New("file name cannot be empty")
}
fw, err := w.Create(file.Name)
if err != nil {
w.Close()
return nil, fmt.Errorf("failed to create file %s: %w", file.Name, err)
}
if _, err := fw.Write(file.Data); err != nil {
w.Close()
return nil, fmt.Errorf("failed to write file %s: %w", file.Name, err)
}
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("failed to close archive: %w", err)
}
return buf.Bytes(), nil
}
// MaxDecompressedSize caps how many bytes ExtractArchive and DecompressData will
// produce, guarding against decompression bombs (a small input that inflates to
// gigabytes). For ExtractArchive the cap is on the ARCHIVE TOTAL across all
// entries, not per entry. The default is 256 MiB. Set it to 0 to disable.
//
// It must be set before any concurrent decompression begins; it is read without
// synchronization, so mutating it while a decompress is in flight is a data race.
var MaxDecompressedSize int64 = 256 << 20
// readAllLimited reads all of r, but errors instead of allocating without bound
// once the output would exceed limit. A limit <= 0 means unlimited.
func readAllLimited(r io.Reader, limit int64) ([]byte, error) {
src := r
if limit > 0 {
// +1 so we can distinguish "exactly at the limit" from "over it".
src = io.LimitReader(r, limit+1)
}
data, err := io.ReadAll(src)
if err != nil {
return nil, fmt.Errorf("read: %w", err)
}
if limit > 0 && int64(len(data)) > limit {
return nil, fmt.Errorf("decompressed size exceeds %d-byte limit (adjust MaxDecompressedSize)", limit)
}
return data, nil
}
// ExtractArchive extracts all files from a ZIP archive.
func ExtractArchive(data []byte) ([]ZipFile, error) {
if len(data) == 0 {
return nil, errors.New("empty archive data")
}
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("failed to open archive: %w", err)
}
files := make([]ZipFile, 0, len(r.File))
var total int64 // aggregate decompressed bytes across all entries
for _, f := range r.File {
// Bound each entry by the budget remaining for the whole archive, so a
// many-entry zip bomb can't blow past MaxDecompressedSize in aggregate.
var perEntryLimit int64
if MaxDecompressedSize > 0 {
if perEntryLimit = MaxDecompressedSize - total; perEntryLimit <= 0 {
return nil, fmt.Errorf("archive exceeds the %d-byte limit (adjust MaxDecompressedSize)", MaxDecompressedSize)
}
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("failed to open file %s: %w", f.Name, err)
}
data, err := readAllLimited(rc, perEntryLimit)
rc.Close()
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", f.Name, err)
}
total += int64(len(data))
files = append(files, ZipFile{
Name: f.Name,
Data: data,
Size: int64(len(data)),
})
}
return files, nil
}
// compressedFile holds one file's raw DEFLATE stream plus the metadata needed
// to assemble it into a ZIP entry without re-compressing it.
type compressedFile struct {
name string
compressed []byte
crc uint32
rawSize uint64
index int
err error
}
// MaxBatchSize caps the number of files CreateArchiveConcurrent will accept
// in a single call. Many small files handed to one call can still exhaust
// memory/CPU in aggregate (per-worker compression buffers, goroutine
// overhead) even though ExtractArchive's aggregate byte cap doesn't apply on
// this create path. Set it to 0 to disable the guard.
var MaxBatchSize = 10_000
// CreateArchiveConcurrent creates a ZIP archive from files using parallel compression.
func CreateArchiveConcurrent(ctx context.Context, files []FileEntry) ([]byte, error) {
if len(files) == 0 {
return nil, errors.New("no files provided")
}
if MaxBatchSize > 0 && len(files) > MaxBatchSize {
return nil, fmt.Errorf("batch of %d files exceeds the %d-file limit (adjust MaxBatchSize)",
len(files), MaxBatchSize)
}
numWorkers := runtime.NumCPU()
if numWorkers > len(files) {
numWorkers = len(files)
}
type job struct {
entry FileEntry
index int
}
jobCh := make(chan job, len(files))
resultCh := make(chan compressedFile, len(files))
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
// Check cancellation first: a bare select races between a
// ready job and ctx.Done() (Go picks randomly), so an
// already-canceled context would only be honored
// intermittently.
if ctx.Err() != nil {
return
}
select {
case <-ctx.Done():
return
case j, ok := <-jobCh:
if !ok {
return
}
cf := compressEntry(j.entry)
cf.index = j.index
resultCh <- cf
}
}
}()
}
go func() {
defer close(jobCh)
for i, file := range files {
select {
case <-ctx.Done():
return
case jobCh <- job{entry: file, index: i}:
}
}
}()
go func() {
wg.Wait()
close(resultCh)
}()
results := make([]compressedFile, len(files))
for r := range resultCh {
if r.err != nil {
return nil, fmt.Errorf("failed to compress %q: %w", r.name, r.err)
}
results[r.index] = r
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
return buildRawZip(results)
}
// compressEntry produces the raw DEFLATE stream for one file along with the CRC
// and uncompressed size that a ZIP entry needs. Errors are carried on the
// returned value so workers stay branch-light.
func compressEntry(entry FileEntry) compressedFile {
var buf bytes.Buffer
w, err := flate.NewWriter(&buf, flate.BestCompression)
if err != nil {
return compressedFile{name: entry.Name, err: err}
}
if _, err := w.Write(entry.Data); err != nil {
_ = w.Close()
return compressedFile{name: entry.Name, err: err}
}
if err := w.Close(); err != nil {
return compressedFile{name: entry.Name, err: err}
}
return compressedFile{
name: entry.Name,
compressed: buf.Bytes(),
crc: crc32.ChecksumIEEE(entry.Data),
rawSize: uint64(len(entry.Data)),
}
}
// buildRawZip assembles already-compressed entries into a ZIP using CreateRaw,
// so the parallel compression work is preserved and the archive round-trips
// correctly (writing pre-deflated bytes into a Deflate entry would double-
// compress them).
func buildRawZip(results []compressedFile) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, r := range results {
fh := &zip.FileHeader{
Name: r.name,
Method: zip.Deflate,
CRC32: r.crc,
CompressedSize64: uint64(len(r.compressed)),
UncompressedSize64: r.rawSize,
}
w, err := zw.CreateRaw(fh)
if err != nil {
_ = zw.Close()
return nil, fmt.Errorf("failed to create zip entry %q: %w", r.name, err)
}
if _, err := w.Write(r.compressed); err != nil {
_ = zw.Close()
return nil, fmt.Errorf("failed to write zip entry %q: %w", r.name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("failed to close archive: %w", err)
}
return buf.Bytes(), nil
}
// ListArchive returns the names and sizes of files in a ZIP archive.
func ListArchive(data []byte) ([]ZipFile, error) {
if len(data) == 0 {
return nil, errors.New("empty archive data")
}
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("failed to open archive: %w", err)
}
files := make([]ZipFile, len(r.File))
for i, f := range r.File {
size := f.UncompressedSize64
if size > math.MaxInt64 {
size = math.MaxInt64 // clamp absurd/hostile sizes rather than overflow
}
files[i] = ZipFile{
Name: f.Name,
Size: int64(size), //nolint:gosec // G115: clamped to MaxInt64 above
}
}
return files, nil
}
// CompressData compresses data using DEFLATE algorithm.
func CompressData(data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("empty data")
}
var buf bytes.Buffer
w, err := flate.NewWriter(&buf, flate.BestCompression)
if err != nil {
return nil, fmt.Errorf("failed to create compressor: %w", err)
}
if _, err := w.Write(data); err != nil {
w.Close()
return nil, fmt.Errorf("compression error: %w", err)
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("failed to close compressor: %w", err)
}
return buf.Bytes(), nil
}
// DecompressData decompresses DEFLATE-compressed data.
func DecompressData(data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("empty data")
}
r := flate.NewReader(bytes.NewReader(data))
defer r.Close()
result, err := readAllLimited(r, MaxDecompressedSize)
if err != nil {
return nil, fmt.Errorf("decompression error: %w", err)
}
return result, nil
}
// CompressStream DEFLATE-compresses everything read from src and writes it to
// dst, without buffering the whole input or output in memory. Use it for large
// files where DecompressData/CompressData's all-in-memory model is too costly.
func CompressStream(dst io.Writer, src io.Reader) error {
if dst == nil || src == nil {
return errors.New("nil reader or writer")
}
w, err := flate.NewWriter(dst, flate.BestCompression)
if err != nil {
return fmt.Errorf("create compressor: %w", err)
}
if _, err := io.Copy(w, src); err != nil {
_ = w.Close()
return fmt.Errorf("compress stream: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("finalize compression: %w", err)
}
return nil
}
// DecompressStream inflates DEFLATE data from src into dst as a stream. The
// output is capped at MaxDecompressedSize (set 0 to disable) so a decompression
// bomb cannot exhaust memory or fill the destination unbounded.
func DecompressStream(dst io.Writer, src io.Reader) error {
if dst == nil || src == nil {
return errors.New("nil reader or writer")
}
r := flate.NewReader(src)
defer r.Close()
var reader io.Reader = r
if MaxDecompressedSize > 0 {
reader = io.LimitReader(r, MaxDecompressedSize+1)
}
n, err := io.Copy(dst, reader)
if err != nil {
return fmt.Errorf("decompress stream: %w", err)
}
if MaxDecompressedSize > 0 && n > MaxDecompressedSize {
return fmt.Errorf("decompressed size exceeds %d-byte limit (adjust MaxDecompressedSize)", MaxDecompressedSize)
}
return nil
}