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.
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 envin your module/workspaceWhat did you do?
Compressed hard-to-compress (random) data with
flate.NewWriterDictand decompressed it withflate.NewReaderDictusing the same dictionary: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:
This is a writer-side bug, not reader-side: Python's
zlib.decompressobj(-15, zdict=b"0123456789abcdefghij")decodes the same stream todict + datatoo. So the compressed stream itself contains the dictionary bytes.Root cause.
NewWriterDictprimes the compressor via(*compressor).fillWindow(dict), which copies the dictionary intod.window, advancesd.windowEndand the match index, but never advancesd.blockStart(it stays 0):go/src/compress/flate/deflate.go
Lines 250 to 253 in bef396c
When the first block is emitted,
writeBlockpasses the raw input window alongside the tokens:The huffman bit writer uses
windowas a fallback. It compares two ways to encode the block: the huffman-coded tokens, or the raw bytes ofwindowstored as-is. If the raw form is smaller, it writeswindowdirectly into the output. But herewindowstarts 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:
blockStartis advanced correctly. The leak is always the whole dictionary, at the very start of the output.flateusers get silent data corruption. The stream is valid DEFLATE, so every decoder outputsdict + datawithout any error.zlib.NewWriterLevelDictusers get a confusingzlib: invalid checksuminstead: the writer computes Adler-32 over the written data, but the reader computes it overdict + data.Master has the same bug. The package was rewritten in 388145c, based on github.com/klauspost/compress/flate, and its
fillWindowalso never updatesblockStart. 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 ind.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 useblockStart):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.