fix(vt): harden resize reflow metadata
- commit
- 06dc0bba52805e02b4976f521e8e3f84e012d7bc
- author
- brice · · 2026-07-15 19:28 UTC
- parent
- b93fa1a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 37 | 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 | 55 | func (b *buffer) hard(y int) { | |
| 41 | 56 | if y >= 0 && y < len(b.boundaries) { | |
| 42 | 57 | 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 = false | 57 | b.boundaries[y].soft = false | ||
M pkg/vt/history_codec.go +3 -2
| @@ -24,8 +24,9 @@ const ( | |||
| 24 | 24 | maxHistoryRowCells = 160 | |
| 25 | 25 | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | |
| 26 | 26 | ||
| 27 | - | maxHistoryDecodeStyles = maxHistoryCells | |
| 28 | - | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | |
| 27 | + | maxHistoryDecodeStyles = maxHistoryCells | |
| 28 | + | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | |
| 29 | + | maxVisibleBoundaryBytes = maxHistoryRows * visibleBoundaryBytes | |
| 29 | 30 | ) | |
| 30 | 31 | ||
| 31 | 32 | var errInvalidHistory = errors.New("invalid history payload") | |
| @@ -24,8 +24,9 @@ const ( | |||||
| 24 | maxHistoryRowCells = 160 | 24 | maxHistoryRowCells = 160 | ||
| 25 | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | 25 | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | ||
| 26 | 26 | ||||
| 27 | - | maxHistoryDecodeStyles = maxHistoryCells | 27 | + | maxHistoryDecodeStyles = maxHistoryCells |
| 28 | - | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | 28 | + | 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) { | |||
| 9 | 9 | if width <= 0 || height <= 0 { | |
| 10 | 10 | // renderer.Frame permits a collapsed geometry. There is no layout to | |
| 11 | 11 | // 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) | |
| 14 | 14 | 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 | |
| 24 | 20 | } | |
| 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} | |
| 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 { | |
| 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) | |
| 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 | 55 | s.Frame = s.buffer.frame | |
| 52 | 56 | // 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 to | 10 | // 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.frame | 13 | + | s.buffer = newBuffer(width, height) |
| 14 | s.Row, s.Col = 0, 0 | 14 | 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 | - | return | 17 | + | 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 saved | 41 | + | // 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.alternate | 44 | + | 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.frame | 55 | s.Frame = s.buffer.frame | ||
| 52 | // A resize can split an in-flight escape sequence from the terminal state it | 56 | // 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) { | |||
| 76 | 76 | } | |
| 77 | 77 | s.clearWidePairAt(s.Col, s.Row) | |
| 78 | 78 | s.Frame.Set(s.Col, s.Row, renderer.BlankCell()) | |
| 79 | + | s.buffer.truncate(s.Row, cx) | |
| 79 | 80 | s.buffer.continueRow(s.Row) | |
| 80 | 81 | s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1}) | |
| 81 | 82 | s.Col = 0 | |
| @@ -98,6 +99,7 @@ func (s *Screen) putPrintable(r rune) { | |||
| 98 | 99 | row[x] = renderer.BlankCell() | |
| 99 | 100 | } | |
| 100 | 101 | s.repairRow(s.Row) | |
| 102 | + | s.buffer.insert(s.Row, s.Col, w) | |
| 101 | 103 | if leftSplit { | |
| 102 | 104 | insertDamageX = s.Col - 1 | |
| 103 | 105 | } | |
| @@ -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 = 0 | 82 | 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 - 1 | 104 | insertDamageX = s.Col - 1 | ||
| 103 | } | 105 | } | ||
M pkg/vt/snapshot.go +35 -19
| @@ -8,7 +8,10 @@ import ( | |||
| 8 | 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 | 16 | // DecodeStats describes resources declared by one canonical VT blob. | |
| 14 | 17 | type DecodeStats struct { | |
| @@ -112,6 +115,9 @@ func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, seale | |||
| 112 | 115 | // MarshalVisible encodes the exact visible primary frame, including every | |
| 113 | 116 | // blank, row width, and terminal cell attribute. | |
| 114 | 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 | 121 | boundaries := make([]lineBoundary, frame.Height) | |
| 116 | 122 | for y := range boundaries { | |
| 117 | 123 | for x := frame.Width - 1; x >= 0; x-- { | |
| @@ -138,19 +144,11 @@ func (s *Screen) MarshalPrimaryVisible() ([]byte, error) { | |||
| 138 | 144 | } | |
| 139 | 145 | ||
| 140 | 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 { | |
| 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 { | |
| 147 | 149 | return nil, fmt.Errorf("marshal visible: %w", errInvalidHistory) | |
| 148 | 150 | } | |
| 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)) | |
| 154 | 152 | out = append(out, visibleMagic...) | |
| 155 | 153 | out = append(out, historyVersion) | |
| 156 | 154 | out = binary.BigEndian.AppendUint32(out, uint32(frame.Width)) | |
| @@ -181,13 +179,8 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||
| 181 | 179 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 182 | 180 | } | |
| 183 | 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 { | |
| 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 { | |
| 191 | 184 | return DecodeStats{}, fmt.Errorf("preflight visible: %w", errInvalidHistory) | |
| 192 | 185 | } | |
| 193 | 186 | p := historyParser{data: data[13:]} | |
| @@ -206,6 +199,29 @@ func PreflightVisibleBlob(data []byte) (DecodeStats, error) { | |||
| 206 | 199 | 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 | 225 | // UnmarshalVisible decodes a preflighted exact visible frame. | |
| 210 | 226 | func UnmarshalVisible(data []byte) (renderer.Frame, error) { | |
| 211 | 227 | 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 every | 115 | // 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}, nil | 199 | 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) | ||