Skip to content

compress/flate: Writer with a preset dictionary emits the dictionary into the output stream #80538

Description

@joechenrh

Go version

go version go1.26.0 darwin/arm64 (also reproduced with go1.25.0; still present in master by source inspection, see below)

Output of go env in your module/workspace

AR='ar'
CC='clang'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='clang++'
GCCGO='gccgo'
GO111MODULE='on'
GOARCH='arm64'
GOARM64='v8.0'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/Users/joechenrh/Library/Caches/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/Users/joechenrh/Library/Application Support/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -arch arm64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -ffile-prefix-map=/var/folders/9z/p3_qpprn0y3dtqs0kcsqtctc0000gn/T/go-build264614243=/tmp/go-build -gno-record-gcc-switches -fno-common'
GOHOSTARCH='arm64'
GOHOSTOS='darwin'
GOINSECURE=''
GOMOD='/private/tmp/claude-501/-Users-joechenrh-code-arrow-go/abd53a25-8005-454a-b855-ea0076bb0d13/scratchpad/stdlibdict/go.mod'
GOMODCACHE='/Users/joechenrh/go/pkg/mod'
GOOS='darwin'
GOPATH='/Users/joechenrh/go'
GOPROXY='https://goproxy.cn,direct'
GOROOT='/Users/joechenrh/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.0.darwin-arm64'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/Users/joechenrh/Library/Application Support/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='go1.26.0'
GOTOOLDIR='/Users/joechenrh/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.0.darwin-arm64/pkg/tool/darwin_arm64'
GOVCS=''
GOVERSION='go1.26.0'
GOWORK=''
PKG_CONFIG='pkg-config'

What did you do?

Compressed hard-to-compress (random) data with flate.NewWriterDict and decompressed it with flate.NewReaderDict using the same dictionary:

package main

import (
	"bytes"
	"compress/flate"
	"fmt"
	"io"
	"math/rand"
)

func main() {
	data := make([]byte, 763)
	rand.New(rand.NewSource(42)).Read(data)
	dict := []byte("0123456789abcdefghij")
	for lvl := range 10 {
		var comp bytes.Buffer
		w, _ := flate.NewWriterDict(&comp, lvl, dict)
		w.Write(data)
		w.Close()
		got, err := io.ReadAll(flate.NewReaderDict(bytes.NewReader(comp.Bytes()), dict))
		fmt.Printf("level %d: err=%v roundtripEqual=%v gotLen=%d wantLen=%d dictPrefix=%v\n",
			lvl, err, bytes.Equal(got, data), len(got), len(data), bytes.HasPrefix(got, dict))
	}
}

What did you see happen?

At levels 2–9 the round trip does not return the written data: the decompressed output is the preset dictionary followed by the data (783 = 20 + 763 bytes), with no error:

level 0: err=<nil> roundtripEqual=true  gotLen=763 wantLen=763 dictPrefix=false
level 1: err=<nil> roundtripEqual=true  gotLen=763 wantLen=763 dictPrefix=false
level 2: err=<nil> roundtripEqual=false gotLen=783 wantLen=763 dictPrefix=true
level 3: err=<nil> roundtripEqual=false gotLen=783 wantLen=763 dictPrefix=true
level 4: err=<nil> roundtripEqual=false gotLen=783 wantLen=763 dictPrefix=true
level 5: err=<nil> roundtripEqual=false gotLen=783 wantLen=763 dictPrefix=true
level 6: err=<nil> roundtripEqual=false gotLen=783 wantLen=763 dictPrefix=true
level 7: err=<nil> roundtripEqual=false gotLen=783 wantLen=763 dictPrefix=true
level 8: err=<nil> roundtripEqual=false gotLen=783 wantLen=763 dictPrefix=true
level 9: err=<nil> roundtripEqual=false gotLen=783 wantLen=763 dictPrefix=true

This is a writer-side bug, not reader-side: Python's zlib.decompressobj(-15, zdict=b"0123456789abcdefghij") decodes the same stream to dict + data too. So the compressed stream itself contains the dictionary bytes.

Root cause. NewWriterDict primes the compressor via (*compressor).fillWindow(dict), which copies the dictionary into d.window, advances d.windowEnd and the match index, but never advances d.blockStart (it stays 0):

// Update window information.
d.windowEnd += n
s.index = n
}

When the first block is emitted, writeBlock passes the raw input window alongside the tokens:

    window = d.window[d.blockStart:index]   // blockStart == 0
    d.blockStart = index
    d.w.writeBlockDynamic(tok, eof, window, d.sync)

The huffman bit writer uses window as a fallback. It compares two ways to encode the block: the huffman-coded tokens, or the raw bytes of window stored as-is. If the raw form is smaller, it writes window directly into the output. But here window starts with the 20 dictionary bytes, which the user never wrote.

For compressible data the token form always wins, so the bug never shows up. That is probably why it was not noticed before. For incompressible data the stored form wins, and the dictionary bytes go into the output.

Consequences:

  • Only the first block can leak. After that, blockStart is advanced correctly. The leak is always the whole dictionary, at the very start of the output.
  • Raw flate users get silent data corruption. The stream is valid DEFLATE, so every decoder outputs dict + data without any error.
  • zlib.NewWriterLevelDict users get a confusing zlib: invalid checksum instead: the writer computes Adler-32 over the written data, but the reader computes it over dict + data.

Master has the same bug. The package was rewritten in 388145c, based on github.com/klauspost/compress/flate, and its fillWindow also never updates blockStart. On master, levels 7–9 are affected (the lazy path). Levels 2–6 use the new fast encoders and are fine: they do not keep the dictionary in d.window. I reproduced the same failure with github.com/klauspost/compress/flate and will report it there as well.

Suggested fix: one line at the end of fillWindow. I verified it on the klauspost version of this code: the round trip is fixed, all existing tests pass, and the compression ratio does not change (dictionary matching works through the hash chains, which do not use blockStart):

 	// Update window information.
 	d.windowEnd += n
 	s.index = n
+	// The dictionary is not part of the output: start the first block after
+	// it, so the raw-window fallback in writeBlock cannot emit it.
+	d.blockStart = d.windowEnd
 }

What did you expect to see?

The round trip returns exactly the bytes written to the compressor, at every compression level. The preset dictionary is never part of the decompressed output.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions