perf(inflate): reuse the adler32 state in a small zlib reader (GO-003)
zlib.Reader.Reset calls adler32.New() each time: the one allocation of every packed read. zlibReader parses the 2-byte header itself (deflate, window <= 32 KiB, FCHECK, no FDICT), keeps one flate decompressor and one adler32 state that it resets, and verifies the big-endian trailer. The checksum is the only check of inflated content. Packed commit, deltified blob and deltified tree reads are now at 0 allocs after warm-up.
- commit
- 9ff53643aa86f266d5fdfb036ba09b72f90b86ba
- author
- brice · · 2026-10-10 23:53 UTC
- committer
- brice · · 2026-10-10 23:53 UTC
- parent
- 555a324
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +501 -22
M alloc_test.go +117 -0
| @@ -80,3 +80,120 @@ func TestAllocReadTreeAndCommit(t *testing.T) { | |||
| 80 | 80 | t.Errorf("ParseCommit allocates %v on a reused Commit", got) | |
| 81 | 81 | } | |
| 82 | 82 | } | |
| 83 | + | ||
| 84 | + | // Packed reads allocate nothing once the pools are warm: the zlib reader | |
| 85 | + | // (flate decompressor and adler32 state) is reused, and so are the buffers. | |
| 86 | + | // The delta cache is on (the default): a deltified object is then a cached | |
| 87 | + | // base plus one small delta. Without it the base is inflated again at each | |
| 88 | + | // read, and compress/flate allocates the link tables of every dynamic block | |
| 89 | + | // with a code longer than 9 bits (see TestAllocColdDynamicBlock). | |
| 90 | + | func TestAllocPackedReads(t *testing.T) { | |
| 91 | + | if raceEnabled { | |
| 92 | + | t.Skip("sync.Pool drops items under the race detector") | |
| 93 | + | } | |
| 94 | + | f := newFx(t) | |
| 95 | + | for i := 0; i < 30; i++ { | |
| 96 | + | f.write("f.txt", append(textLines(400, 0), fmt.Sprintf("rev %d\n", i)...), 0o644) | |
| 97 | + | f.write(fmt.Sprintf("many/file-%03d.txt", i), []byte{byte(i)}, 0o644) | |
| 98 | + | f.commit(fmt.Sprintf("commit %d", i)) | |
| 99 | + | } | |
| 100 | + | f.git("repack", "-adf", "--depth=50", "--window=250") | |
| 101 | + | r := f.open() | |
| 102 | + | s := r.snap.Load() | |
| 103 | + | var commit, deltaBlob, deltaTree ObjectID | |
| 104 | + | for _, p := range s.packs { | |
| 105 | + | for i := range p.idx.n { | |
| 106 | + | off, _ := p.idx.offset(i) | |
| 107 | + | h, err := parseEntryHeader(p.data, off) | |
| 108 | + | if err != nil { | |
| 109 | + | t.Fatal(err) | |
| 110 | + | } | |
| 111 | + | var id ObjectID | |
| 112 | + | copy(id[:], p.idx.id(i)) | |
| 113 | + | if !h.isDelta() { | |
| 114 | + | if h.typ == uint8(TypeCommit) { | |
| 115 | + | commit = id | |
| 116 | + | } | |
| 117 | + | continue | |
| 118 | + | } | |
| 119 | + | typ, err := p.chainEnd(off, h) | |
| 120 | + | if err != nil { | |
| 121 | + | t.Fatal(err) | |
| 122 | + | } | |
| 123 | + | switch typ { | |
| 124 | + | case TypeBlob: | |
| 125 | + | deltaBlob = id | |
| 126 | + | case TypeTree: | |
| 127 | + | deltaTree = id | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | if commit.IsZero() || deltaBlob.IsZero() || deltaTree.IsZero() { | |
| 132 | + | t.Fatalf("fixture lacks an object: commit %v, deltified blob %v, deltified tree %v", !commit.IsZero(), !deltaBlob.IsZero(), !deltaTree.IsZero()) | |
| 133 | + | } | |
| 134 | + | for _, c := range []struct { | |
| 135 | + | name string | |
| 136 | + | id ObjectID | |
| 137 | + | }{{"packed commit", commit}, {"deltified blob", deltaBlob}, {"deltified tree", deltaTree}} { | |
| 138 | + | var buf []byte | |
| 139 | + | read := func() { | |
| 140 | + | _, data, err := r.ReadObject(c.id, buf) | |
| 141 | + | if err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 144 | + | buf = data | |
| 145 | + | } | |
| 146 | + | read() // warm up | |
| 147 | + | if got := testing.AllocsPerRun(100, read); got != 0 { | |
| 148 | + | t.Errorf("%s: ReadObject allocates %v per call after warm-up, want 0", c.name, got) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | var cm Commit | |
| 152 | + | _ = r.ReadCommit(commit, &cm) | |
| 153 | + | if got := testing.AllocsPerRun(100, func() { _ = r.ReadCommit(commit, &cm) }); got != 0 { | |
| 154 | + | t.Errorf("packed commit: ReadCommit allocates %v per call after warm-up, want 0", got) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // compress/flate keeps the 9-bit decoding table of each Huffman tree in the | |
| 159 | + | // decompressor, so Reset reuses it. For codes longer than 9 bits it builds | |
| 160 | + | // link tables with make() in huffmanDecoder.init at every dynamic block, and | |
| 161 | + | // Reset clears them (*h = huffmanDecoder{}), so they are never reused. A | |
| 162 | + | // zlibReader therefore allocates per dynamic block with long codes and | |
| 163 | + | // nothing otherwise. This pins the behaviour: the numbers are Go's, not | |
| 164 | + | // ours, and the test fails if a Go release changes them (good news then). | |
| 165 | + | func TestAllocColdDynamicBlock(t *testing.T) { | |
| 166 | + | if raceEnabled { | |
| 167 | + | t.Skip("sync.Pool drops items under the race detector") | |
| 168 | + | } | |
| 169 | + | var z zlibReader | |
| 170 | + | for _, c := range []struct { | |
| 171 | + | name string | |
| 172 | + | data []byte | |
| 173 | + | zero bool | |
| 174 | + | }{ | |
| 175 | + | {"short codes (all literals the same)", bytes.Repeat([]byte("ab"), 4000), true}, | |
| 176 | + | {"fixed Huffman block", []byte("tiny"), true}, | |
| 177 | + | {"prose-like text", textLines(400, 0), false}, | |
| 178 | + | } { | |
| 179 | + | src := zlibBytes(c.data) | |
| 180 | + | dst := make([]byte, len(c.data)) | |
| 181 | + | var br bytes.Reader | |
| 182 | + | probe := make([]byte, 1) | |
| 183 | + | read := func() { | |
| 184 | + | br.Reset(src) | |
| 185 | + | if err := z.Reset(&br); err != nil { | |
| 186 | + | t.Fatal(err) | |
| 187 | + | } | |
| 188 | + | if err := inflateExact(&z, dst, probe); err != nil { | |
| 189 | + | t.Fatal(err) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | read() | |
| 193 | + | got := testing.AllocsPerRun(50, read) | |
| 194 | + | t.Logf("%s: %v allocs per stream", c.name, got) | |
| 195 | + | if c.zero && got != 0 { | |
| 196 | + | t.Errorf("%s: %v allocs, want 0", c.name, got) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | } | |
| @@ -80,3 +80,120 @@ func TestAllocReadTreeAndCommit(t *testing.T) { | |||||
| 80 | t.Errorf("ParseCommit allocates %v on a reused Commit", got) | 80 | t.Errorf("ParseCommit allocates %v on a reused Commit", got) | ||
| 81 | } | 81 | } | ||
| 82 | } | 82 | } | ||
| 83 | + | ||||
| 84 | + | // Packed reads allocate nothing once the pools are warm: the zlib reader | |||
| 85 | + | // (flate decompressor and adler32 state) is reused, and so are the buffers. | |||
| 86 | + | // The delta cache is on (the default): a deltified object is then a cached | |||
| 87 | + | // base plus one small delta. Without it the base is inflated again at each | |||
| 88 | + | // read, and compress/flate allocates the link tables of every dynamic block | |||
| 89 | + | // with a code longer than 9 bits (see TestAllocColdDynamicBlock). | |||
| 90 | + | func TestAllocPackedReads(t *testing.T) { | |||
| 91 | + | if raceEnabled { | |||
| 92 | + | t.Skip("sync.Pool drops items under the race detector") | |||
| 93 | + | } | |||
| 94 | + | f := newFx(t) | |||
| 95 | + | for i := 0; i < 30; i++ { | |||
| 96 | + | f.write("f.txt", append(textLines(400, 0), fmt.Sprintf("rev %d\n", i)...), 0o644) | |||
| 97 | + | f.write(fmt.Sprintf("many/file-%03d.txt", i), []byte{byte(i)}, 0o644) | |||
| 98 | + | f.commit(fmt.Sprintf("commit %d", i)) | |||
| 99 | + | } | |||
| 100 | + | f.git("repack", "-adf", "--depth=50", "--window=250") | |||
| 101 | + | r := f.open() | |||
| 102 | + | s := r.snap.Load() | |||
| 103 | + | var commit, deltaBlob, deltaTree ObjectID | |||
| 104 | + | for _, p := range s.packs { | |||
| 105 | + | for i := range p.idx.n { | |||
| 106 | + | off, _ := p.idx.offset(i) | |||
| 107 | + | h, err := parseEntryHeader(p.data, off) | |||
| 108 | + | if err != nil { | |||
| 109 | + | t.Fatal(err) | |||
| 110 | + | } | |||
| 111 | + | var id ObjectID | |||
| 112 | + | copy(id[:], p.idx.id(i)) | |||
| 113 | + | if !h.isDelta() { | |||
| 114 | + | if h.typ == uint8(TypeCommit) { | |||
| 115 | + | commit = id | |||
| 116 | + | } | |||
| 117 | + | continue | |||
| 118 | + | } | |||
| 119 | + | typ, err := p.chainEnd(off, h) | |||
| 120 | + | if err != nil { | |||
| 121 | + | t.Fatal(err) | |||
| 122 | + | } | |||
| 123 | + | switch typ { | |||
| 124 | + | case TypeBlob: | |||
| 125 | + | deltaBlob = id | |||
| 126 | + | case TypeTree: | |||
| 127 | + | deltaTree = id | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | if commit.IsZero() || deltaBlob.IsZero() || deltaTree.IsZero() { | |||
| 132 | + | t.Fatalf("fixture lacks an object: commit %v, deltified blob %v, deltified tree %v", !commit.IsZero(), !deltaBlob.IsZero(), !deltaTree.IsZero()) | |||
| 133 | + | } | |||
| 134 | + | for _, c := range []struct { | |||
| 135 | + | name string | |||
| 136 | + | id ObjectID | |||
| 137 | + | }{{"packed commit", commit}, {"deltified blob", deltaBlob}, {"deltified tree", deltaTree}} { | |||
| 138 | + | var buf []byte | |||
| 139 | + | read := func() { | |||
| 140 | + | _, data, err := r.ReadObject(c.id, buf) | |||
| 141 | + | if err != nil { | |||
| 142 | + | t.Fatal(err) | |||
| 143 | + | } | |||
| 144 | + | buf = data | |||
| 145 | + | } | |||
| 146 | + | read() // warm up | |||
| 147 | + | if got := testing.AllocsPerRun(100, read); got != 0 { | |||
| 148 | + | t.Errorf("%s: ReadObject allocates %v per call after warm-up, want 0", c.name, got) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | var cm Commit | |||
| 152 | + | _ = r.ReadCommit(commit, &cm) | |||
| 153 | + | if got := testing.AllocsPerRun(100, func() { _ = r.ReadCommit(commit, &cm) }); got != 0 { | |||
| 154 | + | t.Errorf("packed commit: ReadCommit allocates %v per call after warm-up, want 0", got) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // compress/flate keeps the 9-bit decoding table of each Huffman tree in the | |||
| 159 | + | // decompressor, so Reset reuses it. For codes longer than 9 bits it builds | |||
| 160 | + | // link tables with make() in huffmanDecoder.init at every dynamic block, and | |||
| 161 | + | // Reset clears them (*h = huffmanDecoder{}), so they are never reused. A | |||
| 162 | + | // zlibReader therefore allocates per dynamic block with long codes and | |||
| 163 | + | // nothing otherwise. This pins the behaviour: the numbers are Go's, not | |||
| 164 | + | // ours, and the test fails if a Go release changes them (good news then). | |||
| 165 | + | func TestAllocColdDynamicBlock(t *testing.T) { | |||
| 166 | + | if raceEnabled { | |||
| 167 | + | t.Skip("sync.Pool drops items under the race detector") | |||
| 168 | + | } | |||
| 169 | + | var z zlibReader | |||
| 170 | + | for _, c := range []struct { | |||
| 171 | + | name string | |||
| 172 | + | data []byte | |||
| 173 | + | zero bool | |||
| 174 | + | }{ | |||
| 175 | + | {"short codes (all literals the same)", bytes.Repeat([]byte("ab"), 4000), true}, | |||
| 176 | + | {"fixed Huffman block", []byte("tiny"), true}, | |||
| 177 | + | {"prose-like text", textLines(400, 0), false}, | |||
| 178 | + | } { | |||
| 179 | + | src := zlibBytes(c.data) | |||
| 180 | + | dst := make([]byte, len(c.data)) | |||
| 181 | + | var br bytes.Reader | |||
| 182 | + | probe := make([]byte, 1) | |||
| 183 | + | read := func() { | |||
| 184 | + | br.Reset(src) | |||
| 185 | + | if err := z.Reset(&br); err != nil { | |||
| 186 | + | t.Fatal(err) | |||
| 187 | + | } | |||
| 188 | + | if err := inflateExact(&z, dst, probe); err != nil { | |||
| 189 | + | t.Fatal(err) | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | read() | |||
| 193 | + | got := testing.AllocsPerRun(50, read) | |||
| 194 | + | t.Logf("%s: %v allocs per stream", c.name, got) | |||
| 195 | + | if c.zero && got != 0 { | |||
| 196 | + | t.Errorf("%s: %v allocs, want 0", c.name, got) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | } | |||
M blob.go +2 -2
| @@ -78,7 +78,7 @@ func (r *Repo) openPackedBlob(p *pack, off uint64, id ObjectID) (int64, io.ReadC | |||
| 78 | 78 | return 0, nil, err | |
| 79 | 79 | } | |
| 80 | 80 | p.retain() | |
| 81 | - | return int64(h.size), &streamBlob{src: m, pack: p, left: int64(h.size), zr: m.zr}, nil | |
| 81 | + | return int64(h.size), &streamBlob{src: m, pack: p, left: int64(h.size), zr: &m.zr}, nil | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | 84 | func (r *Repo) openLooseBlob(id *ObjectID) (int64, io.ReadCloser, error) { | |
| @@ -94,7 +94,7 @@ func (r *Repo) openLooseBlob(id *ObjectID) (int64, io.ReadCloser, error) { | |||
| 94 | 94 | l.close() | |
| 95 | 95 | return 0, nil, corrupt("loose object %s: size %d", id.String(), l.size) | |
| 96 | 96 | } | |
| 97 | - | return int64(l.size), &streamBlob{loose: l, left: int64(l.size), zr: l.fi.zr, lead: l.lead[:l.nlea]}, nil | |
| 97 | + | return int64(l.size), &streamBlob{loose: l, left: int64(l.size), zr: &l.fi.zr, lead: l.lead[:l.nlea]}, nil | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | 100 | // memBlob serves a blob rebuilt in memory. | |
| @@ -78,7 +78,7 @@ func (r *Repo) openPackedBlob(p *pack, off uint64, id ObjectID) (int64, io.ReadC | |||||
| 78 | return 0, nil, err | 78 | return 0, nil, err | ||
| 79 | } | 79 | } | ||
| 80 | p.retain() | 80 | p.retain() | ||
| 81 | - | return int64(h.size), &streamBlob{src: m, pack: p, left: int64(h.size), zr: m.zr}, nil | 81 | + | return int64(h.size), &streamBlob{src: m, pack: p, left: int64(h.size), zr: &m.zr}, nil |
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | func (r *Repo) openLooseBlob(id *ObjectID) (int64, io.ReadCloser, error) { | 84 | func (r *Repo) openLooseBlob(id *ObjectID) (int64, io.ReadCloser, error) { | ||
| @@ -94,7 +94,7 @@ func (r *Repo) openLooseBlob(id *ObjectID) (int64, io.ReadCloser, error) { | |||||
| 94 | l.close() | 94 | l.close() | ||
| 95 | return 0, nil, corrupt("loose object %s: size %d", id.String(), l.size) | 95 | return 0, nil, corrupt("loose object %s: size %d", id.String(), l.size) | ||
| 96 | } | 96 | } | ||
| 97 | - | return int64(l.size), &streamBlob{loose: l, left: int64(l.size), zr: l.fi.zr, lead: l.lead[:l.nlea]}, nil | 97 | + | return int64(l.size), &streamBlob{loose: l, left: int64(l.size), zr: &l.fi.zr, lead: l.lead[:l.nlea]}, nil |
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | // memBlob serves a blob rebuilt in memory. | 100 | // memBlob serves a blob rebuilt in memory. | ||
M inflate.go +94 -17
| @@ -5,8 +5,11 @@ package git | |||
| 5 | 5 | import ( | |
| 6 | 6 | "bufio" | |
| 7 | 7 | "bytes" | |
| 8 | - | "compress/zlib" | |
| 8 | + | "compress/flate" | |
| 9 | + | "encoding/binary" | |
| 9 | 10 | "errors" | |
| 11 | + | "hash" | |
| 12 | + | "hash/adler32" | |
| 10 | 13 | "io" | |
| 11 | 14 | "math" | |
| 12 | 15 | "sync" | |
| @@ -47,9 +50,95 @@ func grow(b []byte, n int) []byte { | |||
| 47 | 50 | return make([]byte, n, c) | |
| 48 | 51 | } | |
| 49 | 52 | ||
| 53 | + | // Errors of the zlib wrapper. Callers wrap them in ErrCorrupt. | |
| 54 | + | var ( | |
| 55 | + | errZlibHeader = errors.New("zlib: invalid header") | |
| 56 | + | errZlibDict = errors.New("zlib: preset dictionary") | |
| 57 | + | errZlibChecksum = errors.New("zlib: invalid checksum") | |
| 58 | + | ) | |
| 59 | + | ||
| 60 | + | // zlibReader inflates a zlib stream (RFC 1950). It is compress/zlib's reader | |
| 61 | + | // without its allocation per Reset: Go's zlib.Reader.Reset calls | |
| 62 | + | // adler32.New() each time, while here the checksum state and the flate | |
| 63 | + | // decompressor are both created once and reset. The adler32 trailer is | |
| 64 | + | // verified when the deflate stream ends; it is the only check of the | |
| 65 | + | // inflated content, since neither the pack trailer nor the idx CRCs are | |
| 66 | + | // verified. | |
| 67 | + | // | |
| 68 | + | // A zlibReader is not safe for concurrent use. | |
| 69 | + | type zlibReader struct { | |
| 70 | + | src flate.Reader | |
| 71 | + | fl io.ReadCloser // *flate decompressor, created at the first Reset | |
| 72 | + | rs flate.Resetter | |
| 73 | + | sum hash.Hash32 | |
| 74 | + | err error // sticky; io.EOF once the stream and its checksum are verified | |
| 75 | + | tail [4]byte | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Reset starts reading the zlib stream at the current position of src. It | |
| 79 | + | // reads the 2-byte header: deflate with a window of at most 32 KiB, a valid | |
| 80 | + | // check value, no preset dictionary (Git never writes one). | |
| 81 | + | func (z *zlibReader) Reset(src flate.Reader) error { | |
| 82 | + | z.src, z.err = src, nil | |
| 83 | + | cmf, err := src.ReadByte() | |
| 84 | + | if err == nil { | |
| 85 | + | var flg byte | |
| 86 | + | if flg, err = src.ReadByte(); err == nil { | |
| 87 | + | switch { | |
| 88 | + | case cmf&0x0f != 8 || cmf>>4 > 7 || (uint(cmf)<<8|uint(flg))%31 != 0: | |
| 89 | + | err = errZlibHeader | |
| 90 | + | case flg&0x20 != 0: | |
| 91 | + | err = errZlibDict | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | if err != nil { | |
| 96 | + | if errors.Is(err, io.EOF) { | |
| 97 | + | err = io.ErrUnexpectedEOF | |
| 98 | + | } | |
| 99 | + | z.err = err | |
| 100 | + | return err | |
| 101 | + | } | |
| 102 | + | if z.fl == nil { | |
| 103 | + | z.fl = flate.NewReader(src) | |
| 104 | + | z.rs = z.fl.(flate.Resetter) | |
| 105 | + | z.sum = adler32.New() | |
| 106 | + | } else { | |
| 107 | + | _ = z.rs.Reset(src, nil) // never fails without a dictionary | |
| 108 | + | z.sum.Reset() | |
| 109 | + | } | |
| 110 | + | return nil | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (z *zlibReader) Read(p []byte) (int, error) { | |
| 114 | + | if z.err != nil { | |
| 115 | + | return 0, z.err | |
| 116 | + | } | |
| 117 | + | n, err := z.fl.Read(p) | |
| 118 | + | _, _ = z.sum.Write(p[:n]) // never fails | |
| 119 | + | if err != io.EOF { | |
| 120 | + | z.err = err | |
| 121 | + | return n, err | |
| 122 | + | } | |
| 123 | + | // End of the deflate stream: the big-endian adler32 of the content. | |
| 124 | + | if _, err := io.ReadFull(z.src, z.tail[:]); err != nil { | |
| 125 | + | if errors.Is(err, io.EOF) { | |
| 126 | + | err = io.ErrUnexpectedEOF | |
| 127 | + | } | |
| 128 | + | z.err = err | |
| 129 | + | return n, err | |
| 130 | + | } | |
| 131 | + | if binary.BigEndian.Uint32(z.tail[:]) != z.sum.Sum32() { | |
| 132 | + | z.err = errZlibChecksum | |
| 133 | + | return n, z.err | |
| 134 | + | } | |
| 135 | + | z.err = io.EOF | |
| 136 | + | return n, io.EOF | |
| 137 | + | } | |
| 138 | + | ||
| 50 | 139 | // memInflater inflates a zlib stream held in memory. | |
| 51 | 140 | type memInflater struct { | |
| 52 | - | zr io.ReadCloser | |
| 141 | + | zr zlibReader | |
| 53 | 142 | br bytes.Reader | |
| 54 | 143 | scratch [maxLooseHeader]byte // for reads through the io.Reader interface, which escape | |
| 55 | 144 | } | |
| @@ -60,13 +149,7 @@ var memPool = sync.Pool{New: func() any { return new(memInflater) }} | |||
| 60 | 149 | func getMem(src []byte) (*memInflater, error) { | |
| 61 | 150 | m := memPool.Get().(*memInflater) | |
| 62 | 151 | m.br.Reset(src) | |
| 63 | - | var err error | |
| 64 | - | if m.zr == nil { | |
| 65 | - | m.zr, err = zlib.NewReader(&m.br) | |
| 66 | - | } else { | |
| 67 | - | err = m.zr.(zlib.Resetter).Reset(&m.br, nil) | |
| 68 | - | } | |
| 69 | - | if err != nil { | |
| 152 | + | if err := m.zr.Reset(&m.br); err != nil { | |
| 70 | 153 | putMem(m) | |
| 71 | 154 | return nil, corrupt("zlib header: %v", err) | |
| 72 | 155 | } | |
| @@ -80,7 +163,7 @@ func putMem(m *memInflater) { | |||
| 80 | 163 | ||
| 81 | 164 | // fileInflater inflates a zlib stream read from a file. | |
| 82 | 165 | type fileInflater struct { | |
| 83 | - | zr io.ReadCloser | |
| 166 | + | zr zlibReader | |
| 84 | 167 | br *bufio.Reader | |
| 85 | 168 | scratch [maxLooseHeader]byte | |
| 86 | 169 | } | |
| @@ -90,13 +173,7 @@ var filePool = sync.Pool{New: func() any { return &fileInflater{br: bufio.NewRea | |||
| 90 | 173 | func getFile(r io.Reader) (*fileInflater, error) { | |
| 91 | 174 | f := filePool.Get().(*fileInflater) | |
| 92 | 175 | f.br.Reset(r) | |
| 93 | - | var err error | |
| 94 | - | if f.zr == nil { | |
| 95 | - | f.zr, err = zlib.NewReader(f.br) | |
| 96 | - | } else { | |
| 97 | - | err = f.zr.(zlib.Resetter).Reset(f.br, nil) | |
| 98 | - | } | |
| 99 | - | if err != nil { | |
| 176 | + | if err := f.zr.Reset(f.br); err != nil { | |
| 100 | 177 | putFile(f) | |
| 101 | 178 | return nil, corrupt("zlib header: %v", err) | |
| 102 | 179 | } | |
| @@ -5,8 +5,11 @@ package git | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "bufio" | 6 | "bufio" | ||
| 7 | "bytes" | 7 | "bytes" | ||
| 8 | - | "compress/zlib" | 8 | + | "compress/flate" |
| 9 | + | "encoding/binary" | |||
| 9 | "errors" | 10 | "errors" | ||
| 11 | + | "hash" | |||
| 12 | + | "hash/adler32" | |||
| 10 | "io" | 13 | "io" | ||
| 11 | "math" | 14 | "math" | ||
| 12 | "sync" | 15 | "sync" | ||
| @@ -47,9 +50,95 @@ func grow(b []byte, n int) []byte { | |||||
| 47 | return make([]byte, n, c) | 50 | return make([]byte, n, c) | ||
| 48 | } | 51 | } | ||
| 49 | 52 | ||||
| 53 | + | // Errors of the zlib wrapper. Callers wrap them in ErrCorrupt. | |||
| 54 | + | var ( | |||
| 55 | + | errZlibHeader = errors.New("zlib: invalid header") | |||
| 56 | + | errZlibDict = errors.New("zlib: preset dictionary") | |||
| 57 | + | errZlibChecksum = errors.New("zlib: invalid checksum") | |||
| 58 | + | ) | |||
| 59 | + | ||||
| 60 | + | // zlibReader inflates a zlib stream (RFC 1950). It is compress/zlib's reader | |||
| 61 | + | // without its allocation per Reset: Go's zlib.Reader.Reset calls | |||
| 62 | + | // adler32.New() each time, while here the checksum state and the flate | |||
| 63 | + | // decompressor are both created once and reset. The adler32 trailer is | |||
| 64 | + | // verified when the deflate stream ends; it is the only check of the | |||
| 65 | + | // inflated content, since neither the pack trailer nor the idx CRCs are | |||
| 66 | + | // verified. | |||
| 67 | + | // | |||
| 68 | + | // A zlibReader is not safe for concurrent use. | |||
| 69 | + | type zlibReader struct { | |||
| 70 | + | src flate.Reader | |||
| 71 | + | fl io.ReadCloser // *flate decompressor, created at the first Reset | |||
| 72 | + | rs flate.Resetter | |||
| 73 | + | sum hash.Hash32 | |||
| 74 | + | err error // sticky; io.EOF once the stream and its checksum are verified | |||
| 75 | + | tail [4]byte | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // Reset starts reading the zlib stream at the current position of src. It | |||
| 79 | + | // reads the 2-byte header: deflate with a window of at most 32 KiB, a valid | |||
| 80 | + | // check value, no preset dictionary (Git never writes one). | |||
| 81 | + | func (z *zlibReader) Reset(src flate.Reader) error { | |||
| 82 | + | z.src, z.err = src, nil | |||
| 83 | + | cmf, err := src.ReadByte() | |||
| 84 | + | if err == nil { | |||
| 85 | + | var flg byte | |||
| 86 | + | if flg, err = src.ReadByte(); err == nil { | |||
| 87 | + | switch { | |||
| 88 | + | case cmf&0x0f != 8 || cmf>>4 > 7 || (uint(cmf)<<8|uint(flg))%31 != 0: | |||
| 89 | + | err = errZlibHeader | |||
| 90 | + | case flg&0x20 != 0: | |||
| 91 | + | err = errZlibDict | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | if err != nil { | |||
| 96 | + | if errors.Is(err, io.EOF) { | |||
| 97 | + | err = io.ErrUnexpectedEOF | |||
| 98 | + | } | |||
| 99 | + | z.err = err | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 102 | + | if z.fl == nil { | |||
| 103 | + | z.fl = flate.NewReader(src) | |||
| 104 | + | z.rs = z.fl.(flate.Resetter) | |||
| 105 | + | z.sum = adler32.New() | |||
| 106 | + | } else { | |||
| 107 | + | _ = z.rs.Reset(src, nil) // never fails without a dictionary | |||
| 108 | + | z.sum.Reset() | |||
| 109 | + | } | |||
| 110 | + | return nil | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (z *zlibReader) Read(p []byte) (int, error) { | |||
| 114 | + | if z.err != nil { | |||
| 115 | + | return 0, z.err | |||
| 116 | + | } | |||
| 117 | + | n, err := z.fl.Read(p) | |||
| 118 | + | _, _ = z.sum.Write(p[:n]) // never fails | |||
| 119 | + | if err != io.EOF { | |||
| 120 | + | z.err = err | |||
| 121 | + | return n, err | |||
| 122 | + | } | |||
| 123 | + | // End of the deflate stream: the big-endian adler32 of the content. | |||
| 124 | + | if _, err := io.ReadFull(z.src, z.tail[:]); err != nil { | |||
| 125 | + | if errors.Is(err, io.EOF) { | |||
| 126 | + | err = io.ErrUnexpectedEOF | |||
| 127 | + | } | |||
| 128 | + | z.err = err | |||
| 129 | + | return n, err | |||
| 130 | + | } | |||
| 131 | + | if binary.BigEndian.Uint32(z.tail[:]) != z.sum.Sum32() { | |||
| 132 | + | z.err = errZlibChecksum | |||
| 133 | + | return n, z.err | |||
| 134 | + | } | |||
| 135 | + | z.err = io.EOF | |||
| 136 | + | return n, io.EOF | |||
| 137 | + | } | |||
| 138 | + | ||||
| 50 | // memInflater inflates a zlib stream held in memory. | 139 | // memInflater inflates a zlib stream held in memory. | ||
| 51 | type memInflater struct { | 140 | type memInflater struct { | ||
| 52 | - | zr io.ReadCloser | 141 | + | zr zlibReader |
| 53 | br bytes.Reader | 142 | br bytes.Reader | ||
| 54 | scratch [maxLooseHeader]byte // for reads through the io.Reader interface, which escape | 143 | scratch [maxLooseHeader]byte // for reads through the io.Reader interface, which escape | ||
| 55 | } | 144 | } | ||
| @@ -60,13 +149,7 @@ var memPool = sync.Pool{New: func() any { return new(memInflater) }} | |||||
| 60 | func getMem(src []byte) (*memInflater, error) { | 149 | func getMem(src []byte) (*memInflater, error) { | ||
| 61 | m := memPool.Get().(*memInflater) | 150 | m := memPool.Get().(*memInflater) | ||
| 62 | m.br.Reset(src) | 151 | m.br.Reset(src) | ||
| 63 | - | var err error | 152 | + | if err := m.zr.Reset(&m.br); err != nil { |
| 64 | - | if m.zr == nil { | |||
| 65 | - | m.zr, err = zlib.NewReader(&m.br) | |||
| 66 | - | } else { | |||
| 67 | - | err = m.zr.(zlib.Resetter).Reset(&m.br, nil) | |||
| 68 | - | } | |||
| 69 | - | if err != nil { | |||
| 70 | putMem(m) | 153 | putMem(m) | ||
| 71 | return nil, corrupt("zlib header: %v", err) | 154 | return nil, corrupt("zlib header: %v", err) | ||
| 72 | } | 155 | } | ||
| @@ -80,7 +163,7 @@ func putMem(m *memInflater) { | |||||
| 80 | 163 | ||||
| 81 | // fileInflater inflates a zlib stream read from a file. | 164 | // fileInflater inflates a zlib stream read from a file. | ||
| 82 | type fileInflater struct { | 165 | type fileInflater struct { | ||
| 83 | - | zr io.ReadCloser | 166 | + | zr zlibReader |
| 84 | br *bufio.Reader | 167 | br *bufio.Reader | ||
| 85 | scratch [maxLooseHeader]byte | 168 | scratch [maxLooseHeader]byte | ||
| 86 | } | 169 | } | ||
| @@ -90,13 +173,7 @@ var filePool = sync.Pool{New: func() any { return &fileInflater{br: bufio.NewRea | |||||
| 90 | func getFile(r io.Reader) (*fileInflater, error) { | 173 | func getFile(r io.Reader) (*fileInflater, error) { | ||
| 91 | f := filePool.Get().(*fileInflater) | 174 | f := filePool.Get().(*fileInflater) | ||
| 92 | f.br.Reset(r) | 175 | f.br.Reset(r) | ||
| 93 | - | var err error | 176 | + | if err := f.zr.Reset(f.br); err != nil { |
| 94 | - | if f.zr == nil { | |||
| 95 | - | f.zr, err = zlib.NewReader(f.br) | |||
| 96 | - | } else { | |||
| 97 | - | err = f.zr.(zlib.Resetter).Reset(f.br, nil) | |||
| 98 | - | } | |||
| 99 | - | if err != nil { | |||
| 100 | putFile(f) | 177 | putFile(f) | ||
| 101 | return nil, corrupt("zlib header: %v", err) | 178 | return nil, corrupt("zlib header: %v", err) | ||
| 102 | } | 179 | } | ||
A inflate_test.go +285 -0
| @@ -0,0 +1,285 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package git | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "compress/zlib" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "math/rand/v2" | |
| 12 | + | "os" | |
| 13 | + | "path/filepath" | |
| 14 | + | "testing" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // inflateAll reads a whole zlib stream with the wrapper, reusing z. | |
| 18 | + | func inflateAll(z *zlibReader, src []byte) ([]byte, error) { | |
| 19 | + | br := bytes.NewReader(src) | |
| 20 | + | if err := z.Reset(br); err != nil { | |
| 21 | + | return nil, err | |
| 22 | + | } | |
| 23 | + | return io.ReadAll(z) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // stdInflate is the reference: compress/zlib on the same bytes. | |
| 27 | + | func stdInflate(src []byte) ([]byte, error) { | |
| 28 | + | zr, err := zlib.NewReader(bytes.NewReader(src)) | |
| 29 | + | if err != nil { | |
| 30 | + | return nil, err | |
| 31 | + | } | |
| 32 | + | return io.ReadAll(zr) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func zlibLevel(b []byte, level int) []byte { | |
| 36 | + | var out bytes.Buffer | |
| 37 | + | w, _ := zlib.NewWriterLevel(&out, level) | |
| 38 | + | _, _ = w.Write(b) | |
| 39 | + | _ = w.Close() | |
| 40 | + | return out.Bytes() | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // zlibSamples are streams of every block type: stored, fixed and dynamic | |
| 44 | + | // Huffman, empty, and several blocks. | |
| 45 | + | func zlibSamples() map[string][]byte { | |
| 46 | + | rng := rand.New(rand.NewPCG(1, 2)) | |
| 47 | + | random := make([]byte, 1200) | |
| 48 | + | for i := range random { | |
| 49 | + | random[i] = byte(rng.Uint32()) | |
| 50 | + | } | |
| 51 | + | text := textLines(60, 3) | |
| 52 | + | return map[string][]byte{ | |
| 53 | + | "empty": zlibLevel(nil, zlib.DefaultCompression), | |
| 54 | + | "tiny": zlibLevel([]byte("a"), zlib.DefaultCompression), | |
| 55 | + | "text": zlibLevel(text, zlib.DefaultCompression), | |
| 56 | + | "random": zlibLevel(random, zlib.DefaultCompression), | |
| 57 | + | "stored": zlibLevel(text, zlib.NoCompression), | |
| 58 | + | "huffman": zlibLevel(text, zlib.HuffmanOnly), | |
| 59 | + | "fast": zlibLevel(text, zlib.BestSpeed), | |
| 60 | + | "big": zlibLevel(bytes.Repeat(text, 3), zlib.BestCompression), | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestZlibReaderMatchesStdlib(t *testing.T) { | |
| 65 | + | var z zlibReader // one reader for every stream: Reset must clear all state | |
| 66 | + | for name, src := range zlibSamples() { | |
| 67 | + | want, werr := stdInflate(src) | |
| 68 | + | got, err := inflateAll(&z, src) | |
| 69 | + | if werr != nil || err != nil || !bytes.Equal(got, want) { | |
| 70 | + | t.Errorf("%s: got %d bytes (%v), stdlib %d bytes (%v)", name, len(got), err, len(want), werr) | |
| 71 | + | } | |
| 72 | + | // EOF is sticky and the checksum was verified. | |
| 73 | + | if n, err := z.Read(make([]byte, 1)); n != 0 || err != io.EOF { | |
| 74 | + | t.Errorf("%s: Read after the end = %d, %v", name, n, err) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestZlibReaderHeader(t *testing.T) { | |
| 80 | + | good := zlibLevel([]byte("hello"), zlib.DefaultCompression) | |
| 81 | + | mk := func(cmf, flg byte) []byte { return append([]byte{cmf, flg}, good[2:]...) } | |
| 82 | + | // A valid FCHECK for the given CMF and FLG bits. | |
| 83 | + | fix := func(cmf, flg byte) []byte { | |
| 84 | + | flg &^= 0x1f | |
| 85 | + | flg |= byte(31 - (uint(cmf)<<8|uint(flg))%31) | |
| 86 | + | return mk(cmf, flg) | |
| 87 | + | } | |
| 88 | + | var z zlibReader | |
| 89 | + | for _, c := range []struct { | |
| 90 | + | name string | |
| 91 | + | src []byte | |
| 92 | + | want error // nil: accepted | |
| 93 | + | }{ | |
| 94 | + | {"valid", good, nil}, | |
| 95 | + | {"window 4 KiB", fix(0x28, 0), nil}, | |
| 96 | + | {"bad check", mk(good[0], good[1]+1), errZlibHeader}, | |
| 97 | + | {"not deflate", fix(0x79, 0), errZlibHeader}, | |
| 98 | + | {"window over 32 KiB", fix(0x88, 0), errZlibHeader}, | |
| 99 | + | {"preset dictionary", fix(0x78, 0x20), errZlibDict}, | |
| 100 | + | {"empty", nil, io.ErrUnexpectedEOF}, | |
| 101 | + | {"one byte", good[:1], io.ErrUnexpectedEOF}, | |
| 102 | + | } { | |
| 103 | + | err := z.Reset(bytes.NewReader(c.src)) | |
| 104 | + | if !errors.Is(err, c.want) && (err != nil || c.want != nil) { | |
| 105 | + | t.Errorf("%s: Reset = %v, want %v", c.name, err, c.want) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | // A reader that failed its header recovers on the next Reset. | |
| 109 | + | if got, err := inflateAll(&z, good); err != nil || string(got) != "hello" { | |
| 110 | + | t.Fatalf("after errors: %q, %v", got, err) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Every byte of the stream matters: a bit flipped in the deflate data or in | |
| 115 | + | // the trailer never yields wrong content without an error. The only flips | |
| 116 | + | // that go unnoticed are those that leave the content as it was (the unused | |
| 117 | + | // bits after the last block). | |
| 118 | + | func TestZlibReaderDetectsCorruption(t *testing.T) { | |
| 119 | + | var z zlibReader | |
| 120 | + | for name, src := range zlibSamples() { | |
| 121 | + | want, err := stdInflate(src) | |
| 122 | + | if err != nil { | |
| 123 | + | t.Fatal(err) | |
| 124 | + | } | |
| 125 | + | detected := 0 | |
| 126 | + | for i := range src { | |
| 127 | + | for bit := range 8 { | |
| 128 | + | bad := bytes.Clone(src) | |
| 129 | + | bad[i] ^= 1 << bit | |
| 130 | + | got, err := inflateAll(&z, bad) | |
| 131 | + | switch { | |
| 132 | + | case err != nil: | |
| 133 | + | detected++ | |
| 134 | + | case !bytes.Equal(got, want): | |
| 135 | + | t.Fatalf("%s: bit %d of byte %d/%d changed the content without an error", name, bit, i, len(src)) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | // The 4 trailer bytes are always checked. | |
| 140 | + | for i := len(src) - 4; i < len(src); i++ { | |
| 141 | + | bad := bytes.Clone(src) | |
| 142 | + | bad[i] ^= 0x01 | |
| 143 | + | if _, err := inflateAll(&z, bad); !errors.Is(err, errZlibChecksum) { | |
| 144 | + | t.Errorf("%s: flipped trailer byte %d: %v, want a checksum error", name, i, err) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | if total := len(src) * 8; detected < total*9/10 { | |
| 148 | + | t.Errorf("%s: only %d of %d flips detected", name, detected, total) | |
| 149 | + | } | |
| 150 | + | // A truncated trailer or stream is an error too. | |
| 151 | + | for _, n := range []int{len(src) - 1, len(src) - 4, len(src) - 5} { | |
| 152 | + | if n < 2 { | |
| 153 | + | continue | |
| 154 | + | } | |
| 155 | + | if _, err := inflateAll(&z, src[:n]); err == nil { | |
| 156 | + | t.Errorf("%s: stream cut at %d of %d accepted", name, n, len(src)) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // The wrapper answers like compress/zlib on any input, except that it | |
| 163 | + | // refuses a preset dictionary. | |
| 164 | + | func FuzzZlibReader(f *testing.F) { | |
| 165 | + | for _, src := range zlibSamples() { | |
| 166 | + | f.Add(src) | |
| 167 | + | if len(src) > 8 { | |
| 168 | + | bad := bytes.Clone(src) | |
| 169 | + | bad[len(src)-2] ^= 0x40 | |
| 170 | + | f.Add(bad) | |
| 171 | + | f.Add(src[:len(src)-3]) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | f.Add([]byte{0x78, 0x20, 0, 0, 0, 1, 0x03, 0x00, 0, 0, 0, 1}) | |
| 175 | + | f.Add([]byte{0x78}) | |
| 176 | + | f.Add([]byte{}) | |
| 177 | + | var z zlibReader | |
| 178 | + | warm := zlibLevel([]byte("warm up the reused state"), zlib.DefaultCompression) | |
| 179 | + | f.Fuzz(func(t *testing.T, src []byte) { | |
| 180 | + | if _, err := inflateAll(&z, warm); err != nil { // dirty state from a good stream | |
| 181 | + | t.Fatal(err) | |
| 182 | + | } | |
| 183 | + | got, err := inflateAll(&z, src) | |
| 184 | + | if len(src) >= 2 && src[1]&0x20 != 0 && errors.Is(err, errZlibDict) { | |
| 185 | + | return | |
| 186 | + | } | |
| 187 | + | want, werr := stdInflate(src) | |
| 188 | + | if (err == nil) != (werr == nil) { | |
| 189 | + | t.Fatalf("wrapper: %v, stdlib: %v", err, werr) | |
| 190 | + | } | |
| 191 | + | if err == nil && !bytes.Equal(got, want) { | |
| 192 | + | t.Fatalf("content differs: %d bytes against %d", len(got), len(want)) | |
| 193 | + | } | |
| 194 | + | }) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // flipByteAt flips a bit of the byte at off (from the end when negative). | |
| 198 | + | func flipByteAt(t *testing.T, path string, off int64) { | |
| 199 | + | t.Helper() | |
| 200 | + | b, err := os.ReadFile(path) | |
| 201 | + | if err != nil { | |
| 202 | + | t.Fatal(err) | |
| 203 | + | } | |
| 204 | + | if off < 0 { | |
| 205 | + | off += int64(len(b)) | |
| 206 | + | } | |
| 207 | + | b[off] ^= 0x01 | |
| 208 | + | if err := os.Chmod(path, 0o644); err != nil { | |
| 209 | + | t.Fatal(err) | |
| 210 | + | } | |
| 211 | + | if err := os.WriteFile(path, b, 0o644); err != nil { | |
| 212 | + | t.Fatal(err) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // A flipped byte in the compressed content or in the adler32 trailer of a | |
| 217 | + | // loose or a packed object is ErrCorrupt on every way to read it, since the | |
| 218 | + | // checksum is the only check of the inflated content. | |
| 219 | + | func TestCorruptedObjectsAreErrCorrupt(t *testing.T) { | |
| 220 | + | content := textLines(400, 9) | |
| 221 | + | for _, packed := range []bool{false, true} { | |
| 222 | + | for _, where := range []string{"trailer", "deflate"} { | |
| 223 | + | t.Run(fmt.Sprintf("packed=%v/%s", packed, where), func(t *testing.T) { | |
| 224 | + | f := newFx(t) | |
| 225 | + | f.write("f.txt", content, 0o644) | |
| 226 | + | f.commit("one") | |
| 227 | + | id := mustID(t, f.git("rev-parse", "HEAD:f.txt")) | |
| 228 | + | var file string | |
| 229 | + | var entryEnd int64 = -1 | |
| 230 | + | if packed { | |
| 231 | + | f.git("repack", "-adf") | |
| 232 | + | f.git("prune-packed") | |
| 233 | + | matches, _ := filepath.Glob(filepath.Join(f.dir, "objects/pack/*.pack")) | |
| 234 | + | if len(matches) != 1 { | |
| 235 | + | t.Fatalf("packs: %v", matches) | |
| 236 | + | } | |
| 237 | + | file = matches[0] | |
| 238 | + | // The blob's entry ends where the next one starts, or at | |
| 239 | + | // the pack trailer. | |
| 240 | + | r := f.open() | |
| 241 | + | p := r.snap.Load().packs[0] | |
| 242 | + | pos, ok := p.idx.find(&id) | |
| 243 | + | if !ok { | |
| 244 | + | t.Fatal("blob not in the pack") | |
| 245 | + | } | |
| 246 | + | off, _ := p.idx.offset(pos) | |
| 247 | + | next := uint64(len(p.data) - packTrailerLen) | |
| 248 | + | for i := range p.idx.n { | |
| 249 | + | if o, _ := p.idx.offset(i); o > off && o < next { | |
| 250 | + | next = o | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | entryEnd = int64(next) | |
| 254 | + | _ = r.Close() | |
| 255 | + | } else { | |
| 256 | + | f.git("prune-packed") | |
| 257 | + | file = filepath.Join(f.dir, "objects", id.String()[:2], id.String()[2:]) | |
| 258 | + | } | |
| 259 | + | switch { | |
| 260 | + | case where == "trailer" && packed: | |
| 261 | + | flipByteAt(t, file, entryEnd-1) | |
| 262 | + | case where == "trailer": | |
| 263 | + | flipByteAt(t, file, -1) | |
| 264 | + | case packed: | |
| 265 | + | flipByteAt(t, file, entryEnd/2+6) // inside the deflate data | |
| 266 | + | default: | |
| 267 | + | st, _ := os.Stat(file) | |
| 268 | + | flipByteAt(t, file, st.Size()/2) | |
| 269 | + | } | |
| 270 | + | r := f.open() | |
| 271 | + | if _, _, err := r.ReadObject(id, nil); !errors.Is(err, ErrCorrupt) { | |
| 272 | + | t.Errorf("ReadObject = %v, want ErrCorrupt", err) | |
| 273 | + | } | |
| 274 | + | _, rc, err := r.OpenBlob(id) | |
| 275 | + | if err == nil { | |
| 276 | + | _, err = io.Copy(io.Discard, rc) | |
| 277 | + | _ = rc.Close() | |
| 278 | + | } | |
| 279 | + | if !errors.Is(err, ErrCorrupt) { | |
| 280 | + | t.Errorf("OpenBlob = %v, want ErrCorrupt", err) | |
| 281 | + | } | |
| 282 | + | }) | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | } | |
| @@ -0,0 +1,285 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package git | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "compress/zlib" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "io" | |||
| 11 | + | "math/rand/v2" | |||
| 12 | + | "os" | |||
| 13 | + | "path/filepath" | |||
| 14 | + | "testing" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // inflateAll reads a whole zlib stream with the wrapper, reusing z. | |||
| 18 | + | func inflateAll(z *zlibReader, src []byte) ([]byte, error) { | |||
| 19 | + | br := bytes.NewReader(src) | |||
| 20 | + | if err := z.Reset(br); err != nil { | |||
| 21 | + | return nil, err | |||
| 22 | + | } | |||
| 23 | + | return io.ReadAll(z) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // stdInflate is the reference: compress/zlib on the same bytes. | |||
| 27 | + | func stdInflate(src []byte) ([]byte, error) { | |||
| 28 | + | zr, err := zlib.NewReader(bytes.NewReader(src)) | |||
| 29 | + | if err != nil { | |||
| 30 | + | return nil, err | |||
| 31 | + | } | |||
| 32 | + | return io.ReadAll(zr) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func zlibLevel(b []byte, level int) []byte { | |||
| 36 | + | var out bytes.Buffer | |||
| 37 | + | w, _ := zlib.NewWriterLevel(&out, level) | |||
| 38 | + | _, _ = w.Write(b) | |||
| 39 | + | _ = w.Close() | |||
| 40 | + | return out.Bytes() | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // zlibSamples are streams of every block type: stored, fixed and dynamic | |||
| 44 | + | // Huffman, empty, and several blocks. | |||
| 45 | + | func zlibSamples() map[string][]byte { | |||
| 46 | + | rng := rand.New(rand.NewPCG(1, 2)) | |||
| 47 | + | random := make([]byte, 1200) | |||
| 48 | + | for i := range random { | |||
| 49 | + | random[i] = byte(rng.Uint32()) | |||
| 50 | + | } | |||
| 51 | + | text := textLines(60, 3) | |||
| 52 | + | return map[string][]byte{ | |||
| 53 | + | "empty": zlibLevel(nil, zlib.DefaultCompression), | |||
| 54 | + | "tiny": zlibLevel([]byte("a"), zlib.DefaultCompression), | |||
| 55 | + | "text": zlibLevel(text, zlib.DefaultCompression), | |||
| 56 | + | "random": zlibLevel(random, zlib.DefaultCompression), | |||
| 57 | + | "stored": zlibLevel(text, zlib.NoCompression), | |||
| 58 | + | "huffman": zlibLevel(text, zlib.HuffmanOnly), | |||
| 59 | + | "fast": zlibLevel(text, zlib.BestSpeed), | |||
| 60 | + | "big": zlibLevel(bytes.Repeat(text, 3), zlib.BestCompression), | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestZlibReaderMatchesStdlib(t *testing.T) { | |||
| 65 | + | var z zlibReader // one reader for every stream: Reset must clear all state | |||
| 66 | + | for name, src := range zlibSamples() { | |||
| 67 | + | want, werr := stdInflate(src) | |||
| 68 | + | got, err := inflateAll(&z, src) | |||
| 69 | + | if werr != nil || err != nil || !bytes.Equal(got, want) { | |||
| 70 | + | t.Errorf("%s: got %d bytes (%v), stdlib %d bytes (%v)", name, len(got), err, len(want), werr) | |||
| 71 | + | } | |||
| 72 | + | // EOF is sticky and the checksum was verified. | |||
| 73 | + | if n, err := z.Read(make([]byte, 1)); n != 0 || err != io.EOF { | |||
| 74 | + | t.Errorf("%s: Read after the end = %d, %v", name, n, err) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestZlibReaderHeader(t *testing.T) { | |||
| 80 | + | good := zlibLevel([]byte("hello"), zlib.DefaultCompression) | |||
| 81 | + | mk := func(cmf, flg byte) []byte { return append([]byte{cmf, flg}, good[2:]...) } | |||
| 82 | + | // A valid FCHECK for the given CMF and FLG bits. | |||
| 83 | + | fix := func(cmf, flg byte) []byte { | |||
| 84 | + | flg &^= 0x1f | |||
| 85 | + | flg |= byte(31 - (uint(cmf)<<8|uint(flg))%31) | |||
| 86 | + | return mk(cmf, flg) | |||
| 87 | + | } | |||
| 88 | + | var z zlibReader | |||
| 89 | + | for _, c := range []struct { | |||
| 90 | + | name string | |||
| 91 | + | src []byte | |||
| 92 | + | want error // nil: accepted | |||
| 93 | + | }{ | |||
| 94 | + | {"valid", good, nil}, | |||
| 95 | + | {"window 4 KiB", fix(0x28, 0), nil}, | |||
| 96 | + | {"bad check", mk(good[0], good[1]+1), errZlibHeader}, | |||
| 97 | + | {"not deflate", fix(0x79, 0), errZlibHeader}, | |||
| 98 | + | {"window over 32 KiB", fix(0x88, 0), errZlibHeader}, | |||
| 99 | + | {"preset dictionary", fix(0x78, 0x20), errZlibDict}, | |||
| 100 | + | {"empty", nil, io.ErrUnexpectedEOF}, | |||
| 101 | + | {"one byte", good[:1], io.ErrUnexpectedEOF}, | |||
| 102 | + | } { | |||
| 103 | + | err := z.Reset(bytes.NewReader(c.src)) | |||
| 104 | + | if !errors.Is(err, c.want) && (err != nil || c.want != nil) { | |||
| 105 | + | t.Errorf("%s: Reset = %v, want %v", c.name, err, c.want) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | // A reader that failed its header recovers on the next Reset. | |||
| 109 | + | if got, err := inflateAll(&z, good); err != nil || string(got) != "hello" { | |||
| 110 | + | t.Fatalf("after errors: %q, %v", got, err) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // Every byte of the stream matters: a bit flipped in the deflate data or in | |||
| 115 | + | // the trailer never yields wrong content without an error. The only flips | |||
| 116 | + | // that go unnoticed are those that leave the content as it was (the unused | |||
| 117 | + | // bits after the last block). | |||
| 118 | + | func TestZlibReaderDetectsCorruption(t *testing.T) { | |||
| 119 | + | var z zlibReader | |||
| 120 | + | for name, src := range zlibSamples() { | |||
| 121 | + | want, err := stdInflate(src) | |||
| 122 | + | if err != nil { | |||
| 123 | + | t.Fatal(err) | |||
| 124 | + | } | |||
| 125 | + | detected := 0 | |||
| 126 | + | for i := range src { | |||
| 127 | + | for bit := range 8 { | |||
| 128 | + | bad := bytes.Clone(src) | |||
| 129 | + | bad[i] ^= 1 << bit | |||
| 130 | + | got, err := inflateAll(&z, bad) | |||
| 131 | + | switch { | |||
| 132 | + | case err != nil: | |||
| 133 | + | detected++ | |||
| 134 | + | case !bytes.Equal(got, want): | |||
| 135 | + | t.Fatalf("%s: bit %d of byte %d/%d changed the content without an error", name, bit, i, len(src)) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | // The 4 trailer bytes are always checked. | |||
| 140 | + | for i := len(src) - 4; i < len(src); i++ { | |||
| 141 | + | bad := bytes.Clone(src) | |||
| 142 | + | bad[i] ^= 0x01 | |||
| 143 | + | if _, err := inflateAll(&z, bad); !errors.Is(err, errZlibChecksum) { | |||
| 144 | + | t.Errorf("%s: flipped trailer byte %d: %v, want a checksum error", name, i, err) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | if total := len(src) * 8; detected < total*9/10 { | |||
| 148 | + | t.Errorf("%s: only %d of %d flips detected", name, detected, total) | |||
| 149 | + | } | |||
| 150 | + | // A truncated trailer or stream is an error too. | |||
| 151 | + | for _, n := range []int{len(src) - 1, len(src) - 4, len(src) - 5} { | |||
| 152 | + | if n < 2 { | |||
| 153 | + | continue | |||
| 154 | + | } | |||
| 155 | + | if _, err := inflateAll(&z, src[:n]); err == nil { | |||
| 156 | + | t.Errorf("%s: stream cut at %d of %d accepted", name, n, len(src)) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // The wrapper answers like compress/zlib on any input, except that it | |||
| 163 | + | // refuses a preset dictionary. | |||
| 164 | + | func FuzzZlibReader(f *testing.F) { | |||
| 165 | + | for _, src := range zlibSamples() { | |||
| 166 | + | f.Add(src) | |||
| 167 | + | if len(src) > 8 { | |||
| 168 | + | bad := bytes.Clone(src) | |||
| 169 | + | bad[len(src)-2] ^= 0x40 | |||
| 170 | + | f.Add(bad) | |||
| 171 | + | f.Add(src[:len(src)-3]) | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | f.Add([]byte{0x78, 0x20, 0, 0, 0, 1, 0x03, 0x00, 0, 0, 0, 1}) | |||
| 175 | + | f.Add([]byte{0x78}) | |||
| 176 | + | f.Add([]byte{}) | |||
| 177 | + | var z zlibReader | |||
| 178 | + | warm := zlibLevel([]byte("warm up the reused state"), zlib.DefaultCompression) | |||
| 179 | + | f.Fuzz(func(t *testing.T, src []byte) { | |||
| 180 | + | if _, err := inflateAll(&z, warm); err != nil { // dirty state from a good stream | |||
| 181 | + | t.Fatal(err) | |||
| 182 | + | } | |||
| 183 | + | got, err := inflateAll(&z, src) | |||
| 184 | + | if len(src) >= 2 && src[1]&0x20 != 0 && errors.Is(err, errZlibDict) { | |||
| 185 | + | return | |||
| 186 | + | } | |||
| 187 | + | want, werr := stdInflate(src) | |||
| 188 | + | if (err == nil) != (werr == nil) { | |||
| 189 | + | t.Fatalf("wrapper: %v, stdlib: %v", err, werr) | |||
| 190 | + | } | |||
| 191 | + | if err == nil && !bytes.Equal(got, want) { | |||
| 192 | + | t.Fatalf("content differs: %d bytes against %d", len(got), len(want)) | |||
| 193 | + | } | |||
| 194 | + | }) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // flipByteAt flips a bit of the byte at off (from the end when negative). | |||
| 198 | + | func flipByteAt(t *testing.T, path string, off int64) { | |||
| 199 | + | t.Helper() | |||
| 200 | + | b, err := os.ReadFile(path) | |||
| 201 | + | if err != nil { | |||
| 202 | + | t.Fatal(err) | |||
| 203 | + | } | |||
| 204 | + | if off < 0 { | |||
| 205 | + | off += int64(len(b)) | |||
| 206 | + | } | |||
| 207 | + | b[off] ^= 0x01 | |||
| 208 | + | if err := os.Chmod(path, 0o644); err != nil { | |||
| 209 | + | t.Fatal(err) | |||
| 210 | + | } | |||
| 211 | + | if err := os.WriteFile(path, b, 0o644); err != nil { | |||
| 212 | + | t.Fatal(err) | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // A flipped byte in the compressed content or in the adler32 trailer of a | |||
| 217 | + | // loose or a packed object is ErrCorrupt on every way to read it, since the | |||
| 218 | + | // checksum is the only check of the inflated content. | |||
| 219 | + | func TestCorruptedObjectsAreErrCorrupt(t *testing.T) { | |||
| 220 | + | content := textLines(400, 9) | |||
| 221 | + | for _, packed := range []bool{false, true} { | |||
| 222 | + | for _, where := range []string{"trailer", "deflate"} { | |||
| 223 | + | t.Run(fmt.Sprintf("packed=%v/%s", packed, where), func(t *testing.T) { | |||
| 224 | + | f := newFx(t) | |||
| 225 | + | f.write("f.txt", content, 0o644) | |||
| 226 | + | f.commit("one") | |||
| 227 | + | id := mustID(t, f.git("rev-parse", "HEAD:f.txt")) | |||
| 228 | + | var file string | |||
| 229 | + | var entryEnd int64 = -1 | |||
| 230 | + | if packed { | |||
| 231 | + | f.git("repack", "-adf") | |||
| 232 | + | f.git("prune-packed") | |||
| 233 | + | matches, _ := filepath.Glob(filepath.Join(f.dir, "objects/pack/*.pack")) | |||
| 234 | + | if len(matches) != 1 { | |||
| 235 | + | t.Fatalf("packs: %v", matches) | |||
| 236 | + | } | |||
| 237 | + | file = matches[0] | |||
| 238 | + | // The blob's entry ends where the next one starts, or at | |||
| 239 | + | // the pack trailer. | |||
| 240 | + | r := f.open() | |||
| 241 | + | p := r.snap.Load().packs[0] | |||
| 242 | + | pos, ok := p.idx.find(&id) | |||
| 243 | + | if !ok { | |||
| 244 | + | t.Fatal("blob not in the pack") | |||
| 245 | + | } | |||
| 246 | + | off, _ := p.idx.offset(pos) | |||
| 247 | + | next := uint64(len(p.data) - packTrailerLen) | |||
| 248 | + | for i := range p.idx.n { | |||
| 249 | + | if o, _ := p.idx.offset(i); o > off && o < next { | |||
| 250 | + | next = o | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | entryEnd = int64(next) | |||
| 254 | + | _ = r.Close() | |||
| 255 | + | } else { | |||
| 256 | + | f.git("prune-packed") | |||
| 257 | + | file = filepath.Join(f.dir, "objects", id.String()[:2], id.String()[2:]) | |||
| 258 | + | } | |||
| 259 | + | switch { | |||
| 260 | + | case where == "trailer" && packed: | |||
| 261 | + | flipByteAt(t, file, entryEnd-1) | |||
| 262 | + | case where == "trailer": | |||
| 263 | + | flipByteAt(t, file, -1) | |||
| 264 | + | case packed: | |||
| 265 | + | flipByteAt(t, file, entryEnd/2+6) // inside the deflate data | |||
| 266 | + | default: | |||
| 267 | + | st, _ := os.Stat(file) | |||
| 268 | + | flipByteAt(t, file, st.Size()/2) | |||
| 269 | + | } | |||
| 270 | + | r := f.open() | |||
| 271 | + | if _, _, err := r.ReadObject(id, nil); !errors.Is(err, ErrCorrupt) { | |||
| 272 | + | t.Errorf("ReadObject = %v, want ErrCorrupt", err) | |||
| 273 | + | } | |||
| 274 | + | _, rc, err := r.OpenBlob(id) | |||
| 275 | + | if err == nil { | |||
| 276 | + | _, err = io.Copy(io.Discard, rc) | |||
| 277 | + | _ = rc.Close() | |||
| 278 | + | } | |||
| 279 | + | if !errors.Is(err, ErrCorrupt) { | |||
| 280 | + | t.Errorf("OpenBlob = %v, want ErrCorrupt", err) | |||
| 281 | + | } | |||
| 282 | + | }) | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | } | |||
M loose.go +1 -1
| @@ -170,7 +170,7 @@ func (r *Repo) readLoose(id *ObjectID, dst []byte) (Type, []byte, error) { | |||
| 170 | 170 | } | |
| 171 | 171 | dst = grow(dst, int(l.size)) | |
| 172 | 172 | copy(dst, l.lead[:l.nlea]) | |
| 173 | - | if err := inflateExact(l.fi.zr, dst[l.nlea:], l.fi.scratch[:]); err != nil { | |
| 173 | + | if err := inflateExact(&l.fi.zr, dst[l.nlea:], l.fi.scratch[:]); err != nil { | |
| 174 | 174 | return 0, nil, wrapLoose(id, err) | |
| 175 | 175 | } | |
| 176 | 176 | return l.typ, dst, nil | |
| @@ -170,7 +170,7 @@ func (r *Repo) readLoose(id *ObjectID, dst []byte) (Type, []byte, error) { | |||||
| 170 | } | 170 | } | ||
| 171 | dst = grow(dst, int(l.size)) | 171 | dst = grow(dst, int(l.size)) | ||
| 172 | copy(dst, l.lead[:l.nlea]) | 172 | copy(dst, l.lead[:l.nlea]) | ||
| 173 | - | if err := inflateExact(l.fi.zr, dst[l.nlea:], l.fi.scratch[:]); err != nil { | 173 | + | if err := inflateExact(&l.fi.zr, dst[l.nlea:], l.fi.scratch[:]); err != nil { |
| 174 | return 0, nil, wrapLoose(id, err) | 174 | return 0, nil, wrapLoose(id, err) | ||
| 175 | } | 175 | } | ||
| 176 | return l.typ, dst, nil | 176 | return l.typ, dst, nil | ||
M pack.go +2 -2
| @@ -209,7 +209,7 @@ func (p *pack) inflateEntry(h *entryHdr, dst []byte) error { | |||
| 209 | 209 | return err | |
| 210 | 210 | } | |
| 211 | 211 | defer putMem(m) | |
| 212 | - | return inflateExact(m.zr, dst, m.scratch[:]) | |
| 212 | + | return inflateExact(&m.zr, dst, m.scratch[:]) | |
| 213 | 213 | } | |
| 214 | 214 | ||
| 215 | 215 | // chainEnd follows the chain of delta headers from off without inflating | |
| @@ -276,7 +276,7 @@ func (p *pack) header(off uint64) (Type, int64, error) { | |||
| 276 | 276 | } | |
| 277 | 277 | defer putMem(m) | |
| 278 | 278 | n := min(uint64(len(m.scratch)), h.size) | |
| 279 | - | if _, err := io.ReadFull(m.zr, m.scratch[:n]); err != nil { | |
| 279 | + | if _, err := io.ReadFull(&m.zr, m.scratch[:n]); err != nil { | |
| 280 | 280 | return 0, 0, corrupt("delta header: %v", err) | |
| 281 | 281 | } | |
| 282 | 282 | _, rs, _, err := deltaHeader(m.scratch[:n]) | |
| @@ -209,7 +209,7 @@ func (p *pack) inflateEntry(h *entryHdr, dst []byte) error { | |||||
| 209 | return err | 209 | return err | ||
| 210 | } | 210 | } | ||
| 211 | defer putMem(m) | 211 | defer putMem(m) | ||
| 212 | - | return inflateExact(m.zr, dst, m.scratch[:]) | 212 | + | return inflateExact(&m.zr, dst, m.scratch[:]) |
| 213 | } | 213 | } | ||
| 214 | 214 | ||||
| 215 | // chainEnd follows the chain of delta headers from off without inflating | 215 | // chainEnd follows the chain of delta headers from off without inflating | ||
| @@ -276,7 +276,7 @@ func (p *pack) header(off uint64) (Type, int64, error) { | |||||
| 276 | } | 276 | } | ||
| 277 | defer putMem(m) | 277 | defer putMem(m) | ||
| 278 | n := min(uint64(len(m.scratch)), h.size) | 278 | n := min(uint64(len(m.scratch)), h.size) | ||
| 279 | - | if _, err := io.ReadFull(m.zr, m.scratch[:n]); err != nil { | 279 | + | if _, err := io.ReadFull(&m.zr, m.scratch[:n]); err != nil { |
| 280 | return 0, 0, corrupt("delta header: %v", err) | 280 | return 0, 0, corrupt("delta header: %v", err) | ||
| 281 | } | 281 | } | ||
| 282 | _, rs, _, err := deltaHeader(m.scratch[:n]) | 282 | _, rs, _, err := deltaHeader(m.scratch[:n]) | ||