detached bf359d3

test(snapshot): specify v3 snapshot validation

commit
bf359d38e6959b18667e7e55f72666ec28836987
author
brice · · 2026-07-12 15:22 UTC
parent
2c3eaa1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +219 -0
M internal/usecase/snapshot/codec_test.go +219 -0
@@ -107,6 +107,141 @@ func TestRoundTripSessions(t *testing.T) {
107107 }
108108 }
109109
110+// V3 preserves the v2 16-byte big-endian envelope, but replaces its
111+// compressed row payload with a flags-zero manifest of opaque VT blobs.
112+// Snapshot owns only the manifest: sealed chunks are oldest-first and all
113+// chunk, tail, and visible payloads remain self-contained VT bytes.
114+const (
115+ snapshotV3Version = uint16(3)
116+ v3EnvelopeSize = 16
117+)
118+
119+func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
120+ indexed := renderer.DefaultStyle()
121+ indexed.Bold = true
122+ indexed.Inverse = true
123+ indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough
124+ indexed.Foreground = 196
125+ indexed.Background = 17
126+ indexed.UnderlineStyle = renderer.UnderlineCurly
127+ indexed.HasUnderlineColor = true
128+ indexed.UnderlineColor = 203
129+
130+ rgb := renderer.DefaultStyle()
131+ rgb.Italic = true
132+ rgb.HasForegroundRGB = true
133+ rgb.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
134+ rgb.HasBackgroundRGB = true
135+ rgb.BackgroundRGB = renderer.RGB{R: 4, G: 5, B: 6}
136+ rgb.UnderlineStyle = renderer.UnderlineDashed
137+ rgb.HasUnderlineColorRGB = true
138+ rgb.UnderlineColorRGB = renderer.RGB{R: 7, G: 8, B: 9}
139+
140+ want := Session{Name: "v3 exact", Tabs: []Tab{{
141+ Focus: "p",
142+ Tree: layout.NewTree("p"),
143+ Panes: []Pane{{ID: "p", Scrollback: [][]renderer.Cell{{
144+ {Rune: 'I', Style: indexed},
145+ {Rune: '好', Style: rgb},
146+ {Continuation: true, Style: rgb},
147+ }}, Visible: [][]renderer.Cell{{
148+ {Rune: 'V', Style: rgb},
149+ {Rune: '界', Style: indexed},
150+ {Continuation: true, Style: indexed},
151+ }}}},
152+ }}}
153+
154+ encoded, err := Marshal(want)
155+ if err != nil {
156+ t.Fatalf("Marshal() error = %v", err)
157+ }
158+ requireV3Envelope(t, encoded)
159+ got, err := Unmarshal(encoded)
160+ if err != nil {
161+ t.Fatalf("Unmarshal() error = %v", err)
162+ }
163+ if !equalSession(got, want) {
164+ t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, want)
165+ }
166+}
167+
168+func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) {
169+ for _, tc := range []struct {
170+ name string
171+ version uint16
172+ }{
173+ {name: "v1", version: 1},
174+ {name: "v2", version: 2},
175+ } {
176+ t.Run(tc.name, func(t *testing.T) {
177+ _, err := Unmarshal(v3Envelope(tc.version, 0, nil))
178+ if !errors.Is(err, ErrBadVersion) {
179+ t.Fatalf("Unmarshal() error = %v, want %v", err, ErrBadVersion)
180+ }
181+ })
182+ }
183+}
184+
185+func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) {
186+ body := v3Manifest(0, nil, nil, 0, nil, 0, nil)
187+ valid := v3Envelope(snapshotV3Version, 0, body)
188+ badCRC := append([]byte(nil), valid...)
189+ badCRC[15] ^= 1
190+
191+ for _, tc := range []struct {
192+ name string
193+ data []byte
194+ want error
195+ }{
196+ {name: "short envelope", data: valid[:v3EnvelopeSize-1], want: ErrShortPayload},
197+ {name: "bad magic", data: append([]byte("NOPE"), valid[4:]...), want: ErrBadMagic},
198+ {name: "future version", data: v3Envelope(snapshotV3Version+1, 0, body), want: ErrBadVersion},
199+ {name: "nonzero flags", data: v3Envelope(snapshotV3Version, 1, body), want: ErrInvalidData},
200+ {name: "bad checksum", data: badCRC, want: ErrBadCRC},
201+ {name: "oversized body declaration", data: v3EnvelopeWithLength(snapshotV3Version, 0, nil, math.MaxUint32), want: ErrInvalidData},
202+ } {
203+ t.Run(tc.name, func(t *testing.T) {
204+ assertV3RejectsWithoutPanic(t, tc.data, tc.want)
205+ })
206+ }
207+}
208+
209+func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testing.T) {
210+ for _, tc := range []struct {
211+ name string
212+ body []byte
213+ }{
214+ {name: "sealed chunk count", body: v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)},
215+ {name: "sealed blob length", body: v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)},
216+ {name: "tail blob length", body: v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)},
217+ {name: "visible blob length", body: v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)},
218+ // Aggregate accounting is snapshot-owned even though each payload is
219+ // VT-owned opaque data; declarations must be bounded before a blob can
220+ // cause a count-driven allocation.
221+ {name: "aggregate opaque blob declarations", body: v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)},
222+ } {
223+ t.Run(tc.name, func(t *testing.T) {
224+ assertV3RejectsWithoutPanic(t, v3Envelope(snapshotV3Version, 0, tc.body), ErrInvalidData)
225+ })
226+ }
227+}
228+
229+func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {
230+ encoded, err := Marshal(Session{Name: "v3 prefixes", Tabs: []Tab{{
231+ Focus: "p",
232+ Tree: layout.NewTree("p"),
233+ Panes: []Pane{{ID: "p", Scrollback: rows("history"), Visible: rows("visible")}},
234+ }}})
235+ if err != nil {
236+ t.Fatalf("Marshal() error = %v", err)
237+ }
238+ requireV3Envelope(t, encoded)
239+ for n := range len(encoded) {
240+ assertV3RejectsWithoutPanic(t, encoded[:n], nil)
241+ }
242+ assertV3RejectsWithoutPanic(t, append(append([]byte(nil), encoded...), 0), ErrTrailingBytes)
243+}
244+
110245 func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
111246 good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}})
112247 if err != nil {
@@ -431,3 +566,87 @@ func unknownPaneSnapshot(t *testing.T) []byte {
431566 }
432567 return b
433568 }
569+
570+func requireV3Envelope(t *testing.T, data []byte) {
571+ t.Helper()
572+ if len(data) < v3EnvelopeSize {
573+ t.Fatalf("encoded snapshot is %d bytes, want at least %d", len(data), v3EnvelopeSize)
574+ }
575+ if got := binary.BigEndian.Uint16(data[4:6]); got != snapshotV3Version {
576+ t.Fatalf("snapshot version = %d, want v3", got)
577+ }
578+ if got := binary.BigEndian.Uint16(data[6:8]); got != 0 {
579+ t.Fatalf("snapshot flags = %#x, want 0", got)
580+ }
581+}
582+
583+func assertV3RejectsWithoutPanic(t *testing.T, data []byte, want error) {
584+ t.Helper()
585+ defer func() {
586+ if r := recover(); r != nil {
587+ t.Fatalf("Unmarshal panicked: %v", r)
588+ }
589+ }()
590+ _, err := Unmarshal(data)
591+ if err == nil {
592+ t.Fatal("Unmarshal() error = nil")
593+ }
594+ if want != nil && !errors.Is(err, want) {
595+ t.Fatalf("Unmarshal() error = %v, want %v", err, want)
596+ }
597+}
598+
599+func v3Envelope(version, flags uint16, body []byte) []byte {
600+ return v3EnvelopeWithLength(version, flags, body, uint32(len(body)))
601+}
602+
603+func v3EnvelopeWithLength(version, flags uint16, body []byte, bodyLen uint32) []byte {
604+ out := make([]byte, v3EnvelopeSize+len(body))
605+ copy(out[:4], magic)
606+ binary.BigEndian.PutUint16(out[4:6], version)
607+ binary.BigEndian.PutUint16(out[6:8], flags)
608+ binary.BigEndian.PutUint32(out[8:12], bodyLen)
609+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
610+ copy(out[v3EnvelopeSize:], body)
611+ return out
612+}
613+
614+// v3Manifest writes only snapshot-owned fields. Blob contents are deliberately
615+// opaque: VT validates their style, row, run, cell, and decoded-byte budgets.
616+func v3Manifest(
617+ sealedCount uint32,
618+ sealedLengths []uint32,
619+ sealedBlobs [][]byte,
620+ tailLength uint32,
621+ tailBlob []byte,
622+ visibleLength uint32,
623+ visibleBlob []byte,
624+) []byte {
625+ var w payloadWriter
626+ _ = w.putString("v3")
627+ w.putUint64(0)
628+ w.putUint16(0)
629+ w.putUint16(1)
630+ _ = w.putString("tab")
631+ w.putUint16(80)
632+ w.putUint16(24)
633+ w.putUint64(1)
634+ _ = w.putString("p")
635+ _ = writeNode(&w, layout.NewLeaf("p"))
636+ w.putUint16(1)
637+ _ = w.putString("p")
638+ _ = w.putString("")
639+ _ = w.putString("")
640+ w.putUint32(sealedCount)
641+ for i := range sealedLengths {
642+ w.putUint32(sealedLengths[i])
643+ if i < len(sealedBlobs) {
644+ w.b = append(w.b, sealedBlobs[i]...)
645+ }
646+ }
647+ w.putUint32(tailLength)
648+ w.b = append(w.b, tailBlob...)
649+ w.putUint32(visibleLength)
650+ w.b = append(w.b, visibleBlob...)
651+ return w.b
652+}
@@ -107,6 +107,141 @@ func TestRoundTripSessions(t *testing.T) {
107 }107 }
108 }108 }
109 109
110+// V3 preserves the v2 16-byte big-endian envelope, but replaces its
111+// compressed row payload with a flags-zero manifest of opaque VT blobs.
112+// Snapshot owns only the manifest: sealed chunks are oldest-first and all
113+// chunk, tail, and visible payloads remain self-contained VT bytes.
114+const (
115+ snapshotV3Version = uint16(3)
116+ v3EnvelopeSize = 16
117+)
118+
119+func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
120+ indexed := renderer.DefaultStyle()
121+ indexed.Bold = true
122+ indexed.Inverse = true
123+ indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough
124+ indexed.Foreground = 196
125+ indexed.Background = 17
126+ indexed.UnderlineStyle = renderer.UnderlineCurly
127+ indexed.HasUnderlineColor = true
128+ indexed.UnderlineColor = 203
129+
130+ rgb := renderer.DefaultStyle()
131+ rgb.Italic = true
132+ rgb.HasForegroundRGB = true
133+ rgb.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
134+ rgb.HasBackgroundRGB = true
135+ rgb.BackgroundRGB = renderer.RGB{R: 4, G: 5, B: 6}
136+ rgb.UnderlineStyle = renderer.UnderlineDashed
137+ rgb.HasUnderlineColorRGB = true
138+ rgb.UnderlineColorRGB = renderer.RGB{R: 7, G: 8, B: 9}
139+
140+ want := Session{Name: "v3 exact", Tabs: []Tab{{
141+ Focus: "p",
142+ Tree: layout.NewTree("p"),
143+ Panes: []Pane{{ID: "p", Scrollback: [][]renderer.Cell{{
144+ {Rune: 'I', Style: indexed},
145+ {Rune: '好', Style: rgb},
146+ {Continuation: true, Style: rgb},
147+ }}, Visible: [][]renderer.Cell{{
148+ {Rune: 'V', Style: rgb},
149+ {Rune: '界', Style: indexed},
150+ {Continuation: true, Style: indexed},
151+ }}}},
152+ }}}
153+
154+ encoded, err := Marshal(want)
155+ if err != nil {
156+ t.Fatalf("Marshal() error = %v", err)
157+ }
158+ requireV3Envelope(t, encoded)
159+ got, err := Unmarshal(encoded)
160+ if err != nil {
161+ t.Fatalf("Unmarshal() error = %v", err)
162+ }
163+ if !equalSession(got, want) {
164+ t.Fatalf("round trip mismatch\ngot %#v\nwant %#v", got, want)
165+ }
166+}
167+
168+func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) {
169+ for _, tc := range []struct {
170+ name string
171+ version uint16
172+ }{
173+ {name: "v1", version: 1},
174+ {name: "v2", version: 2},
175+ } {
176+ t.Run(tc.name, func(t *testing.T) {
177+ _, err := Unmarshal(v3Envelope(tc.version, 0, nil))
178+ if !errors.Is(err, ErrBadVersion) {
179+ t.Fatalf("Unmarshal() error = %v, want %v", err, ErrBadVersion)
180+ }
181+ })
182+ }
183+}
184+
185+func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) {
186+ body := v3Manifest(0, nil, nil, 0, nil, 0, nil)
187+ valid := v3Envelope(snapshotV3Version, 0, body)
188+ badCRC := append([]byte(nil), valid...)
189+ badCRC[15] ^= 1
190+
191+ for _, tc := range []struct {
192+ name string
193+ data []byte
194+ want error
195+ }{
196+ {name: "short envelope", data: valid[:v3EnvelopeSize-1], want: ErrShortPayload},
197+ {name: "bad magic", data: append([]byte("NOPE"), valid[4:]...), want: ErrBadMagic},
198+ {name: "future version", data: v3Envelope(snapshotV3Version+1, 0, body), want: ErrBadVersion},
199+ {name: "nonzero flags", data: v3Envelope(snapshotV3Version, 1, body), want: ErrInvalidData},
200+ {name: "bad checksum", data: badCRC, want: ErrBadCRC},
201+ {name: "oversized body declaration", data: v3EnvelopeWithLength(snapshotV3Version, 0, nil, math.MaxUint32), want: ErrInvalidData},
202+ } {
203+ t.Run(tc.name, func(t *testing.T) {
204+ assertV3RejectsWithoutPanic(t, tc.data, tc.want)
205+ })
206+ }
207+}
208+
209+func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testing.T) {
210+ for _, tc := range []struct {
211+ name string
212+ body []byte
213+ }{
214+ {name: "sealed chunk count", body: v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)},
215+ {name: "sealed blob length", body: v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)},
216+ {name: "tail blob length", body: v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)},
217+ {name: "visible blob length", body: v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)},
218+ // Aggregate accounting is snapshot-owned even though each payload is
219+ // VT-owned opaque data; declarations must be bounded before a blob can
220+ // cause a count-driven allocation.
221+ {name: "aggregate opaque blob declarations", body: v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)},
222+ } {
223+ t.Run(tc.name, func(t *testing.T) {
224+ assertV3RejectsWithoutPanic(t, v3Envelope(snapshotV3Version, 0, tc.body), ErrInvalidData)
225+ })
226+ }
227+}
228+
229+func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {
230+ encoded, err := Marshal(Session{Name: "v3 prefixes", Tabs: []Tab{{
231+ Focus: "p",
232+ Tree: layout.NewTree("p"),
233+ Panes: []Pane{{ID: "p", Scrollback: rows("history"), Visible: rows("visible")}},
234+ }}})
235+ if err != nil {
236+ t.Fatalf("Marshal() error = %v", err)
237+ }
238+ requireV3Envelope(t, encoded)
239+ for n := range len(encoded) {
240+ assertV3RejectsWithoutPanic(t, encoded[:n], nil)
241+ }
242+ assertV3RejectsWithoutPanic(t, append(append([]byte(nil), encoded...), 0), ErrTrailingBytes)
243+}
244+
110 func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {245 func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
111 good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}})246 good, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p"}}}}})
112 if err != nil {247 if err != nil {
@@ -431,3 +566,87 @@ func unknownPaneSnapshot(t *testing.T) []byte {
431 }566 }
432 return b567 return b
433 }568 }
569+
570+func requireV3Envelope(t *testing.T, data []byte) {
571+ t.Helper()
572+ if len(data) < v3EnvelopeSize {
573+ t.Fatalf("encoded snapshot is %d bytes, want at least %d", len(data), v3EnvelopeSize)
574+ }
575+ if got := binary.BigEndian.Uint16(data[4:6]); got != snapshotV3Version {
576+ t.Fatalf("snapshot version = %d, want v3", got)
577+ }
578+ if got := binary.BigEndian.Uint16(data[6:8]); got != 0 {
579+ t.Fatalf("snapshot flags = %#x, want 0", got)
580+ }
581+}
582+
583+func assertV3RejectsWithoutPanic(t *testing.T, data []byte, want error) {
584+ t.Helper()
585+ defer func() {
586+ if r := recover(); r != nil {
587+ t.Fatalf("Unmarshal panicked: %v", r)
588+ }
589+ }()
590+ _, err := Unmarshal(data)
591+ if err == nil {
592+ t.Fatal("Unmarshal() error = nil")
593+ }
594+ if want != nil && !errors.Is(err, want) {
595+ t.Fatalf("Unmarshal() error = %v, want %v", err, want)
596+ }
597+}
598+
599+func v3Envelope(version, flags uint16, body []byte) []byte {
600+ return v3EnvelopeWithLength(version, flags, body, uint32(len(body)))
601+}
602+
603+func v3EnvelopeWithLength(version, flags uint16, body []byte, bodyLen uint32) []byte {
604+ out := make([]byte, v3EnvelopeSize+len(body))
605+ copy(out[:4], magic)
606+ binary.BigEndian.PutUint16(out[4:6], version)
607+ binary.BigEndian.PutUint16(out[6:8], flags)
608+ binary.BigEndian.PutUint32(out[8:12], bodyLen)
609+ binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
610+ copy(out[v3EnvelopeSize:], body)
611+ return out
612+}
613+
614+// v3Manifest writes only snapshot-owned fields. Blob contents are deliberately
615+// opaque: VT validates their style, row, run, cell, and decoded-byte budgets.
616+func v3Manifest(
617+ sealedCount uint32,
618+ sealedLengths []uint32,
619+ sealedBlobs [][]byte,
620+ tailLength uint32,
621+ tailBlob []byte,
622+ visibleLength uint32,
623+ visibleBlob []byte,
624+) []byte {
625+ var w payloadWriter
626+ _ = w.putString("v3")
627+ w.putUint64(0)
628+ w.putUint16(0)
629+ w.putUint16(1)
630+ _ = w.putString("tab")
631+ w.putUint16(80)
632+ w.putUint16(24)
633+ w.putUint64(1)
634+ _ = w.putString("p")
635+ _ = writeNode(&w, layout.NewLeaf("p"))
636+ w.putUint16(1)
637+ _ = w.putString("p")
638+ _ = w.putString("")
639+ _ = w.putString("")
640+ w.putUint32(sealedCount)
641+ for i := range sealedLengths {
642+ w.putUint32(sealedLengths[i])
643+ if i < len(sealedBlobs) {
644+ w.b = append(w.b, sealedBlobs[i]...)
645+ }
646+ }
647+ w.putUint32(tailLength)
648+ w.b = append(w.b, tailBlob...)
649+ w.putUint32(visibleLength)
650+ w.b = append(w.b, visibleBlob...)
651+ return w.b
652+}

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