detached 351738d

fix(snapshot): inflate object payloads into an exact-size buffer

commit
351738de7a983ac81132dd2e297eef50dc2f3b90
author
brice · · 2026-10-08 08:53 UTC
committer
brice · · 2026-10-08 09:31 UTC
parent
f0493e8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +67 -10
M internal/snapshotcodec/object_compress.go +26 -10
@@ -58,18 +58,10 @@ func inflateObjectPayload(compressed []byte, size uint32) ([]byte, error) {
5858 return nil, invalid(err)
5959 }
6060 defer objectDecompressorPool.Put(r)
61- out := bytes.NewBuffer(make([]byte, 0, min(int(size), inflateInitialCap)))
62- if _, err := io.Copy(out, io.LimitReader(r, int64(size))); err != nil {
61+ payload, err := readExact(r, int(size))
62+ if err != nil {
6363 return nil, invalid(err)
6464 }
65- if out.Len() != int(size) {
66- return nil, invalid(io.ErrUnexpectedEOF)
67- }
68- payload := out.Bytes()
69- if cap(payload) > len(payload)+len(payload)/4 {
70- // Restored payloads are retained; drop the growth slack.
71- payload = bytes.Clone(payload)
72- }
7365 var extra [1]byte
7466 if n, err := r.Read(extra[:]); n != 0 || err != io.EOF {
7567 if err == nil || err == io.EOF {
@@ -83,6 +75,30 @@ func inflateObjectPayload(compressed []byte, size uint32) ([]byte, error) {
8375 return payload, nil
8476 }
8577
78+// readExact reads exactly size bytes. It doubles the buffer as data arrives,
79+// starting at inflateInitialCap and capping every step at size, so a corrupt
80+// declared length allocates at most about twice what the stream produces and
81+// the returned payload has no growth slack.
82+func readExact(r io.Reader, size int) ([]byte, error) {
83+ buf := make([]byte, min(size, inflateInitialCap))
84+ for n := 0; ; {
85+ m, err := io.ReadFull(r, buf[n:])
86+ n += m
87+ if err != nil {
88+ if err == io.EOF {
89+ err = io.ErrUnexpectedEOF
90+ }
91+ return nil, err
92+ }
93+ if n == size {
94+ return buf, nil
95+ }
96+ grown := make([]byte, min(2*len(buf), size))
97+ copy(grown, buf)
98+ buf = grown
99+ }
100+}
101+
86102 func objectDecompressor(source io.Reader) (io.ReadCloser, error) {
87103 if pooled, ok := objectDecompressorPool.Get().(io.ReadCloser); ok {
88104 if err := pooled.(zlib.Resetter).Reset(source, nil); err != nil {
@@ -58,18 +58,10 @@ func inflateObjectPayload(compressed []byte, size uint32) ([]byte, error) {
58 return nil, invalid(err)58 return nil, invalid(err)
59 }59 }
60 defer objectDecompressorPool.Put(r)60 defer objectDecompressorPool.Put(r)
61- out := bytes.NewBuffer(make([]byte, 0, min(int(size), inflateInitialCap)))61+ payload, err := readExact(r, int(size))
62- if _, err := io.Copy(out, io.LimitReader(r, int64(size))); err != nil {62+ if err != nil {
63 return nil, invalid(err)63 return nil, invalid(err)
64 }64 }
65- if out.Len() != int(size) {
66- return nil, invalid(io.ErrUnexpectedEOF)
67- }
68- payload := out.Bytes()
69- if cap(payload) > len(payload)+len(payload)/4 {
70- // Restored payloads are retained; drop the growth slack.
71- payload = bytes.Clone(payload)
72- }
73 var extra [1]byte65 var extra [1]byte
74 if n, err := r.Read(extra[:]); n != 0 || err != io.EOF {66 if n, err := r.Read(extra[:]); n != 0 || err != io.EOF {
75 if err == nil || err == io.EOF {67 if err == nil || err == io.EOF {
@@ -83,6 +75,30 @@ func inflateObjectPayload(compressed []byte, size uint32) ([]byte, error) {
83 return payload, nil75 return payload, nil
84 }76 }
85 77
78+// readExact reads exactly size bytes. It doubles the buffer as data arrives,
79+// starting at inflateInitialCap and capping every step at size, so a corrupt
80+// declared length allocates at most about twice what the stream produces and
81+// the returned payload has no growth slack.
82+func readExact(r io.Reader, size int) ([]byte, error) {
83+ buf := make([]byte, min(size, inflateInitialCap))
84+ for n := 0; ; {
85+ m, err := io.ReadFull(r, buf[n:])
86+ n += m
87+ if err != nil {
88+ if err == io.EOF {
89+ err = io.ErrUnexpectedEOF
90+ }
91+ return nil, err
92+ }
93+ if n == size {
94+ return buf, nil
95+ }
96+ grown := make([]byte, min(2*len(buf), size))
97+ copy(grown, buf)
98+ buf = grown
99+ }
100+}
101+
86 func objectDecompressor(source io.Reader) (io.ReadCloser, error) {102 func objectDecompressor(source io.Reader) (io.ReadCloser, error) {
87 if pooled, ok := objectDecompressorPool.Get().(io.ReadCloser); ok {103 if pooled, ok := objectDecompressorPool.Get().(io.ReadCloser); ok {
88 if err := pooled.(zlib.Resetter).Reset(source, nil); err != nil {104 if err := pooled.(zlib.Resetter).Reset(source, nil); err != nil {
M internal/snapshotcodec/object_compress_test.go +41 -0
@@ -5,6 +5,7 @@ import (
55 "encoding/binary"
66 "errors"
77 "hash/crc32"
8+ "runtime"
89 "testing"
910 )
1011
@@ -65,3 +66,43 @@ func TestObjectCompressionRejectsMalformedPayloads(t *testing.T) {
6566 })
6667 }
6768 }
69+
70+// TestInflateObjectPayloadAllocation pins the inflate memory bound: a payload
71+// costs about its own size, and a corrupt maximum declared length on a small
72+// stream never allocates the declared size.
73+func TestInflateObjectPayloadAllocation(t *testing.T) {
74+ for _, tc := range []struct {
75+ name string
76+ size int
77+ declared uint32
78+ maxBytes int64
79+ }{
80+ {name: "small payload", size: 64 << 10, maxBytes: 64<<10 + 64<<10},
81+ {name: "payload at the initial cap", size: inflateInitialCap, maxBytes: inflateInitialCap + 64<<10},
82+ {name: "payload above the initial cap", size: 3 << 20, maxBytes: 2*(3<<20) + 64<<10},
83+ {name: "declared maximum on a small stream", size: 64 << 10, declared: maxObjectPayloadSize, maxBytes: 2*inflateInitialCap + 64<<10},
84+ } {
85+ t.Run(tc.name, func(t *testing.T) {
86+ payload := bytes.Repeat([]byte("history row "), tc.size/12+1)[:tc.size]
87+ compressed, err := compressObjectPayload(nil, payload)
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ declared := uint32(tc.size)
92+ if tc.declared != 0 {
93+ declared = tc.declared
94+ }
95+ _, _ = inflateObjectPayload(compressed, declared) // warm the decompressor pool
96+ const runs = 8
97+ var before, after runtime.MemStats
98+ runtime.ReadMemStats(&before)
99+ for range runs {
100+ _, _ = inflateObjectPayload(compressed, declared)
101+ }
102+ runtime.ReadMemStats(&after)
103+ if got := int64(after.TotalAlloc-before.TotalAlloc) / runs; got > tc.maxBytes {
104+ t.Fatalf("inflate allocated %d bytes/op, want at most %d", got, tc.maxBytes)
105+ }
106+ })
107+ }
108+}
@@ -5,6 +5,7 @@ import (
5 "encoding/binary"5 "encoding/binary"
6 "errors"6 "errors"
7 "hash/crc32"7 "hash/crc32"
8+ "runtime"
8 "testing"9 "testing"
9 )10 )
10 11
@@ -65,3 +66,43 @@ func TestObjectCompressionRejectsMalformedPayloads(t *testing.T) {
65 })66 })
66 }67 }
67 }68 }
69+
70+// TestInflateObjectPayloadAllocation pins the inflate memory bound: a payload
71+// costs about its own size, and a corrupt maximum declared length on a small
72+// stream never allocates the declared size.
73+func TestInflateObjectPayloadAllocation(t *testing.T) {
74+ for _, tc := range []struct {
75+ name string
76+ size int
77+ declared uint32
78+ maxBytes int64
79+ }{
80+ {name: "small payload", size: 64 << 10, maxBytes: 64<<10 + 64<<10},
81+ {name: "payload at the initial cap", size: inflateInitialCap, maxBytes: inflateInitialCap + 64<<10},
82+ {name: "payload above the initial cap", size: 3 << 20, maxBytes: 2*(3<<20) + 64<<10},
83+ {name: "declared maximum on a small stream", size: 64 << 10, declared: maxObjectPayloadSize, maxBytes: 2*inflateInitialCap + 64<<10},
84+ } {
85+ t.Run(tc.name, func(t *testing.T) {
86+ payload := bytes.Repeat([]byte("history row "), tc.size/12+1)[:tc.size]
87+ compressed, err := compressObjectPayload(nil, payload)
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ declared := uint32(tc.size)
92+ if tc.declared != 0 {
93+ declared = tc.declared
94+ }
95+ _, _ = inflateObjectPayload(compressed, declared) // warm the decompressor pool
96+ const runs = 8
97+ var before, after runtime.MemStats
98+ runtime.ReadMemStats(&before)
99+ for range runs {
100+ _, _ = inflateObjectPayload(compressed, declared)
101+ }
102+ runtime.ReadMemStats(&after)
103+ if got := int64(after.TotalAlloc-before.TotalAlloc) / runs; got > tc.maxBytes {
104+ t.Fatalf("inflate allocated %d bytes/op, want at most %d", got, tc.maxBytes)
105+ }
106+ })
107+ }
108+}

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 12

Tags 11

↑↓ moveenter openesc close