detached 03130e9

fix(snapshot): bound inflated object payloads on restore

Inflation allocated the declared raw length up front, so a corrupt object header could force a 256 MiB allocation. It now grows the buffer from a 1 MiB start as the stream produces bytes. Restoring one generation also caps its total inflated payloads at 256 MiB, the budget uncompressed checkpoints had, since the on-disk read cap no longer bounds memory.
commit
03130e93c0f13011c53aceb3c78817ff0a45e313
author
brice · · 2026-10-08 05:39 UTC
committer
brice · · 2026-10-08 05:46 UTC
parent
4604c71
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +37 -6
M internal/snapshotcodec/codec.go +4 -0
@@ -34,6 +34,10 @@ const (
3434 maxSnapshotDecodedAllocation = 256 << 20
3535 )
3636
37+// MaxGenerationPayloadBytes bounds the inflated object payloads of one
38+// incremental generation, the same budget an uncompressed checkpoint had.
39+const MaxGenerationPayloadBytes = 256 << 20
40+
3741 // Unmarshal first measures the complete manifest and its VT blobs without
3842 // allocating. After accepting aggregate budgets, it validates references and
3943 // blob semantics before decoding the session.
@@ -34,6 +34,10 @@ const (
34 maxSnapshotDecodedAllocation = 256 << 2034 maxSnapshotDecodedAllocation = 256 << 20
35 )35 )
36 36
37+// MaxGenerationPayloadBytes bounds the inflated object payloads of one
38+// incremental generation, the same budget an uncompressed checkpoint had.
39+const MaxGenerationPayloadBytes = 256 << 20
40+
37 // Unmarshal first measures the complete manifest and its VT blobs without41 // Unmarshal first measures the complete manifest and its VT blobs without
38 // allocating. After accepting aggregate budgets, it validates references and42 // allocating. After accepting aggregate budgets, it validates references and
39 // blob semantics before decoding the session.43 // blob semantics before decoding the session.
M internal/snapshotcodec/object_compress.go +12 -3
@@ -12,7 +12,12 @@ import (
1212 // Object payloads are stored zlib-compressed. History cells are highly
1313 // repetitive, so a sealed 10k-row history shrinks several times on disk.
1414 // BestSpeed keeps snapshot encoding cheap; Go's deflate output is
15-// deterministic for one level, so content addressing stays stable.
15+// deterministic for one toolchain and level, so content addressing stays
16+// stable between checkpoints of one build.
17+
18+// inflateInitialCap bounds the up-front allocation for one payload, so a
19+// corrupt declared length cannot allocate more than the stream produces.
20+const inflateInitialCap = 1 << 20
1621
1722 var objectCompressorPool = sync.Pool{New: func() any {
1823 w, err := zlib.NewWriterLevel(io.Discard, zlib.BestSpeed)
@@ -53,10 +58,14 @@ func inflateObjectPayload(compressed []byte, size uint32) ([]byte, error) {
5358 return nil, invalid(err)
5459 }
5560 defer objectDecompressorPool.Put(r)
56- payload := make([]byte, size)
57- if _, err := io.ReadFull(r, payload); err != nil {
61+ out := bytes.NewBuffer(make([]byte, 0, min(int(size), inflateInitialCap)))
62+ if _, err := io.Copy(out, io.LimitReader(r, int64(size))); err != nil {
5863 return nil, invalid(err)
5964 }
65+ if out.Len() != int(size) {
66+ return nil, invalid(io.ErrUnexpectedEOF)
67+ }
68+ payload := out.Bytes()
6069 var extra [1]byte
6170 if n, err := r.Read(extra[:]); n != 0 || err != io.EOF {
6271 if err == nil || err == io.EOF {
@@ -12,7 +12,12 @@ import (
12 // Object payloads are stored zlib-compressed. History cells are highly12 // Object payloads are stored zlib-compressed. History cells are highly
13 // repetitive, so a sealed 10k-row history shrinks several times on disk.13 // repetitive, so a sealed 10k-row history shrinks several times on disk.
14 // BestSpeed keeps snapshot encoding cheap; Go's deflate output is14 // BestSpeed keeps snapshot encoding cheap; Go's deflate output is
15-// deterministic for one level, so content addressing stays stable.15+// deterministic for one toolchain and level, so content addressing stays
16+// stable between checkpoints of one build.
17+
18+// inflateInitialCap bounds the up-front allocation for one payload, so a
19+// corrupt declared length cannot allocate more than the stream produces.
20+const inflateInitialCap = 1 << 20
16 21
17 var objectCompressorPool = sync.Pool{New: func() any {22 var objectCompressorPool = sync.Pool{New: func() any {
18 w, err := zlib.NewWriterLevel(io.Discard, zlib.BestSpeed)23 w, err := zlib.NewWriterLevel(io.Discard, zlib.BestSpeed)
@@ -53,10 +58,14 @@ func inflateObjectPayload(compressed []byte, size uint32) ([]byte, error) {
53 return nil, invalid(err)58 return nil, invalid(err)
54 }59 }
55 defer objectDecompressorPool.Put(r)60 defer objectDecompressorPool.Put(r)
56- payload := make([]byte, size)61+ out := bytes.NewBuffer(make([]byte, 0, min(int(size), inflateInitialCap)))
57- if _, err := io.ReadFull(r, payload); err != nil {62+ if _, err := io.Copy(out, io.LimitReader(r, int64(size))); err != nil {
58 return nil, invalid(err)63 return nil, invalid(err)
59 }64 }
65+ if out.Len() != int(size) {
66+ return nil, invalid(io.ErrUnexpectedEOF)
67+ }
68+ payload := out.Bytes()
60 var extra [1]byte69 var extra [1]byte
61 if n, err := r.Read(extra[:]); n != 0 || err != io.EOF {70 if n, err := r.Read(extra[:]); n != 0 || err != io.EOF {
62 if err == nil || err == io.EOF {71 if err == nil || err == io.EOF {
M internal/snapshotcodec/object_compress_test.go +4 -0
@@ -46,6 +46,10 @@ func TestObjectCompressionRejectsMalformedPayloads(t *testing.T) {
4646 binary.BigEndian.PutUint32(b[1:5], uint32(len(payload)+1))
4747 return b
4848 }, want: ErrInvalidData},
49+ {name: "declared maximum on a small stream", mutate: func(b []byte) []byte {
50+ binary.BigEndian.PutUint32(b[1:5], maxObjectPayloadSize)
51+ return b
52+ }, want: ErrInvalidData},
4953 {name: "truncated stream", mutate: func(b []byte) []byte { return b[:len(b)-1] }, want: ErrInvalidData},
5054 {name: "corrupt stream", mutate: func(b []byte) []byte { b[len(b)-6] ^= 0xff; return b }, want: ErrInvalidData},
5155 {name: "trailing garbage", mutate: func(b []byte) []byte { return append(b, 0) }, want: ErrTrailingBytes},
@@ -46,6 +46,10 @@ func TestObjectCompressionRejectsMalformedPayloads(t *testing.T) {
46 binary.BigEndian.PutUint32(b[1:5], uint32(len(payload)+1))46 binary.BigEndian.PutUint32(b[1:5], uint32(len(payload)+1))
47 return b47 return b
48 }, want: ErrInvalidData},48 }, want: ErrInvalidData},
49+ {name: "declared maximum on a small stream", mutate: func(b []byte) []byte {
50+ binary.BigEndian.PutUint32(b[1:5], maxObjectPayloadSize)
51+ return b
52+ }, want: ErrInvalidData},
49 {name: "truncated stream", mutate: func(b []byte) []byte { return b[:len(b)-1] }, want: ErrInvalidData},53 {name: "truncated stream", mutate: func(b []byte) []byte { return b[:len(b)-1] }, want: ErrInvalidData},
50 {name: "corrupt stream", mutate: func(b []byte) []byte { b[len(b)-6] ^= 0xff; return b }, want: ErrInvalidData},54 {name: "corrupt stream", mutate: func(b []byte) []byte { b[len(b)-6] ^= 0xff; return b }, want: ErrInvalidData},
51 {name: "trailing garbage", mutate: func(b []byte) []byte { return append(b, 0) }, want: ErrTrailingBytes},55 {name: "trailing garbage", mutate: func(b []byte) []byte { return append(b, 0) }, want: ErrTrailingBytes},
M internal/usecase/daemon/snapshot_restore_incremental.go +17 -3
@@ -395,21 +395,35 @@ func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Sessi
395395 return snapcodec.Session{}, fmt.Errorf("snapshot: generation identity mismatch")
396396 }
397397 result := snapcodec.Session{Name: manifest.Name, CreatedAt: manifest.CreatedAt, Active: manifest.Active, Tabs: make([]snapcodec.Tab, 0, len(manifest.Tabs))}
398+ // Objects are compressed, so the on-disk read budget no longer bounds the
399+ // inflated generation; budget its payloads as one uncompressed checkpoint.
400+ budget := snapcodec.MaxGenerationPayloadBytes
401+ object := func(ref snapcodec.ObjectRef, kind snapcodec.ObjectKind) ([]byte, error) {
402+ data, err := generationObject(generation, ref, kind)
403+ if err != nil {
404+ return nil, err
405+ }
406+ if len(data) > budget {
407+ return nil, fmt.Errorf("snapshot: generation payload too large")
408+ }
409+ budget -= len(data)
410+ return data, nil
411+ }
398412 for _, tab := range manifest.Tabs {
399413 outTab := snapcodec.Tab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.Pane, 0, len(tab.Panes))}
400414 for _, pane := range tab.Panes {
401415 outPane := snapcodec.Pane{ID: pane.ID, StableID: pane.StableID, Cwd: pane.Cwd, Process: pane.Process}
402416 for _, ref := range pane.Sealed {
403- data, err := generationObject(generation, ref, snapcodec.HistoryChunk)
417+ data, err := object(ref, snapcodec.HistoryChunk)
404418 if err != nil {
405419 return snapcodec.Session{}, err
406420 }
407421 outPane.SealedChunks = append(outPane.SealedChunks, data)
408422 }
409- if outPane.Tail, err = generationObject(generation, pane.Tail, snapcodec.HistoryTail); err != nil {
423+ if outPane.Tail, err = object(pane.Tail, snapcodec.HistoryTail); err != nil {
410424 return snapcodec.Session{}, err
411425 }
412- if outPane.Transcript, err = generationObject(generation, pane.Transcript, snapcodec.RecoveryTranscript); err != nil {
426+ if outPane.Transcript, err = object(pane.Transcript, snapcodec.RecoveryTranscript); err != nil {
413427 return snapcodec.Session{}, err
414428 }
415429 outTab.Panes = append(outTab.Panes, outPane)
@@ -395,21 +395,35 @@ func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Sessi
395 return snapcodec.Session{}, fmt.Errorf("snapshot: generation identity mismatch")395 return snapcodec.Session{}, fmt.Errorf("snapshot: generation identity mismatch")
396 }396 }
397 result := snapcodec.Session{Name: manifest.Name, CreatedAt: manifest.CreatedAt, Active: manifest.Active, Tabs: make([]snapcodec.Tab, 0, len(manifest.Tabs))}397 result := snapcodec.Session{Name: manifest.Name, CreatedAt: manifest.CreatedAt, Active: manifest.Active, Tabs: make([]snapcodec.Tab, 0, len(manifest.Tabs))}
398+ // Objects are compressed, so the on-disk read budget no longer bounds the
399+ // inflated generation; budget its payloads as one uncompressed checkpoint.
400+ budget := snapcodec.MaxGenerationPayloadBytes
401+ object := func(ref snapcodec.ObjectRef, kind snapcodec.ObjectKind) ([]byte, error) {
402+ data, err := generationObject(generation, ref, kind)
403+ if err != nil {
404+ return nil, err
405+ }
406+ if len(data) > budget {
407+ return nil, fmt.Errorf("snapshot: generation payload too large")
408+ }
409+ budget -= len(data)
410+ return data, nil
411+ }
398 for _, tab := range manifest.Tabs {412 for _, tab := range manifest.Tabs {
399 outTab := snapcodec.Tab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.Pane, 0, len(tab.Panes))}413 outTab := snapcodec.Tab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.Pane, 0, len(tab.Panes))}
400 for _, pane := range tab.Panes {414 for _, pane := range tab.Panes {
401 outPane := snapcodec.Pane{ID: pane.ID, StableID: pane.StableID, Cwd: pane.Cwd, Process: pane.Process}415 outPane := snapcodec.Pane{ID: pane.ID, StableID: pane.StableID, Cwd: pane.Cwd, Process: pane.Process}
402 for _, ref := range pane.Sealed {416 for _, ref := range pane.Sealed {
403- data, err := generationObject(generation, ref, snapcodec.HistoryChunk)417+ data, err := object(ref, snapcodec.HistoryChunk)
404 if err != nil {418 if err != nil {
405 return snapcodec.Session{}, err419 return snapcodec.Session{}, err
406 }420 }
407 outPane.SealedChunks = append(outPane.SealedChunks, data)421 outPane.SealedChunks = append(outPane.SealedChunks, data)
408 }422 }
409- if outPane.Tail, err = generationObject(generation, pane.Tail, snapcodec.HistoryTail); err != nil {423+ if outPane.Tail, err = object(pane.Tail, snapcodec.HistoryTail); err != nil {
410 return snapcodec.Session{}, err424 return snapcodec.Session{}, err
411 }425 }
412- if outPane.Transcript, err = generationObject(generation, pane.Transcript, snapcodec.RecoveryTranscript); err != nil {426+ if outPane.Transcript, err = object(pane.Transcript, snapcodec.RecoveryTranscript); err != nil {
413 return snapcodec.Session{}, err427 return snapcodec.Session{}, err
414 }428 }
415 outTab.Panes = append(outTab.Panes, outPane)429 outTab.Panes = append(outTab.Panes, outPane)

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