detached 06dc0bb

fix(vt): harden resize reflow metadata

commit
06dc0bba52805e02b4976f521e8e3f84e012d7bc
author
brice · · 2026-07-15 19:28 UTC
parent
b93fa1a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +173 -53
M pkg/vt/buffer.go +15 -0
@@ -37,6 +37,21 @@ func (b *buffer) content(y, end int) {
3737 b.boundaries[y].end = max(b.boundaries[y].end, clamp(end, 0, b.frame.Width))
3838 }
3939
40+func (b *buffer) truncate(y, end int) {
41+ if y < 0 || y >= len(b.boundaries) {
42+ return
43+ }
44+ b.boundaries[y].end = min(b.boundaries[y].end, clamp(end, 0, b.frame.Width))
45+}
46+
47+// insert retains the meaningful shifted tail when insertion happens within it.
48+func (b *buffer) insert(y, at, width int) {
49+ if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].end {
50+ return
51+ }
52+ b.boundaries[y].end = min(b.boundaries[y].end+width, b.frame.Width)
53+}
54+
4055 func (b *buffer) hard(y int) {
4156 if y >= 0 && y < len(b.boundaries) {
4257 b.boundaries[y].soft = false
@@ -37,6 +37,21 @@ func (b *buffer) content(y, end int) {
37 b.boundaries[y].end = max(b.boundaries[y].end, clamp(end, 0, b.frame.Width))37 b.boundaries[y].end = max(b.boundaries[y].end, clamp(end, 0, b.frame.Width))
38 }38 }
39 39
40+func (b *buffer) truncate(y, end int) {
41+ if y < 0 || y >= len(b.boundaries) {
42+ return
43+ }
44+ b.boundaries[y].end = min(b.boundaries[y].end, clamp(end, 0, b.frame.Width))
45+}
46+
47+// insert retains the meaningful shifted tail when insertion happens within it.
48+func (b *buffer) insert(y, at, width int) {
49+ if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].end {
50+ return
51+ }
52+ b.boundaries[y].end = min(b.boundaries[y].end+width, b.frame.Width)
53+}
54+
40 func (b *buffer) hard(y int) {55 func (b *buffer) hard(y int) {
41 if y >= 0 && y < len(b.boundaries) {56 if y >= 0 && y < len(b.boundaries) {
42 b.boundaries[y].soft = false57 b.boundaries[y].soft = false
M pkg/vt/history_codec.go +3 -2
@@ -24,8 +24,9 @@ const (
2424 maxHistoryRowCells = 160
2525 maxHistoryCells = maxHistoryRows * maxHistoryRowCells
2626
27- maxHistoryDecodeStyles = maxHistoryCells
28- maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes
27+ maxHistoryDecodeStyles = maxHistoryCells
28+ maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes
29+ maxVisibleBoundaryBytes = maxHistoryRows * visibleBoundaryBytes
2930 )
3031
3132 var errInvalidHistory = errors.New("invalid history payload")
@@ -24,8 +24,9 @@ const (
24 maxHistoryRowCells = 16024 maxHistoryRowCells = 160
25 maxHistoryCells = maxHistoryRows * maxHistoryRowCells25 maxHistoryCells = maxHistoryRows * maxHistoryRowCells
26 26
27- maxHistoryDecodeStyles = maxHistoryCells27+ maxHistoryDecodeStyles = maxHistoryCells
28- maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes28+ maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes
29+ maxVisibleBoundaryBytes = maxHistoryRows * visibleBoundaryBytes
29 )30 )
30 31
31 var errInvalidHistory = errors.New("invalid history payload")32 var errInvalidHistory = errors.New("invalid history payload")
A pkg/vt/resize_reflow_regression_test.go +82 -0
@@ -0,0 +1,82 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) {
11+ s := NewScreen(5, 3)
12+ s.Write([]byte("shell"))
13+ s.Write([]byte("\x1b[?1049h"))
14+ s.Write([]byte("\x1b["))
15+
16+ s.Resize(0, 0)
17+
18+ if len(s.escapeBuf) != 0 {
19+ t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf)
20+ }
21+ if s.Frame.Width != 0 || s.Frame.Height != 0 {
22+ t.Fatalf("active frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height)
23+ }
24+ s.Write([]byte("\x1b[?1049l"))
25+ if s.Frame.Width != 0 || s.Frame.Height != 0 {
26+ t.Fatalf("saved primary frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height)
27+ }
28+}
29+
30+func TestResizeReflowDropsAbandonedWideEdgePadding(t *testing.T) {
31+ s := NewScreen(4, 3)
32+ s.Write([]byte("ab界"))
33+ s.Write([]byte("\x1b[1;4H界x"))
34+
35+ s.Resize(6, 3)
36+
37+ assertCell(t, s, 0, 0, 'a')
38+ assertCell(t, s, 1, 0, 'b')
39+ assertCell(t, s, 2, 0, '界')
40+ if !cellAt(s, 3, 0).Continuation {
41+ t.Fatal("reflowed wide rune lost continuation")
42+ }
43+ assertCell(t, s, 4, 0, 'x')
44+}
45+
46+func TestInsertModeRetainsShiftedTailInReflowExtent(t *testing.T) {
47+ s := NewScreen(6, 2)
48+ s.Write([]byte("abcd"))
49+ s.Write([]byte("\x1b[1;2H\x1b[4hX"))
50+ // Simulate the subsequent logical continuation that causes the row to be
51+ // reflowed. Its meaningful extent must include the shifted tail.
52+ s.buffer.continueRow(0)
53+
54+ s.Resize(5, 2)
55+
56+ for x, r := range []rune("aXbcd") {
57+ assertCell(t, s, x, 0, r)
58+ }
59+}
60+
61+func TestVisibleBoundaryMetadataBudget(t *testing.T) {
62+ const height = maxHistoryRows + 1
63+ if _, err := MarshalVisible(renderer.NewFrame(0, height)); err == nil {
64+ t.Fatal("marshal accepted boundary metadata beyond its allocation budget")
65+ }
66+ s := NewScreen(0, height)
67+ if _, err := s.MarshalPrimaryVisible(); err == nil {
68+ t.Fatal("marshal accepted boundary metadata beyond its allocation budget")
69+ }
70+
71+ data := make([]byte, 13+height*visibleBoundaryBytes)
72+ copy(data[:4], visibleMagic)
73+ data[4] = historyVersion
74+ binary.BigEndian.PutUint32(data[9:13], height)
75+ if _, err := PreflightVisibleBlob(data); err == nil {
76+ t.Fatal("preflight accepted boundary metadata beyond its allocation budget")
77+ }
78+ if _, err := UnmarshalVisible(data); err == nil {
79+ t.Fatal("unmarshal accepted boundary metadata beyond its allocation budget")
80+ }
81+
82+}
@@ -0,0 +1,82 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) {
11+ s := NewScreen(5, 3)
12+ s.Write([]byte("shell"))
13+ s.Write([]byte("\x1b[?1049h"))
14+ s.Write([]byte("\x1b["))
15+
16+ s.Resize(0, 0)
17+
18+ if len(s.escapeBuf) != 0 {
19+ t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf)
20+ }
21+ if s.Frame.Width != 0 || s.Frame.Height != 0 {
22+ t.Fatalf("active frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height)
23+ }
24+ s.Write([]byte("\x1b[?1049l"))
25+ if s.Frame.Width != 0 || s.Frame.Height != 0 {
26+ t.Fatalf("saved primary frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height)
27+ }
28+}
29+
30+func TestResizeReflowDropsAbandonedWideEdgePadding(t *testing.T) {
31+ s := NewScreen(4, 3)
32+ s.Write([]byte("ab界"))
33+ s.Write([]byte("\x1b[1;4H界x"))
34+
35+ s.Resize(6, 3)
36+
37+ assertCell(t, s, 0, 0, 'a')
38+ assertCell(t, s, 1, 0, 'b')
39+ assertCell(t, s, 2, 0, '界')
40+ if !cellAt(s, 3, 0).Continuation {
41+ t.Fatal("reflowed wide rune lost continuation")
42+ }
43+ assertCell(t, s, 4, 0, 'x')
44+}
45+
46+func TestInsertModeRetainsShiftedTailInReflowExtent(t *testing.T) {
47+ s := NewScreen(6, 2)
48+ s.Write([]byte("abcd"))
49+ s.Write([]byte("\x1b[1;2H\x1b[4hX"))
50+ // Simulate the subsequent logical continuation that causes the row to be
51+ // reflowed. Its meaningful extent must include the shifted tail.
52+ s.buffer.continueRow(0)
53+
54+ s.Resize(5, 2)
55+
56+ for x, r := range []rune("aXbcd") {
57+ assertCell(t, s, x, 0, r)
58+ }
59+}
60+
61+func TestVisibleBoundaryMetadataBudget(t *testing.T) {
62+ const height = maxHistoryRows + 1
63+ if _, err := MarshalVisible(renderer.NewFrame(0, height)); err == nil {
64+ t.Fatal("marshal accepted boundary metadata beyond its allocation budget")
65+ }
66+ s := NewScreen(0, height)
67+ if _, err := s.MarshalPrimaryVisible(); err == nil {
68+ t.Fatal("marshal accepted boundary metadata beyond its allocation budget")
69+ }
70+
71+ data := make([]byte, 13+height*visibleBoundaryBytes)
72+ copy(data[:4], visibleMagic)
73+ data[4] = historyVersion
74+ binary.BigEndian.PutUint32(data[9:13], height)
75+ if _, err := PreflightVisibleBlob(data); err == nil {
76+ t.Fatal("preflight accepted boundary metadata beyond its allocation budget")
77+ }
78+ if _, err := UnmarshalVisible(data); err == nil {
79+ t.Fatal("unmarshal accepted boundary metadata beyond its allocation budget")
80+ }
81+
82+}
M pkg/vt/screen_resize.go +36 -32
@@ -9,44 +9,48 @@ func (s *Screen) Resize(width, height int) {
99 if width <= 0 || height <= 0 {
1010 // renderer.Frame permits a collapsed geometry. There is no layout to
1111 // preserve until a usable viewport is supplied again.
12- s.buffer = newBuffer(max(width, 0), max(height, 0))
13- s.Frame = s.buffer.frame
12+ width, height = max(width, 0), max(height, 0)
13+ s.buffer = newBuffer(width, height)
1414 s.Row, s.Col = 0, 0
15- s.resetScrollRegion()
16- s.fullRedraw()
17- return
18- }
19- resize := func(b *buffer, row, col int, saved *cursorState, evict bool) (int, int) {
20- active := &bufferCursor{row: row, col: col}
21- var savedPoint *bufferCursor
22- if saved != nil && saved.saved {
23- savedPoint = &bufferCursor{row: saved.row, col: saved.col}
15+ if state := s.alternate; state != nil {
16+ state.buffer = newBuffer(width, height)
17+ state.frame = state.buffer.frame
18+ state.row, state.col = 0, 0
19+ state.scrollTop, state.scrollBottom = 0, height-1
2420 }
25- evicted := b.resize(width, height, active, savedPoint)
26- if evict {
27- for _, line := range evicted {
28- s.recordEvicted(line)
21+ } else {
22+ resize := func(b *buffer, row, col int, saved *cursorState, evict bool) (int, int) {
23+ active := &bufferCursor{row: row, col: col}
24+ var savedPoint *bufferCursor
25+ if saved != nil && saved.saved {
26+ savedPoint = &bufferCursor{row: saved.row, col: saved.col}
2927 }
28+ evicted := b.resize(width, height, active, savedPoint)
29+ if evict {
30+ for _, line := range evicted {
31+ s.recordEvicted(line)
32+ }
33+ }
34+ if savedPoint != nil {
35+ saved.row, saved.col = savedPoint.row, clamp(savedPoint.col, 0, width-1)
36+ }
37+ return active.row, clamp(active.col, 0, width-1)
3038 }
31- if savedPoint != nil {
32- saved.row, saved.col = savedPoint.row, clamp(savedPoint.col, 0, width-1)
33- }
34- return active.row, clamp(active.col, 0, width-1)
35- }
3639
37- if s.alternate != nil {
38- // Both screen states are reflowed independently, but active and saved
39- // cursors for each state are mapped by their single buffer layout pass.
40- s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false)
41- state := s.alternate
42- if state.buffer == nil {
43- state.buffer = bufferFromFrame(state.frame)
40+ if s.alternate != nil {
41+ // Both screen states are reflowed independently, but active and saved
42+ // cursors for each state are mapped by their single buffer layout pass.
43+ s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false)
44+ state := s.alternate
45+ if state.buffer == nil {
46+ state.buffer = bufferFromFrame(state.frame)
47+ }
48+ state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)
49+ state.frame = state.buffer.frame
50+ state.scrollTop, state.scrollBottom = 0, height-1
51+ } else {
52+ s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true)
4453 }
45- state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)
46- state.frame = state.buffer.frame
47- state.scrollTop, state.scrollBottom = 0, height-1
48- } else {
49- s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true)
5054 }
5155 s.Frame = s.buffer.frame
5256 // A resize can split an in-flight escape sequence from the terminal state it
@@ -9,44 +9,48 @@ func (s *Screen) Resize(width, height int) {
9 if width <= 0 || height <= 0 {9 if width <= 0 || height <= 0 {
10 // renderer.Frame permits a collapsed geometry. There is no layout to10 // renderer.Frame permits a collapsed geometry. There is no layout to
11 // preserve until a usable viewport is supplied again.11 // preserve until a usable viewport is supplied again.
12- s.buffer = newBuffer(max(width, 0), max(height, 0))12+ width, height = max(width, 0), max(height, 0)
13- s.Frame = s.buffer.frame13+ s.buffer = newBuffer(width, height)
14 s.Row, s.Col = 0, 014 s.Row, s.Col = 0, 0
15- s.resetScrollRegion()15+ if state := s.alternate; state != nil {
16- s.fullRedraw()16+ state.buffer = newBuffer(width, height)
17- return17+ state.frame = state.buffer.frame
18- }18+ state.row, state.col = 0, 0
19- resize := func(b *buffer, row, col int, saved *cursorState, evict bool) (int, int) {19+ state.scrollTop, state.scrollBottom = 0, height-1
20- active := &bufferCursor{row: row, col: col}
21- var savedPoint *bufferCursor
22- if saved != nil && saved.saved {
23- savedPoint = &bufferCursor{row: saved.row, col: saved.col}
24 }20 }
25- evicted := b.resize(width, height, active, savedPoint)21+ } else {
26- if evict {22+ resize := func(b *buffer, row, col int, saved *cursorState, evict bool) (int, int) {
27- for _, line := range evicted {23+ active := &bufferCursor{row: row, col: col}
28- s.recordEvicted(line)24+ var savedPoint *bufferCursor
25+ if saved != nil && saved.saved {
26+ savedPoint = &bufferCursor{row: saved.row, col: saved.col}
29 }27 }
28+ evicted := b.resize(width, height, active, savedPoint)
29+ if evict {
30+ for _, line := range evicted {
31+ s.recordEvicted(line)
32+ }
33+ }
34+ if savedPoint != nil {
35+ saved.row, saved.col = savedPoint.row, clamp(savedPoint.col, 0, width-1)
36+ }
37+ return active.row, clamp(active.col, 0, width-1)
30 }38 }
31- if savedPoint != nil {
32- saved.row, saved.col = savedPoint.row, clamp(savedPoint.col, 0, width-1)
33- }
34- return active.row, clamp(active.col, 0, width-1)
35- }
36 39
37- if s.alternate != nil {40+ if s.alternate != nil {
38- // Both screen states are reflowed independently, but active and saved41+ // Both screen states are reflowed independently, but active and saved
39- // cursors for each state are mapped by their single buffer layout pass.42+ // cursors for each state are mapped by their single buffer layout pass.
40- s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false)43+ s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false)
41- state := s.alternate44+ state := s.alternate
42- if state.buffer == nil {45+ if state.buffer == nil {
43- state.buffer = bufferFromFrame(state.frame)46+ state.buffer = bufferFromFrame(state.frame)
47+ }
48+ state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)
49+ state.frame = state.buffer.frame
50+ state.scrollTop, state.scrollBottom = 0, height-1
51+ } else {
52+ s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true)
44 }53 }
45- state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)
46- state.frame = state.buffer.frame
47- state.scrollTop, state.scrollBottom = 0, height-1
48- } else {
49- s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true)
50 }54 }
51 s.Frame = s.buffer.frame55 s.Frame = s.buffer.frame
52 // A resize can split an in-flight escape sequence from the terminal state it56 // A resize can split an in-flight escape sequence from the terminal state it
M pkg/vt/screen_write.go +2 -0
@@ -76,6 +76,7 @@ func (s *Screen) putPrintable(r rune) {
7676 }
7777 s.clearWidePairAt(s.Col, s.Row)
7878 s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
79+ s.buffer.truncate(s.Row, cx)
7980 s.buffer.continueRow(s.Row)
8081 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
8182 s.Col = 0
@@ -98,6 +99,7 @@ func (s *Screen) putPrintable(r rune) {
9899 row[x] = renderer.BlankCell()
99100 }
100101 s.repairRow(s.Row)
102+ s.buffer.insert(s.Row, s.Col, w)
101103 if leftSplit {
102104 insertDamageX = s.Col - 1
103105 }
@@ -76,6 +76,7 @@ func (s *Screen) putPrintable(r rune) {
76 }76 }
77 s.clearWidePairAt(s.Col, s.Row)77 s.clearWidePairAt(s.Col, s.Row)
78 s.Frame.Set(s.Col, s.Row, renderer.BlankCell())78 s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
79+ s.buffer.truncate(s.Row, cx)
79 s.buffer.continueRow(s.Row)80 s.buffer.continueRow(s.Row)
80 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})81 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
81 s.Col = 082 s.Col = 0
@@ -98,6 +99,7 @@ func (s *Screen) putPrintable(r rune) {
98 row[x] = renderer.BlankCell()99 row[x] = renderer.BlankCell()
99 }100 }
100 s.repairRow(s.Row)101 s.repairRow(s.Row)
102+ s.buffer.insert(s.Row, s.Col, w)
101 if leftSplit {103 if leftSplit {
102 insertDamageX = s.Col - 1104 insertDamageX = s.Col - 1
103 }105 }
M pkg/vt/snapshot.go +35 -19
@@ -8,7 +8,10 @@ import (
88 "github.com/bnema/vev/pkg/renderer"
99 )
1010
11-const visibleMagic = "VTV2"
11+const (
12+ visibleMagic = "VTV2"
13+ visibleBoundaryBytes = 5
14+)
1215
1316 // DecodeStats describes resources declared by one canonical VT blob.
1417 type DecodeStats struct {
@@ -112,6 +115,9 @@ func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, seale
112115 // MarshalVisible encodes the exact visible primary frame, including every
113116 // blank, row width, and terminal cell attribute.
114117 func MarshalVisible(frame renderer.Frame) ([]byte, error) {
118+ if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok {
119+ return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
120+ }
115121 boundaries := make([]lineBoundary, frame.Height)
116122 for y := range boundaries {
117123 for x := frame.Width - 1; x >= 0; x-- {
@@ -138,19 +144,11 @@ func (s *Screen) MarshalPrimaryVisible() ([]byte, error) {
138144 }
139145
140146 func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) {
141- if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 {
142- return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
143- }
144- cells := uint64(frame.Width) * uint64(frame.Height)
145- bytes, ok := historyCellByteCount(cells)
146- if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes {
147+ _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height)
148+ if !ok || len(boundaries) != frame.Height {
147149 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
148150 }
149- if len(boundaries) != frame.Height {
150- return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
151- }
152- boundaryBytes := frame.Height * 5
153- out := make([]byte, 0, 13+bytes+uint64(boundaryBytes))
151+ out := make([]byte, 0, int(13+bytes+boundaryBytes))
154152 out = append(out, visibleMagic...)
155153 out = append(out, historyVersion)
156154 out = binary.BigEndian.AppendUint32(out, uint32(frame.Width))
@@ -181,13 +179,8 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
181179 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
182180 }
183181 width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13]))
184- if width != 0 && height > math.MaxUint64/width {
185- return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
186- }
187- cells := width * height
188- bytes, ok := historyCellByteCount(cells)
189- expected := bytes + uint64(height)*5
190- if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != expected {
182+ cells, bytes, boundaryBytes, ok := visibleEncodingBudget64(width, height)
183+ if !ok || bytes > math.MaxUint64-boundaryBytes || uint64(len(data)-13) != bytes+boundaryBytes {
191184 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
192185 }
193186 p := historyParser{data: data[13:]}
@@ -206,6 +199,29 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
206199 return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil
207200 }
208201
202+func visibleEncodingBudget(width, height int) (cells, cellBytes, boundaryBytes uint64, ok bool) {
203+ if width < 0 || height < 0 {
204+ return 0, 0, 0, false
205+ }
206+ return visibleEncodingBudget64(uint64(width), uint64(height))
207+}
208+
209+func visibleEncodingBudget64(width, height uint64) (cells, cellBytes, boundaryBytes uint64, ok bool) {
210+ if width > math.MaxUint32 || height > math.MaxUint32 || (width != 0 && height > math.MaxUint64/width) || height > math.MaxUint64/visibleBoundaryBytes {
211+ return 0, 0, 0, false
212+ }
213+ cells = width * height
214+ cellBytes, ok = historyCellByteCount(cells)
215+ if !ok || cells > maxHistoryCells || cellBytes > maxHistoryDecodedBytes {
216+ return 0, 0, 0, false
217+ }
218+ boundaryBytes = height * visibleBoundaryBytes
219+ if boundaryBytes > maxVisibleBoundaryBytes {
220+ return 0, 0, 0, false
221+ }
222+ return cells, cellBytes, boundaryBytes, true
223+}
224+
209225 // UnmarshalVisible decodes a preflighted exact visible frame.
210226 func UnmarshalVisible(data []byte) (renderer.Frame, error) {
211227 frame, _, err := unmarshalVisible(data)
@@ -8,7 +8,10 @@ import (
8 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
9 )9 )
10 10
11-const visibleMagic = "VTV2"11+const (
12+ visibleMagic = "VTV2"
13+ visibleBoundaryBytes = 5
14+)
12 15
13 // DecodeStats describes resources declared by one canonical VT blob.16 // DecodeStats describes resources declared by one canonical VT blob.
14 type DecodeStats struct {17 type DecodeStats struct {
@@ -112,6 +115,9 @@ func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, seale
112 // MarshalVisible encodes the exact visible primary frame, including every115 // MarshalVisible encodes the exact visible primary frame, including every
113 // blank, row width, and terminal cell attribute.116 // blank, row width, and terminal cell attribute.
114 func MarshalVisible(frame renderer.Frame) ([]byte, error) {117 func MarshalVisible(frame renderer.Frame) ([]byte, error) {
118+ if _, _, _, ok := visibleEncodingBudget(frame.Width, frame.Height); !ok {
119+ return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
120+ }
115 boundaries := make([]lineBoundary, frame.Height)121 boundaries := make([]lineBoundary, frame.Height)
116 for y := range boundaries {122 for y := range boundaries {
117 for x := frame.Width - 1; x >= 0; x-- {123 for x := frame.Width - 1; x >= 0; x-- {
@@ -138,19 +144,11 @@ func (s *Screen) MarshalPrimaryVisible() ([]byte, error) {
138 }144 }
139 145
140 func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) {146 func marshalVisible(frame renderer.Frame, boundaries []lineBoundary) ([]byte, error) {
141- if frame.Width < 0 || frame.Height < 0 || uint64(frame.Width) > math.MaxUint32 || uint64(frame.Height) > math.MaxUint32 {147+ _, bytes, boundaryBytes, ok := visibleEncodingBudget(frame.Width, frame.Height)
142- return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)148+ if !ok || len(boundaries) != frame.Height {
143- }
144- cells := uint64(frame.Width) * uint64(frame.Height)
145- bytes, ok := historyCellByteCount(cells)
146- if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes {
147 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)149 return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
148 }150 }
149- if len(boundaries) != frame.Height {151+ out := make([]byte, 0, int(13+bytes+boundaryBytes))
150- return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory)
151- }
152- boundaryBytes := frame.Height * 5
153- out := make([]byte, 0, 13+bytes+uint64(boundaryBytes))
154 out = append(out, visibleMagic...)152 out = append(out, visibleMagic...)
155 out = append(out, historyVersion)153 out = append(out, historyVersion)
156 out = binary.BigEndian.AppendUint32(out, uint32(frame.Width))154 out = binary.BigEndian.AppendUint32(out, uint32(frame.Width))
@@ -181,13 +179,8 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
181 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)179 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
182 }180 }
183 width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13]))181 width, height := uint64(binary.BigEndian.Uint32(data[5:9])), uint64(binary.BigEndian.Uint32(data[9:13]))
184- if width != 0 && height > math.MaxUint64/width {182+ cells, bytes, boundaryBytes, ok := visibleEncodingBudget64(width, height)
185- return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)183+ if !ok || bytes > math.MaxUint64-boundaryBytes || uint64(len(data)-13) != bytes+boundaryBytes {
186- }
187- cells := width * height
188- bytes, ok := historyCellByteCount(cells)
189- expected := bytes + uint64(height)*5
190- if !ok || cells > maxHistoryCells || bytes > maxHistoryDecodedBytes || uint64(len(data)-13) != expected {
191 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)184 return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory)
192 }185 }
193 p := historyParser{data: data[13:]}186 p := historyParser{data: data[13:]}
@@ -206,6 +199,29 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) {
206 return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil199 return DecodeStats{Rows: height, Cells: cells, Styles: cells, Bytes: bytes}, nil
207 }200 }
208 201
202+func visibleEncodingBudget(width, height int) (cells, cellBytes, boundaryBytes uint64, ok bool) {
203+ if width < 0 || height < 0 {
204+ return 0, 0, 0, false
205+ }
206+ return visibleEncodingBudget64(uint64(width), uint64(height))
207+}
208+
209+func visibleEncodingBudget64(width, height uint64) (cells, cellBytes, boundaryBytes uint64, ok bool) {
210+ if width > math.MaxUint32 || height > math.MaxUint32 || (width != 0 && height > math.MaxUint64/width) || height > math.MaxUint64/visibleBoundaryBytes {
211+ return 0, 0, 0, false
212+ }
213+ cells = width * height
214+ cellBytes, ok = historyCellByteCount(cells)
215+ if !ok || cells > maxHistoryCells || cellBytes > maxHistoryDecodedBytes {
216+ return 0, 0, 0, false
217+ }
218+ boundaryBytes = height * visibleBoundaryBytes
219+ if boundaryBytes > maxVisibleBoundaryBytes {
220+ return 0, 0, 0, false
221+ }
222+ return cells, cellBytes, boundaryBytes, true
223+}
224+
209 // UnmarshalVisible decodes a preflighted exact visible frame.225 // UnmarshalVisible decodes a preflighted exact visible frame.
210 func UnmarshalVisible(data []byte) (renderer.Frame, error) {226 func UnmarshalVisible(data []byte) (renderer.Frame, error) {
211 frame, _, err := unmarshalVisible(data)227 frame, _, err := unmarshalVisible(data)

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