1package vt
2
3import (
4 "encoding/binary"
5 "errors"
6 "fmt"
7 "math"
8 "unicode/utf8"
9
10 "github.com/bnema/vev/pkg/renderer"
11)
12
13const (
14 historyMagic = "VTH1"
15 historyVersion = 2
16 historyCellBytes = 41
17 historyBoundBytes = 5
18 maxHistoryChunkRows = 256
19
20 // The daemon retains 10,000 history rows. Support a 20% row margin and
21 // 160-cell rows, which leaves headroom above the representative 10k×120
22 // terminal while bounding snapshot allocation.
23 maxHistoryChunks = 12_000
24 maxHistoryRows = 12_000
25 maxHistoryRowCells = 160
26 maxHistoryCells = maxHistoryRows * maxHistoryRowCells
27
28 maxHistoryDecodeStyles = maxHistoryCells
29 maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes
30)
31
32var errInvalidHistory = errors.New("invalid history payload")
33
34// MarshalHistory encodes a HistoryView in a deterministic, self-contained
35// format. It preserves chunk boundaries as well as every Cell and Style field.
36func MarshalHistory(view HistoryView) ([]byte, error) {
37 if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks {
38 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
39 }
40 stats := historyDecodeStats{}
41 for _, chunk := range view.chunks {
42 if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows ||
43 !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) {
44 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
45 }
46 for _, row := range chunk.rows {
47 cellCount := uint64(len(row))
48 cellBytes, ok := historyCellByteCount(cellCount)
49 if !ok ||
50 !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) ||
51 !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) ||
52 !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
53 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
54 }
55 }
56 }
57
58 out := make([]byte, 0, 9)
59 out = append(out, historyMagic...)
60 out = append(out, historyVersion)
61 out = appendUint32(out, uint32(len(view.chunks)))
62 for _, chunk := range view.chunks {
63 if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows {
64 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
65 }
66 out = appendUint32(out, uint32(len(chunk.rows)))
67 for i, row := range chunk.rows {
68 if uint64(len(row)) > math.MaxUint32 {
69 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
70 }
71 bound := chunkBound(chunk, i)
72 if !validHistoryBound(bound, len(row)) {
73 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
74 }
75 out = appendUint32(out, uint32(len(row)))
76 for _, cell := range row {
77 if !validHistoryCell(cell) {
78 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
79 }
80 out = appendHistoryCell(out, cell)
81 }
82 out = appendHistoryBound(out, bound)
83 }
84 }
85 return out, nil
86}
87
88func historyViewRowCount(view HistoryView) int {
89 n := 0
90 for _, chunk := range view.chunks {
91 if chunk != nil && len(chunk.rows) <= math.MaxInt-n {
92 n += len(chunk.rows)
93 continue
94 }
95 return -1
96 }
97 return n
98}
99
100func appendUint32(dst []byte, v uint32) []byte {
101 return binary.BigEndian.AppendUint32(dst, v)
102}
103
104func appendHistoryCell(dst []byte, cell renderer.Cell) []byte {
105 style := cell.Style
106 dst = binary.BigEndian.AppendUint32(dst, uint32(cell.Rune))
107 flags := byte(0)
108 if cell.Continuation {
109 flags |= 1 << 0
110 }
111 if style.Bold {
112 flags |= 1 << 1
113 }
114 if style.Italic {
115 flags |= 1 << 2
116 }
117 if style.Inverse {
118 flags |= 1 << 3
119 }
120 if style.HasForegroundRGB {
121 flags |= 1 << 4
122 }
123 if style.HasBackgroundRGB {
124 flags |= 1 << 5
125 }
126 if style.HasUnderlineColor {
127 flags |= 1 << 6
128 }
129 if style.HasUnderlineColorRGB {
130 flags |= 1 << 7
131 }
132 dst = append(dst, flags)
133 dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs))
134 dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground)))
135 dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background)))
136 dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B)
137 dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B)
138 dst = append(dst, byte(style.UnderlineStyle))
139 dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor)))
140 return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)
141}
142
143func 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
150func 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.
159func validHistoryBound(b LineBound, cells int) bool {
160 return b.End >= 0 && b.End <= cells
161}
162
163// UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects
164// malformed declarations, cells, truncated data, and trailing bytes.
165func UnmarshalHistory(data []byte) (HistoryView, error) {
166 view, _, ok := parseHistory(data, true)
167 if !ok {
168 return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
169 }
170 return view, nil
171}
172
173type historyDecodeStats struct {
174 chunks uint64
175 rows uint64
176 cells uint64
177 styles uint64
178 bytes uint64
179}
180
181// preflightHistory validates a history blob without allocating decoded rows.
182// It shares parseHistory with UnmarshalHistory so both paths enforce the same
183// structural, cell, and aggregate-budget constraints.
184func preflightHistory(data []byte) (historyDecodeStats, bool) {
185 _, stats, ok := parseHistory(data, false)
186 return stats, ok
187}
188
189// parseHistory is the authoritative history payload validation path. populate
190// controls whether validated cells are retained in a HistoryView.
191func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) {
192 if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion {
193 return HistoryView{}, historyDecodeStats{}, false
194 }
195
196 p := historyParser{data: data[5:]}
197 chunkCount, ok := p.uint32()
198 if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 {
199 return HistoryView{}, historyDecodeStats{}, false
200 }
201 stats := historyDecodeStats{chunks: uint64(chunkCount)}
202 var chunks []*HistoryChunk
203 if populate {
204 chunks = make([]*HistoryChunk, 0, chunkCount)
205 }
206 for range chunkCount {
207 rowCount, ok := p.uint32()
208 if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||
209 uint64(rowCount) > uint64(len(p.data))/(4+historyBoundBytes) ||
210 !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {
211 return HistoryView{}, historyDecodeStats{}, false
212 }
213
214 var rows [][]renderer.Cell
215 var bounds []LineBound
216 if populate {
217 rows = make([][]renderer.Cell, 0, rowCount)
218 bounds = make([]LineBound, 0, rowCount)
219 }
220 for range rowCount {
221 cellCount, ok := p.uint32()
222 cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))
223 if !ok || !validCellBytes ||
224 !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) ||
225 !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) ||
226 !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) ||
227 uint64(cellCount) > uint64(len(p.data))/historyCellBytes {
228 return HistoryView{}, historyDecodeStats{}, false
229 }
230
231 var row []renderer.Cell
232 if populate {
233 row = make([]renderer.Cell, cellCount)
234 }
235 for i := range cellCount {
236 cell, ok := p.cell()
237 if !ok || !validHistoryCell(cell) {
238 return HistoryView{}, historyDecodeStats{}, false
239 }
240 if populate {
241 row[i] = cell
242 }
243 }
244 bound, ok := p.bound()
245 if !ok || !validHistoryBound(bound, int(cellCount)) {
246 return HistoryView{}, historyDecodeStats{}, false
247 }
248 if populate {
249 rows = append(rows, row)
250 bounds = append(bounds, bound)
251 }
252 }
253 if populate {
254 chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds})
255 }
256 }
257 if len(p.data) != 0 {
258 return HistoryView{}, historyDecodeStats{}, false
259 }
260 return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells)}, stats, true
261}
262
263func historyCellByteCount(cellCount uint64) (uint64, bool) {
264 if cellCount > math.MaxUint64/historyCellBytes {
265 return 0, false
266 }
267 return cellCount * historyCellBytes, true
268}
269
270func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool {
271 if *total > limit || amount > limit-*total {
272 return false
273 }
274 *total += amount
275 return true
276}
277
278type historyParser struct{ data []byte }
279
280func (p *historyParser) uint32() (uint32, bool) {
281 if len(p.data) < 4 {
282 return 0, false
283 }
284 v := binary.BigEndian.Uint32(p.data)
285 p.data = p.data[4:]
286 return v, true
287}
288
289func (p *historyParser) cell() (renderer.Cell, bool) {
290 if len(p.data) < historyCellBytes {
291 return renderer.Cell{}, false
292 }
293 b := p.data[:historyCellBytes]
294 p.data = p.data[historyCellBytes:]
295 foreground, ok := historyInt(binary.BigEndian.Uint64(b[7:15]))
296 if !ok {
297 return renderer.Cell{}, false
298 }
299 background, ok := historyInt(binary.BigEndian.Uint64(b[15:23]))
300 if !ok {
301 return renderer.Cell{}, false
302 }
303 underlineColor, ok := historyInt(binary.BigEndian.Uint64(b[30:38]))
304 if !ok {
305 return renderer.Cell{}, false
306 }
307 flags := b[4]
308 return renderer.Cell{
309 Rune: rune(binary.BigEndian.Uint32(b[0:4])),
310 Continuation: flags&1 != 0,
311 Style: renderer.Style{
312 Bold: flags&(1<<1) != 0,
313 Italic: flags&(1<<2) != 0,
314 Inverse: flags&(1<<3) != 0,
315 Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[5:7])),
316 Foreground: foreground,
317 Background: background,
318 HasForegroundRGB: flags&(1<<4) != 0,
319 ForegroundRGB: renderer.RGB{R: b[23], G: b[24], B: b[25]},
320 HasBackgroundRGB: flags&(1<<5) != 0,
321 BackgroundRGB: renderer.RGB{R: b[26], G: b[27], B: b[28]},
322 UnderlineStyle: renderer.UnderlineStyle(b[29]),
323 HasUnderlineColor: flags&(1<<6) != 0,
324 UnderlineColor: underlineColor,
325 HasUnderlineColorRGB: flags&(1<<7) != 0,
326 UnderlineColorRGB: renderer.RGB{R: b[38], G: b[39], B: b[40]},
327 },
328 }, true
329}
330
331func (p *historyParser) bound() (LineBound, bool) {
332 if len(p.data) < historyBoundBytes {
333 return LineBound{}, false
334 }
335 end := binary.BigEndian.Uint32(p.data[:4])
336 flag := p.data[4]
337 p.data = p.data[historyBoundBytes:]
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
344func historyInt(raw uint64) (int, bool) {
345 v := int64(raw)
346 if int64(int(v)) != v {
347 return 0, false
348 }
349 return int(v), true
350}
351
352func validHistoryCell(cell renderer.Cell) bool {
353 return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed
354}