detached 9ff5364

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
8080 t.Errorf("ParseCommit allocates %v on a reused Commit", got)
8181 }
8282 }
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
7878 return 0, nil, err
7979 }
8080 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
8282 }
8383
8484 func (r *Repo) openLooseBlob(id *ObjectID) (int64, io.ReadCloser, error) {
@@ -94,7 +94,7 @@ func (r *Repo) openLooseBlob(id *ObjectID) (int64, io.ReadCloser, error) {
9494 l.close()
9595 return 0, nil, corrupt("loose object %s: size %d", id.String(), l.size)
9696 }
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
9898 }
9999
100100 // 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, err78 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}, nil81+ 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]}, nil97+ 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
55 import (
66 "bufio"
77 "bytes"
8- "compress/zlib"
8+ "compress/flate"
9+ "encoding/binary"
910 "errors"
11+ "hash"
12+ "hash/adler32"
1013 "io"
1114 "math"
1215 "sync"
@@ -47,9 +50,95 @@ func grow(b []byte, n int) []byte {
4750 return make([]byte, n, c)
4851 }
4952
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+
50139 // memInflater inflates a zlib stream held in memory.
51140 type memInflater struct {
52- zr io.ReadCloser
141+ zr zlibReader
53142 br bytes.Reader
54143 scratch [maxLooseHeader]byte // for reads through the io.Reader interface, which escape
55144 }
@@ -60,13 +149,7 @@ var memPool = sync.Pool{New: func() any { return new(memInflater) }}
60149 func getMem(src []byte) (*memInflater, error) {
61150 m := memPool.Get().(*memInflater)
62151 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 {
70153 putMem(m)
71154 return nil, corrupt("zlib header: %v", err)
72155 }
@@ -80,7 +163,7 @@ func putMem(m *memInflater) {
80163
81164 // fileInflater inflates a zlib stream read from a file.
82165 type fileInflater struct {
83- zr io.ReadCloser
166+ zr zlibReader
84167 br *bufio.Reader
85168 scratch [maxLooseHeader]byte
86169 }
@@ -90,13 +173,7 @@ var filePool = sync.Pool{New: func() any { return &fileInflater{br: bufio.NewRea
90173 func getFile(r io.Reader) (*fileInflater, error) {
91174 f := filePool.Get().(*fileInflater)
92175 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 {
100177 putFile(f)
101178 return nil, corrupt("zlib header: %v", err)
102179 }
@@ -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.ReadCloser141+ zr zlibReader
53 br bytes.Reader142 br bytes.Reader
54 scratch [maxLooseHeader]byte // for reads through the io.Reader interface, which escape143 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 error152+ 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.ReadCloser166+ zr zlibReader
84 br *bufio.Reader167 br *bufio.Reader
85 scratch [maxLooseHeader]byte168 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 error176+ 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) {
170170 }
171171 dst = grow(dst, int(l.size))
172172 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 {
174174 return 0, nil, wrapLoose(id, err)
175175 }
176176 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, nil176 return l.typ, dst, nil
M pack.go +2 -2
@@ -209,7 +209,7 @@ func (p *pack) inflateEntry(h *entryHdr, dst []byte) error {
209209 return err
210210 }
211211 defer putMem(m)
212- return inflateExact(m.zr, dst, m.scratch[:])
212+ return inflateExact(&m.zr, dst, m.scratch[:])
213213 }
214214
215215 // chainEnd follows the chain of delta headers from off without inflating
@@ -276,7 +276,7 @@ func (p *pack) header(off uint64) (Type, int64, error) {
276276 }
277277 defer putMem(m)
278278 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 {
280280 return 0, 0, corrupt("delta header: %v", err)
281281 }
282282 _, rs, _, err := deltaHeader(m.scratch[:n])
@@ -209,7 +209,7 @@ func (p *pack) inflateEntry(h *entryHdr, dst []byte) error {
209 return err209 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 inflating215 // 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])

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 2

↑↓ moveenter openesc close