feat(vt): persist per-row logical extents, bump history format to v2
- commit
- ff84b777fbf5f6f0546ba460d494e6a735f724e9
- author
- brice · · 2026-07-25 06:06 UTC
- parent
- 74eecd7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +138 -4
M pkg/vt/history_codec.go +49 -4
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | const ( | |
| 14 | 14 | historyMagic = "VTH1" | |
| 15 | - | historyVersion = 1 | |
| 15 | + | historyVersion = 2 | |
| 16 | 16 | historyCellBytes = 41 | |
| 17 | 17 | maxHistoryChunkRows = 256 | |
| 18 | 18 | ||
| @@ -64,10 +64,14 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||
| 64 | 64 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 65 | 65 | } | |
| 66 | 66 | out = appendUint32(out, uint32(len(chunk.rows))) | |
| 67 | - | for _, row := range chunk.rows { | |
| 67 | + | for i, row := range chunk.rows { | |
| 68 | 68 | if uint64(len(row)) > math.MaxUint32 { | |
| 69 | 69 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 70 | 70 | } | |
| 71 | + | bound := chunkBound(chunk, i) | |
| 72 | + | if !validHistoryBound(bound, len(row)) { | |
| 73 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 74 | + | } | |
| 71 | 75 | out = appendUint32(out, uint32(len(row))) | |
| 72 | 76 | for _, cell := range row { | |
| 73 | 77 | if !validHistoryCell(cell) { | |
| @@ -75,6 +79,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||
| 75 | 79 | } | |
| 76 | 80 | out = appendHistoryCell(out, cell) | |
| 77 | 81 | } | |
| 82 | + | out = appendHistoryBound(out, bound) | |
| 78 | 83 | } | |
| 79 | 84 | } | |
| 80 | 85 | return out, nil | |
| @@ -135,6 +140,26 @@ func appendHistoryCell(dst []byte, cell renderer.Cell) []byte { | |||
| 135 | 140 | return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B) | |
| 136 | 141 | } | |
| 137 | 142 | ||
| 143 | + | func chunkBound(chunk *HistoryChunk, i int) LineBound { | |
| 144 | + | if i < 0 || i >= len(chunk.bounds) { | |
| 145 | + | return LineBound{} | |
| 146 | + | } | |
| 147 | + | return chunk.bounds[i] | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func appendHistoryBound(dst []byte, b LineBound) []byte { | |
| 151 | + | dst = binary.BigEndian.AppendUint32(dst, uint32(b.End)) | |
| 152 | + | if b.Soft { | |
| 153 | + | return append(dst, 1) | |
| 154 | + | } | |
| 155 | + | return append(dst, 0) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // validHistoryBound rejects extents that cannot describe their own row. | |
| 159 | + | func validHistoryBound(b LineBound, cells int) bool { | |
| 160 | + | return b.End >= 0 && b.End <= cells | |
| 161 | + | } | |
| 162 | + | ||
| 138 | 163 | // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects | |
| 139 | 164 | // malformed declarations, cells, truncated data, and trailing bytes. | |
| 140 | 165 | func UnmarshalHistory(data []byte) (HistoryView, error) { | |
| @@ -181,14 +206,16 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 181 | 206 | for range chunkCount { | |
| 182 | 207 | rowCount, ok := p.uint32() | |
| 183 | 208 | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | |
| 184 | - | uint64(rowCount) > uint64(len(p.data))/4 || | |
| 209 | + | uint64(rowCount) > uint64(len(p.data))/(4+visibleBoundaryBytes) || | |
| 185 | 210 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | |
| 186 | 211 | return HistoryView{}, historyDecodeStats{}, false | |
| 187 | 212 | } | |
| 188 | 213 | ||
| 189 | 214 | var rows [][]renderer.Cell | |
| 215 | + | var bounds []LineBound | |
| 190 | 216 | if populate { | |
| 191 | 217 | rows = make([][]renderer.Cell, 0, rowCount) | |
| 218 | + | bounds = make([]LineBound, 0, rowCount) | |
| 192 | 219 | } | |
| 193 | 220 | for range rowCount { | |
| 194 | 221 | cellCount, ok := p.uint32() | |
| @@ -214,12 +241,17 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 214 | 241 | row[i] = cell | |
| 215 | 242 | } | |
| 216 | 243 | } | |
| 244 | + | bound, ok := p.bound() | |
| 245 | + | if !ok || !validHistoryBound(bound, int(cellCount)) { | |
| 246 | + | return HistoryView{}, historyDecodeStats{}, false | |
| 247 | + | } | |
| 217 | 248 | if populate { | |
| 218 | 249 | rows = append(rows, row) | |
| 250 | + | bounds = append(bounds, bound) | |
| 219 | 251 | } | |
| 220 | 252 | } | |
| 221 | 253 | if populate { | |
| 222 | - | chunks = append(chunks, &HistoryChunk{rows: rows}) | |
| 254 | + | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds}) | |
| 223 | 255 | } | |
| 224 | 256 | } | |
| 225 | 257 | if len(p.data) != 0 { | |
| @@ -296,6 +328,19 @@ func (p *historyParser) cell() (renderer.Cell, bool) { | |||
| 296 | 328 | }, true | |
| 297 | 329 | } | |
| 298 | 330 | ||
| 331 | + | func (p *historyParser) bound() (LineBound, bool) { | |
| 332 | + | if len(p.data) < visibleBoundaryBytes { | |
| 333 | + | return LineBound{}, false | |
| 334 | + | } | |
| 335 | + | end := binary.BigEndian.Uint32(p.data[:4]) | |
| 336 | + | flag := p.data[4] | |
| 337 | + | p.data = p.data[visibleBoundaryBytes:] | |
| 338 | + | if flag > 1 || uint64(end) > math.MaxInt32 { | |
| 339 | + | return LineBound{}, false | |
| 340 | + | } | |
| 341 | + | return LineBound{End: int(end), Soft: flag == 1}, true | |
| 342 | + | } | |
| 343 | + | ||
| 299 | 344 | func historyInt(raw uint64) (int, bool) { | |
| 300 | 345 | v := int64(raw) | |
| 301 | 346 | if int64(int(v)) != v { | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | const ( | 13 | const ( | ||
| 14 | historyMagic = "VTH1" | 14 | historyMagic = "VTH1" | ||
| 15 | - | historyVersion = 1 | 15 | + | historyVersion = 2 |
| 16 | historyCellBytes = 41 | 16 | historyCellBytes = 41 | ||
| 17 | maxHistoryChunkRows = 256 | 17 | maxHistoryChunkRows = 256 | ||
| 18 | 18 | ||||
| @@ -64,10 +64,14 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||||
| 64 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 64 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | ||
| 65 | } | 65 | } | ||
| 66 | out = appendUint32(out, uint32(len(chunk.rows))) | 66 | out = appendUint32(out, uint32(len(chunk.rows))) | ||
| 67 | - | for _, row := range chunk.rows { | 67 | + | for i, row := range chunk.rows { |
| 68 | if uint64(len(row)) > math.MaxUint32 { | 68 | if uint64(len(row)) > math.MaxUint32 { | ||
| 69 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 69 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | ||
| 70 | } | 70 | } | ||
| 71 | + | bound := chunkBound(chunk, i) | |||
| 72 | + | if !validHistoryBound(bound, len(row)) { | |||
| 73 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 74 | + | } | |||
| 71 | out = appendUint32(out, uint32(len(row))) | 75 | out = appendUint32(out, uint32(len(row))) | ||
| 72 | for _, cell := range row { | 76 | for _, cell := range row { | ||
| 73 | if !validHistoryCell(cell) { | 77 | if !validHistoryCell(cell) { | ||
| @@ -75,6 +79,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||||
| 75 | } | 79 | } | ||
| 76 | out = appendHistoryCell(out, cell) | 80 | out = appendHistoryCell(out, cell) | ||
| 77 | } | 81 | } | ||
| 82 | + | out = appendHistoryBound(out, bound) | |||
| 78 | } | 83 | } | ||
| 79 | } | 84 | } | ||
| 80 | return out, nil | 85 | return out, nil | ||
| @@ -135,6 +140,26 @@ func appendHistoryCell(dst []byte, cell renderer.Cell) []byte { | |||||
| 135 | return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B) | 140 | return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B) | ||
| 136 | } | 141 | } | ||
| 137 | 142 | ||||
| 143 | + | func chunkBound(chunk *HistoryChunk, i int) LineBound { | |||
| 144 | + | if i < 0 || i >= len(chunk.bounds) { | |||
| 145 | + | return LineBound{} | |||
| 146 | + | } | |||
| 147 | + | return chunk.bounds[i] | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func appendHistoryBound(dst []byte, b LineBound) []byte { | |||
| 151 | + | dst = binary.BigEndian.AppendUint32(dst, uint32(b.End)) | |||
| 152 | + | if b.Soft { | |||
| 153 | + | return append(dst, 1) | |||
| 154 | + | } | |||
| 155 | + | return append(dst, 0) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // validHistoryBound rejects extents that cannot describe their own row. | |||
| 159 | + | func validHistoryBound(b LineBound, cells int) bool { | |||
| 160 | + | return b.End >= 0 && b.End <= cells | |||
| 161 | + | } | |||
| 162 | + | ||||
| 138 | // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects | 163 | // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects | ||
| 139 | // malformed declarations, cells, truncated data, and trailing bytes. | 164 | // malformed declarations, cells, truncated data, and trailing bytes. | ||
| 140 | func UnmarshalHistory(data []byte) (HistoryView, error) { | 165 | func UnmarshalHistory(data []byte) (HistoryView, error) { | ||
| @@ -181,14 +206,16 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 181 | for range chunkCount { | 206 | for range chunkCount { | ||
| 182 | rowCount, ok := p.uint32() | 207 | rowCount, ok := p.uint32() | ||
| 183 | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | 208 | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | ||
| 184 | - | uint64(rowCount) > uint64(len(p.data))/4 || | 209 | + | uint64(rowCount) > uint64(len(p.data))/(4+visibleBoundaryBytes) || |
| 185 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | 210 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | ||
| 186 | return HistoryView{}, historyDecodeStats{}, false | 211 | return HistoryView{}, historyDecodeStats{}, false | ||
| 187 | } | 212 | } | ||
| 188 | 213 | ||||
| 189 | var rows [][]renderer.Cell | 214 | var rows [][]renderer.Cell | ||
| 215 | + | var bounds []LineBound | |||
| 190 | if populate { | 216 | if populate { | ||
| 191 | rows = make([][]renderer.Cell, 0, rowCount) | 217 | rows = make([][]renderer.Cell, 0, rowCount) | ||
| 218 | + | bounds = make([]LineBound, 0, rowCount) | |||
| 192 | } | 219 | } | ||
| 193 | for range rowCount { | 220 | for range rowCount { | ||
| 194 | cellCount, ok := p.uint32() | 221 | cellCount, ok := p.uint32() | ||
| @@ -214,12 +241,17 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 214 | row[i] = cell | 241 | row[i] = cell | ||
| 215 | } | 242 | } | ||
| 216 | } | 243 | } | ||
| 244 | + | bound, ok := p.bound() | |||
| 245 | + | if !ok || !validHistoryBound(bound, int(cellCount)) { | |||
| 246 | + | return HistoryView{}, historyDecodeStats{}, false | |||
| 247 | + | } | |||
| 217 | if populate { | 248 | if populate { | ||
| 218 | rows = append(rows, row) | 249 | rows = append(rows, row) | ||
| 250 | + | bounds = append(bounds, bound) | |||
| 219 | } | 251 | } | ||
| 220 | } | 252 | } | ||
| 221 | if populate { | 253 | if populate { | ||
| 222 | - | chunks = append(chunks, &HistoryChunk{rows: rows}) | 254 | + | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds}) |
| 223 | } | 255 | } | ||
| 224 | } | 256 | } | ||
| 225 | if len(p.data) != 0 { | 257 | if len(p.data) != 0 { | ||
| @@ -296,6 +328,19 @@ func (p *historyParser) cell() (renderer.Cell, bool) { | |||||
| 296 | }, true | 328 | }, true | ||
| 297 | } | 329 | } | ||
| 298 | 330 | ||||
| 331 | + | func (p *historyParser) bound() (LineBound, bool) { | |||
| 332 | + | if len(p.data) < visibleBoundaryBytes { | |||
| 333 | + | return LineBound{}, false | |||
| 334 | + | } | |||
| 335 | + | end := binary.BigEndian.Uint32(p.data[:4]) | |||
| 336 | + | flag := p.data[4] | |||
| 337 | + | p.data = p.data[visibleBoundaryBytes:] | |||
| 338 | + | if flag > 1 || uint64(end) > math.MaxInt32 { | |||
| 339 | + | return LineBound{}, false | |||
| 340 | + | } | |||
| 341 | + | return LineBound{End: int(end), Soft: flag == 1}, true | |||
| 342 | + | } | |||
| 343 | + | ||||
| 299 | func historyInt(raw uint64) (int, bool) { | 344 | func historyInt(raw uint64) (int, bool) { | ||
| 300 | v := int64(raw) | 345 | v := int64(raw) | ||
| 301 | if int64(int(v)) != v { | 346 | if int64(int(v)) != v { | ||
M pkg/vt/history_codec_test.go +89 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "encoding/binary" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "math" | |
| @@ -9,6 +10,94 @@ import ( | |||
| 9 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 13 | + | func TestHistoryCodecV2Bytes(t *testing.T) { | |
| 14 | + | h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2}) | |
| 15 | + | if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil { | |
| 16 | + | t.Fatalf("append: %v", err) | |
| 17 | + | } | |
| 18 | + | got, err := MarshalHistory(h.SealAndView()) | |
| 19 | + | if err != nil { | |
| 20 | + | t.Fatalf("marshal: %v", err) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | want := []byte{ | |
| 24 | + | 'V', 'T', 'H', '1', 2, // magic, version | |
| 25 | + | 0, 0, 0, 1, // chunk count | |
| 26 | + | 0, 0, 0, 1, // row count | |
| 27 | + | 0, 0, 0, 1, // cell count | |
| 28 | + | 0, 0, 0, 'a', // Rune | |
| 29 | + | 0, // flags | |
| 30 | + | 0, 0, // Attrs | |
| 31 | + | 0, 0, 0, 0, 0, 0, 0, 0, // Foreground | |
| 32 | + | 0, 0, 0, 0, 0, 0, 0, 0, // Background | |
| 33 | + | 0, 0, 0, // ForegroundRGB | |
| 34 | + | 0, 0, 0, // BackgroundRGB | |
| 35 | + | 0, // UnderlineStyle | |
| 36 | + | 0, 0, 0, 0, 0, 0, 0, 0, // UnderlineColor | |
| 37 | + | 0, 0, 0, // UnderlineColorRGB — 41 cell bytes through here | |
| 38 | + | 0, 0, 0, 1, 1, // bound: End=1, Soft=true | |
| 39 | + | } | |
| 40 | + | if !bytes.Equal(got, want) { | |
| 41 | + | t.Fatalf("MarshalHistory() = % x, want % x", got, want) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestHistoryCodecRoundTripsBounds(t *testing.T) { | |
| 46 | + | h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2}) | |
| 47 | + | bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}} | |
| 48 | + | for _, b := range bounds { | |
| 49 | + | if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil { | |
| 50 | + | t.Fatalf("append: %v", err) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | blob, err := MarshalHistory(h.SealAndView()) | |
| 54 | + | if err != nil { | |
| 55 | + | t.Fatalf("marshal: %v", err) | |
| 56 | + | } | |
| 57 | + | view, err := UnmarshalHistory(blob) | |
| 58 | + | if err != nil { | |
| 59 | + | t.Fatalf("unmarshal: %v", err) | |
| 60 | + | } | |
| 61 | + | for i, want := range bounds { | |
| 62 | + | if got := view.Bound(i); got != want { | |
| 63 | + | t.Errorf("Bound(%d) = %+v, want %+v", i, got, want) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) { | |
| 69 | + | // Canonical v2 fixture from TestHistoryCodecV2Bytes. | |
| 70 | + | v2 := []byte{ | |
| 71 | + | 'V', 'T', 'H', '1', 2, | |
| 72 | + | 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, | |
| 73 | + | 0, 0, 0, 'a', | |
| 74 | + | 0, 0, 0, | |
| 75 | + | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | |
| 76 | + | 0, 0, 0, 0, 0, 0, | |
| 77 | + | 0, | |
| 78 | + | 0, 0, 0, 0, 0, 0, 0, 0, | |
| 79 | + | 0, 0, 0, | |
| 80 | + | 0, 0, 0, 1, 1, | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // This is a genuine v1-layout payload: version 1 and no trailing bound | |
| 84 | + | // bytes. It is not a v2 payload with only its version byte changed. | |
| 85 | + | v1 := append([]byte(nil), v2[:len(v2)-visibleBoundaryBytes]...) | |
| 86 | + | v1[4] = 1 | |
| 87 | + | if _, err := UnmarshalHistory(v1); err == nil { | |
| 88 | + | t.Fatal("accepted a genuine v1 history payload") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | for i := 0; i < len(v2); i++ { | |
| 92 | + | if _, err := UnmarshalHistory(v2[:i]); err == nil { | |
| 93 | + | t.Fatalf("accepted truncated prefix of length %d", i) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | if _, err := UnmarshalHistory(append(append([]byte(nil), v2...), 0xff)); err == nil { | |
| 97 | + | t.Fatal("accepted trailing garbage") | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 12 | 101 | func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | |
| 13 | 102 | indexed := renderer.Style{ | |
| 14 | 103 | Bold: true, | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "encoding/binary" | 5 | "encoding/binary" | ||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "math" | 7 | "math" | ||
| @@ -9,6 +10,94 @@ import ( | |||||
| 9 | "github.com/bnema/vev/pkg/renderer" | 10 | "github.com/bnema/vev/pkg/renderer" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 13 | + | func TestHistoryCodecV2Bytes(t *testing.T) { | |||
| 14 | + | h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2}) | |||
| 15 | + | if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil { | |||
| 16 | + | t.Fatalf("append: %v", err) | |||
| 17 | + | } | |||
| 18 | + | got, err := MarshalHistory(h.SealAndView()) | |||
| 19 | + | if err != nil { | |||
| 20 | + | t.Fatalf("marshal: %v", err) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | want := []byte{ | |||
| 24 | + | 'V', 'T', 'H', '1', 2, // magic, version | |||
| 25 | + | 0, 0, 0, 1, // chunk count | |||
| 26 | + | 0, 0, 0, 1, // row count | |||
| 27 | + | 0, 0, 0, 1, // cell count | |||
| 28 | + | 0, 0, 0, 'a', // Rune | |||
| 29 | + | 0, // flags | |||
| 30 | + | 0, 0, // Attrs | |||
| 31 | + | 0, 0, 0, 0, 0, 0, 0, 0, // Foreground | |||
| 32 | + | 0, 0, 0, 0, 0, 0, 0, 0, // Background | |||
| 33 | + | 0, 0, 0, // ForegroundRGB | |||
| 34 | + | 0, 0, 0, // BackgroundRGB | |||
| 35 | + | 0, // UnderlineStyle | |||
| 36 | + | 0, 0, 0, 0, 0, 0, 0, 0, // UnderlineColor | |||
| 37 | + | 0, 0, 0, // UnderlineColorRGB — 41 cell bytes through here | |||
| 38 | + | 0, 0, 0, 1, 1, // bound: End=1, Soft=true | |||
| 39 | + | } | |||
| 40 | + | if !bytes.Equal(got, want) { | |||
| 41 | + | t.Fatalf("MarshalHistory() = % x, want % x", got, want) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestHistoryCodecRoundTripsBounds(t *testing.T) { | |||
| 46 | + | h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2}) | |||
| 47 | + | bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}} | |||
| 48 | + | for _, b := range bounds { | |||
| 49 | + | if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil { | |||
| 50 | + | t.Fatalf("append: %v", err) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | blob, err := MarshalHistory(h.SealAndView()) | |||
| 54 | + | if err != nil { | |||
| 55 | + | t.Fatalf("marshal: %v", err) | |||
| 56 | + | } | |||
| 57 | + | view, err := UnmarshalHistory(blob) | |||
| 58 | + | if err != nil { | |||
| 59 | + | t.Fatalf("unmarshal: %v", err) | |||
| 60 | + | } | |||
| 61 | + | for i, want := range bounds { | |||
| 62 | + | if got := view.Bound(i); got != want { | |||
| 63 | + | t.Errorf("Bound(%d) = %+v, want %+v", i, got, want) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) { | |||
| 69 | + | // Canonical v2 fixture from TestHistoryCodecV2Bytes. | |||
| 70 | + | v2 := []byte{ | |||
| 71 | + | 'V', 'T', 'H', '1', 2, | |||
| 72 | + | 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, | |||
| 73 | + | 0, 0, 0, 'a', | |||
| 74 | + | 0, 0, 0, | |||
| 75 | + | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | |||
| 76 | + | 0, 0, 0, 0, 0, 0, | |||
| 77 | + | 0, | |||
| 78 | + | 0, 0, 0, 0, 0, 0, 0, 0, | |||
| 79 | + | 0, 0, 0, | |||
| 80 | + | 0, 0, 0, 1, 1, | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // This is a genuine v1-layout payload: version 1 and no trailing bound | |||
| 84 | + | // bytes. It is not a v2 payload with only its version byte changed. | |||
| 85 | + | v1 := append([]byte(nil), v2[:len(v2)-visibleBoundaryBytes]...) | |||
| 86 | + | v1[4] = 1 | |||
| 87 | + | if _, err := UnmarshalHistory(v1); err == nil { | |||
| 88 | + | t.Fatal("accepted a genuine v1 history payload") | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | for i := 0; i < len(v2); i++ { | |||
| 92 | + | if _, err := UnmarshalHistory(v2[:i]); err == nil { | |||
| 93 | + | t.Fatalf("accepted truncated prefix of length %d", i) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | if _, err := UnmarshalHistory(append(append([]byte(nil), v2...), 0xff)); err == nil { | |||
| 97 | + | t.Fatal("accepted trailing garbage") | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 12 | func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | 101 | func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | ||
| 13 | indexed := renderer.Style{ | 102 | indexed := renderer.Style{ | ||
| 14 | Bold: true, | 103 | Bold: true, | ||