fix(vt): align history codec limits
- commit
- d2b05f96e2430c598579a7a071b333ddc7d1fcfc
- author
- brice · · 2026-07-12 15:43 UTC
- parent
- a23e755
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +139 -16
M pkg/vt/history_codec.go +42 -14
| @@ -16,13 +16,16 @@ const ( | |||
| 16 | 16 | historyCellBytes = 41 | |
| 17 | 17 | maxHistoryChunkRows = 256 | |
| 18 | 18 | ||
| 19 | - | // Preserve ten-thousand-row histories while bounding untrusted snapshot | |
| 20 | - | // payloads. The cell limit permits an average width of 100 cells per row. | |
| 21 | - | maxHistoryDecodeChunks = 10_000 | |
| 22 | - | maxHistoryDecodeRows = 10_000 | |
| 23 | - | maxHistoryDecodeCells = 1_000_000 | |
| 24 | - | maxHistoryDecodeStyles = maxHistoryDecodeCells | |
| 25 | - | maxHistoryDecodedBytes = 64 << 20 | |
| 19 | + | // The daemon retains 10,000 history rows. Support a 20% row margin and | |
| 20 | + | // 160-cell rows, which leaves headroom above the representative 10k×120 | |
| 21 | + | // terminal while bounding snapshot allocation. | |
| 22 | + | maxHistoryChunks = 12_000 | |
| 23 | + | maxHistoryRows = 12_000 | |
| 24 | + | maxHistoryRowCells = 160 | |
| 25 | + | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | |
| 26 | + | ||
| 27 | + | maxHistoryDecodeStyles = maxHistoryCells | |
| 28 | + | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | |
| 26 | 29 | ) | |
| 27 | 30 | ||
| 28 | 31 | var errInvalidHistory = errors.New("invalid history payload") | |
| @@ -30,9 +33,27 @@ var errInvalidHistory = errors.New("invalid history payload") | |||
| 30 | 33 | // MarshalHistory encodes a HistoryView in a deterministic, self-contained | |
| 31 | 34 | // format. It preserves chunk boundaries as well as every Cell and Style field. | |
| 32 | 35 | func MarshalHistory(view HistoryView) ([]byte, error) { | |
| 33 | - | if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 { | |
| 36 | + | if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks { | |
| 34 | 37 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 35 | 38 | } | |
| 39 | + | stats := historyDecodeStats{} | |
| 40 | + | for _, chunk := range view.chunks { | |
| 41 | + | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows || | |
| 42 | + | !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) { | |
| 43 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 44 | + | } | |
| 45 | + | for _, row := range chunk.rows { | |
| 46 | + | cellCount := uint64(len(row)) | |
| 47 | + | cellBytes, ok := historyCellByteCount(cellCount) | |
| 48 | + | if cellCount > maxHistoryRowCells || !ok || | |
| 49 | + | !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) || | |
| 50 | + | !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) || | |
| 51 | + | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | |
| 52 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 36 | 57 | out := make([]byte, 0, 9) | |
| 37 | 58 | out = append(out, historyMagic...) | |
| 38 | 59 | out = append(out, historyVersion) | |
| @@ -141,7 +162,7 @@ func UnmarshalHistory(data []byte) (HistoryView, error) { | |||
| 141 | 162 | rows := make([][]renderer.Cell, 0, rowCount) | |
| 142 | 163 | for range rowCount { | |
| 143 | 164 | cellCount, ok := p.uint32() | |
| 144 | - | if !ok || uint64(cellCount) > uint64(len(p.data))/historyCellBytes || uint64(cellCount) > uint64(math.MaxInt) { | |
| 165 | + | if !ok || uint64(cellCount) > maxHistoryRowCells || uint64(cellCount) > uint64(len(p.data))/historyCellBytes || uint64(cellCount) > uint64(math.MaxInt) { | |
| 145 | 166 | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| 146 | 167 | } | |
| 147 | 168 | row := make([]renderer.Cell, cellCount) | |
| @@ -176,22 +197,22 @@ type historyDecodeStats struct { | |||
| 176 | 197 | func preflightHistory(data []byte) (historyDecodeStats, bool) { | |
| 177 | 198 | p := historyParser{data: data} | |
| 178 | 199 | chunkCount, ok := p.uint32() | |
| 179 | - | if !ok || uint64(chunkCount) > maxHistoryDecodeChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | |
| 200 | + | if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | |
| 180 | 201 | return historyDecodeStats{}, false | |
| 181 | 202 | } | |
| 182 | 203 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | |
| 183 | 204 | for range chunkCount { | |
| 184 | 205 | rowCount, ok := p.uint32() | |
| 185 | - | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 || !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryDecodeRows) { | |
| 206 | + | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 || !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | |
| 186 | 207 | return historyDecodeStats{}, false | |
| 187 | 208 | } | |
| 188 | 209 | for range rowCount { | |
| 189 | 210 | cellCount, ok := p.uint32() | |
| 190 | - | if !ok || uint64(cellCount) > uint64(len(p.data))/historyCellBytes { | |
| 211 | + | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | |
| 212 | + | if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes || uint64(cellCount) > uint64(len(p.data))/historyCellBytes { | |
| 191 | 213 | return historyDecodeStats{}, false | |
| 192 | 214 | } | |
| 193 | - | cellBytes := uint64(cellCount) * historyCellBytes | |
| 194 | - | if !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryDecodeCells) || | |
| 215 | + | if !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) || | |
| 195 | 216 | !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) || | |
| 196 | 217 | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | |
| 197 | 218 | return historyDecodeStats{}, false | |
| @@ -202,6 +223,13 @@ func preflightHistory(data []byte) (historyDecodeStats, bool) { | |||
| 202 | 223 | return stats, len(p.data) == 0 | |
| 203 | 224 | } | |
| 204 | 225 | ||
| 226 | + | func historyCellByteCount(cellCount uint64) (uint64, bool) { | |
| 227 | + | if cellCount > math.MaxUint64/historyCellBytes { | |
| 228 | + | return 0, false | |
| 229 | + | } | |
| 230 | + | return cellCount * historyCellBytes, true | |
| 231 | + | } | |
| 232 | + | ||
| 205 | 233 | func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool { | |
| 206 | 234 | if *total > limit || amount > limit-*total { | |
| 207 | 235 | return false | |
| @@ -16,13 +16,16 @@ const ( | |||||
| 16 | historyCellBytes = 41 | 16 | historyCellBytes = 41 | ||
| 17 | maxHistoryChunkRows = 256 | 17 | maxHistoryChunkRows = 256 | ||
| 18 | 18 | ||||
| 19 | - | // Preserve ten-thousand-row histories while bounding untrusted snapshot | 19 | + | // The daemon retains 10,000 history rows. Support a 20% row margin and |
| 20 | - | // payloads. The cell limit permits an average width of 100 cells per row. | 20 | + | // 160-cell rows, which leaves headroom above the representative 10k×120 |
| 21 | - | maxHistoryDecodeChunks = 10_000 | 21 | + | // terminal while bounding snapshot allocation. |
| 22 | - | maxHistoryDecodeRows = 10_000 | 22 | + | maxHistoryChunks = 12_000 |
| 23 | - | maxHistoryDecodeCells = 1_000_000 | 23 | + | maxHistoryRows = 12_000 |
| 24 | - | maxHistoryDecodeStyles = maxHistoryDecodeCells | 24 | + | maxHistoryRowCells = 160 |
| 25 | - | maxHistoryDecodedBytes = 64 << 20 | 25 | + | maxHistoryCells = maxHistoryRows * maxHistoryRowCells |
| 26 | + | ||||
| 27 | + | maxHistoryDecodeStyles = maxHistoryCells | |||
| 28 | + | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | |||
| 26 | ) | 29 | ) | ||
| 27 | 30 | ||||
| 28 | var errInvalidHistory = errors.New("invalid history payload") | 31 | var errInvalidHistory = errors.New("invalid history payload") | ||
| @@ -30,9 +33,27 @@ var errInvalidHistory = errors.New("invalid history payload") | |||||
| 30 | // MarshalHistory encodes a HistoryView in a deterministic, self-contained | 33 | // MarshalHistory encodes a HistoryView in a deterministic, self-contained | ||
| 31 | // format. It preserves chunk boundaries as well as every Cell and Style field. | 34 | // format. It preserves chunk boundaries as well as every Cell and Style field. | ||
| 32 | func MarshalHistory(view HistoryView) ([]byte, error) { | 35 | func MarshalHistory(view HistoryView) ([]byte, error) { | ||
| 33 | - | if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 { | 36 | + | if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks { |
| 34 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 37 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | ||
| 35 | } | 38 | } | ||
| 39 | + | stats := historyDecodeStats{} | |||
| 40 | + | for _, chunk := range view.chunks { | |||
| 41 | + | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows || | |||
| 42 | + | !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) { | |||
| 43 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 44 | + | } | |||
| 45 | + | for _, row := range chunk.rows { | |||
| 46 | + | cellCount := uint64(len(row)) | |||
| 47 | + | cellBytes, ok := historyCellByteCount(cellCount) | |||
| 48 | + | if cellCount > maxHistoryRowCells || !ok || | |||
| 49 | + | !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) || | |||
| 50 | + | !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) || | |||
| 51 | + | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | |||
| 52 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 36 | out := make([]byte, 0, 9) | 57 | out := make([]byte, 0, 9) | ||
| 37 | out = append(out, historyMagic...) | 58 | out = append(out, historyMagic...) | ||
| 38 | out = append(out, historyVersion) | 59 | out = append(out, historyVersion) | ||
| @@ -141,7 +162,7 @@ func UnmarshalHistory(data []byte) (HistoryView, error) { | |||||
| 141 | rows := make([][]renderer.Cell, 0, rowCount) | 162 | rows := make([][]renderer.Cell, 0, rowCount) | ||
| 142 | for range rowCount { | 163 | for range rowCount { | ||
| 143 | cellCount, ok := p.uint32() | 164 | cellCount, ok := p.uint32() | ||
| 144 | - | if !ok || uint64(cellCount) > uint64(len(p.data))/historyCellBytes || uint64(cellCount) > uint64(math.MaxInt) { | 165 | + | if !ok || uint64(cellCount) > maxHistoryRowCells || uint64(cellCount) > uint64(len(p.data))/historyCellBytes || uint64(cellCount) > uint64(math.MaxInt) { |
| 145 | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | 166 | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | ||
| 146 | } | 167 | } | ||
| 147 | row := make([]renderer.Cell, cellCount) | 168 | row := make([]renderer.Cell, cellCount) | ||
| @@ -176,22 +197,22 @@ type historyDecodeStats struct { | |||||
| 176 | func preflightHistory(data []byte) (historyDecodeStats, bool) { | 197 | func preflightHistory(data []byte) (historyDecodeStats, bool) { | ||
| 177 | p := historyParser{data: data} | 198 | p := historyParser{data: data} | ||
| 178 | chunkCount, ok := p.uint32() | 199 | chunkCount, ok := p.uint32() | ||
| 179 | - | if !ok || uint64(chunkCount) > maxHistoryDecodeChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | 200 | + | if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 { |
| 180 | return historyDecodeStats{}, false | 201 | return historyDecodeStats{}, false | ||
| 181 | } | 202 | } | ||
| 182 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | 203 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | ||
| 183 | for range chunkCount { | 204 | for range chunkCount { | ||
| 184 | rowCount, ok := p.uint32() | 205 | rowCount, ok := p.uint32() | ||
| 185 | - | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 || !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryDecodeRows) { | 206 | + | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 || !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { |
| 186 | return historyDecodeStats{}, false | 207 | return historyDecodeStats{}, false | ||
| 187 | } | 208 | } | ||
| 188 | for range rowCount { | 209 | for range rowCount { | ||
| 189 | cellCount, ok := p.uint32() | 210 | cellCount, ok := p.uint32() | ||
| 190 | - | if !ok || uint64(cellCount) > uint64(len(p.data))/historyCellBytes { | 211 | + | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) |
| 212 | + | if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes || uint64(cellCount) > uint64(len(p.data))/historyCellBytes { | |||
| 191 | return historyDecodeStats{}, false | 213 | return historyDecodeStats{}, false | ||
| 192 | } | 214 | } | ||
| 193 | - | cellBytes := uint64(cellCount) * historyCellBytes | 215 | + | if !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) || |
| 194 | - | if !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryDecodeCells) || | |||
| 195 | !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) || | 216 | !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) || | ||
| 196 | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | 217 | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | ||
| 197 | return historyDecodeStats{}, false | 218 | return historyDecodeStats{}, false | ||
| @@ -202,6 +223,13 @@ func preflightHistory(data []byte) (historyDecodeStats, bool) { | |||||
| 202 | return stats, len(p.data) == 0 | 223 | return stats, len(p.data) == 0 | ||
| 203 | } | 224 | } | ||
| 204 | 225 | ||||
| 226 | + | func historyCellByteCount(cellCount uint64) (uint64, bool) { | |||
| 227 | + | if cellCount > math.MaxUint64/historyCellBytes { | |||
| 228 | + | return 0, false | |||
| 229 | + | } | |||
| 230 | + | return cellCount * historyCellBytes, true | |||
| 231 | + | } | |||
| 232 | + | ||||
| 205 | func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool { | 233 | func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool { | ||
| 206 | if *total > limit || amount > limit-*total { | 234 | if *total > limit || amount > limit-*total { | ||
| 207 | return false | 235 | return false | ||
M pkg/vt/history_codec_test.go +97 -2
| @@ -98,14 +98,72 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | |||
| 98 | 98 | } | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | + | func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) { | |
| 102 | + | const ( | |
| 103 | + | rows = 10_000 | |
| 104 | + | width = 120 | |
| 105 | + | ) | |
| 106 | + | ||
| 107 | + | encoded, err := MarshalHistory(historyViewWithDimensions(rows, width)) | |
| 108 | + | if err != nil { | |
| 109 | + | t.Fatalf("marshal representative history: %v", err) | |
| 110 | + | } | |
| 111 | + | decoded, err := UnmarshalHistory(encoded) | |
| 112 | + | if err != nil { | |
| 113 | + | t.Fatalf("unmarshal representative history: %v", err) | |
| 114 | + | } | |
| 115 | + | if got := decoded.Len(); got != rows { | |
| 116 | + | t.Fatalf("decoded rows = %d, want %d", got, rows) | |
| 117 | + | } | |
| 118 | + | for _, rowIndex := range []int{0, rows - 1} { | |
| 119 | + | if got := len(decoded.Row(rowIndex)); got != width { | |
| 120 | + | t.Fatalf("decoded row %d width = %d, want %d", rowIndex, got, width) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) { | |
| 126 | + | const ( | |
| 127 | + | supportedRows = 12_000 | |
| 128 | + | supportedWidth = 160 | |
| 129 | + | ) | |
| 130 | + | tests := []struct { | |
| 131 | + | name string | |
| 132 | + | view HistoryView | |
| 133 | + | data []byte | |
| 134 | + | }{ | |
| 135 | + | { | |
| 136 | + | name: "too many rows", | |
| 137 | + | view: historyViewWithDimensions(supportedRows+1, 0), | |
| 138 | + | data: historyPayloadWithDimensions(supportedRows+1, 0), | |
| 139 | + | }, | |
| 140 | + | { | |
| 141 | + | name: "too many cells in a row", | |
| 142 | + | view: historyViewWithDimensions(1, supportedWidth+1), | |
| 143 | + | data: historyPayloadWithDimensions(1, supportedWidth+1), | |
| 144 | + | }, | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | for _, tt := range tests { | |
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |
| 149 | + | if _, err := MarshalHistory(tt.view); err == nil { | |
| 150 | + | t.Fatal("marshal accepted dimensions beyond the supported limits") | |
| 151 | + | } | |
| 152 | + | if _, err := UnmarshalHistory(tt.data); err == nil { | |
| 153 | + | t.Fatal("unmarshal accepted dimensions beyond the supported limits") | |
| 154 | + | } | |
| 155 | + | }) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 101 | 159 | func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) { | |
| 102 | 160 | tests := []struct { | |
| 103 | 161 | name string | |
| 104 | 162 | chunkCount int | |
| 105 | 163 | rowCount int | |
| 106 | 164 | }{ | |
| 107 | - | {name: "more than ten thousand chunks", chunkCount: 10_001, rowCount: 1}, | |
| 108 | - | {name: "more than ten thousand rows", chunkCount: 40, rowCount: maxHistoryChunkRows}, | |
| 165 | + | {name: "more than twelve thousand chunks", chunkCount: 12_001, rowCount: 1}, | |
| 166 | + | {name: "more than twelve thousand rows", chunkCount: 47, rowCount: maxHistoryChunkRows}, | |
| 109 | 167 | } | |
| 110 | 168 | ||
| 111 | 169 | for _, tt := range tests { | |
| @@ -117,6 +175,43 @@ func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) { | |||
| 117 | 175 | } | |
| 118 | 176 | } | |
| 119 | 177 | ||
| 178 | + | func historyViewWithDimensions(rowCount, width int) HistoryView { | |
| 179 | + | totalRows := rowCount | |
| 180 | + | row := make([]renderer.Cell, width) | |
| 181 | + | for i := range row { | |
| 182 | + | row[i].Rune = 'x' | |
| 183 | + | } | |
| 184 | + | chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows) | |
| 185 | + | for rowCount > 0 { | |
| 186 | + | chunkRows := min(rowCount, maxHistoryChunkRows) | |
| 187 | + | rows := make([][]renderer.Cell, chunkRows) | |
| 188 | + | for i := range rows { | |
| 189 | + | rows[i] = row | |
| 190 | + | } | |
| 191 | + | chunks = append(chunks, &HistoryChunk{rows: rows}) | |
| 192 | + | rowCount -= chunkRows | |
| 193 | + | } | |
| 194 | + | return HistoryView{chunks: chunks, rows: totalRows} | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func historyPayloadWithDimensions(rowCount, width int) []byte { | |
| 198 | + | data := make([]byte, 9) | |
| 199 | + | copy(data, historyMagic) | |
| 200 | + | data[4] = historyVersion | |
| 201 | + | chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows | |
| 202 | + | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |
| 203 | + | for rowCount > 0 { | |
| 204 | + | chunkRows := min(rowCount, maxHistoryChunkRows) | |
| 205 | + | data = binary.BigEndian.AppendUint32(data, uint32(chunkRows)) | |
| 206 | + | for range chunkRows { | |
| 207 | + | data = binary.BigEndian.AppendUint32(data, uint32(width)) | |
| 208 | + | data = append(data, make([]byte, width*historyCellBytes)...) | |
| 209 | + | } | |
| 210 | + | rowCount -= chunkRows | |
| 211 | + | } | |
| 212 | + | return data | |
| 213 | + | } | |
| 214 | + | ||
| 120 | 215 | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | |
| 121 | 216 | data := make([]byte, 9, 9+chunkCount*(4+rowCount*4)) | |
| 122 | 217 | copy(data, historyMagic) | |
| @@ -98,14 +98,72 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | |||||
| 98 | } | 98 | } | ||
| 99 | } | 99 | } | ||
| 100 | 100 | ||||
| 101 | + | func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) { | |||
| 102 | + | const ( | |||
| 103 | + | rows = 10_000 | |||
| 104 | + | width = 120 | |||
| 105 | + | ) | |||
| 106 | + | ||||
| 107 | + | encoded, err := MarshalHistory(historyViewWithDimensions(rows, width)) | |||
| 108 | + | if err != nil { | |||
| 109 | + | t.Fatalf("marshal representative history: %v", err) | |||
| 110 | + | } | |||
| 111 | + | decoded, err := UnmarshalHistory(encoded) | |||
| 112 | + | if err != nil { | |||
| 113 | + | t.Fatalf("unmarshal representative history: %v", err) | |||
| 114 | + | } | |||
| 115 | + | if got := decoded.Len(); got != rows { | |||
| 116 | + | t.Fatalf("decoded rows = %d, want %d", got, rows) | |||
| 117 | + | } | |||
| 118 | + | for _, rowIndex := range []int{0, rows - 1} { | |||
| 119 | + | if got := len(decoded.Row(rowIndex)); got != width { | |||
| 120 | + | t.Fatalf("decoded row %d width = %d, want %d", rowIndex, got, width) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) { | |||
| 126 | + | const ( | |||
| 127 | + | supportedRows = 12_000 | |||
| 128 | + | supportedWidth = 160 | |||
| 129 | + | ) | |||
| 130 | + | tests := []struct { | |||
| 131 | + | name string | |||
| 132 | + | view HistoryView | |||
| 133 | + | data []byte | |||
| 134 | + | }{ | |||
| 135 | + | { | |||
| 136 | + | name: "too many rows", | |||
| 137 | + | view: historyViewWithDimensions(supportedRows+1, 0), | |||
| 138 | + | data: historyPayloadWithDimensions(supportedRows+1, 0), | |||
| 139 | + | }, | |||
| 140 | + | { | |||
| 141 | + | name: "too many cells in a row", | |||
| 142 | + | view: historyViewWithDimensions(1, supportedWidth+1), | |||
| 143 | + | data: historyPayloadWithDimensions(1, supportedWidth+1), | |||
| 144 | + | }, | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | for _, tt := range tests { | |||
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 149 | + | if _, err := MarshalHistory(tt.view); err == nil { | |||
| 150 | + | t.Fatal("marshal accepted dimensions beyond the supported limits") | |||
| 151 | + | } | |||
| 152 | + | if _, err := UnmarshalHistory(tt.data); err == nil { | |||
| 153 | + | t.Fatal("unmarshal accepted dimensions beyond the supported limits") | |||
| 154 | + | } | |||
| 155 | + | }) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 101 | func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) { | 159 | func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) { | ||
| 102 | tests := []struct { | 160 | tests := []struct { | ||
| 103 | name string | 161 | name string | ||
| 104 | chunkCount int | 162 | chunkCount int | ||
| 105 | rowCount int | 163 | rowCount int | ||
| 106 | }{ | 164 | }{ | ||
| 107 | - | {name: "more than ten thousand chunks", chunkCount: 10_001, rowCount: 1}, | 165 | + | {name: "more than twelve thousand chunks", chunkCount: 12_001, rowCount: 1}, |
| 108 | - | {name: "more than ten thousand rows", chunkCount: 40, rowCount: maxHistoryChunkRows}, | 166 | + | {name: "more than twelve thousand rows", chunkCount: 47, rowCount: maxHistoryChunkRows}, |
| 109 | } | 167 | } | ||
| 110 | 168 | ||||
| 111 | for _, tt := range tests { | 169 | for _, tt := range tests { | ||
| @@ -117,6 +175,43 @@ func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) { | |||||
| 117 | } | 175 | } | ||
| 118 | } | 176 | } | ||
| 119 | 177 | ||||
| 178 | + | func historyViewWithDimensions(rowCount, width int) HistoryView { | |||
| 179 | + | totalRows := rowCount | |||
| 180 | + | row := make([]renderer.Cell, width) | |||
| 181 | + | for i := range row { | |||
| 182 | + | row[i].Rune = 'x' | |||
| 183 | + | } | |||
| 184 | + | chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows) | |||
| 185 | + | for rowCount > 0 { | |||
| 186 | + | chunkRows := min(rowCount, maxHistoryChunkRows) | |||
| 187 | + | rows := make([][]renderer.Cell, chunkRows) | |||
| 188 | + | for i := range rows { | |||
| 189 | + | rows[i] = row | |||
| 190 | + | } | |||
| 191 | + | chunks = append(chunks, &HistoryChunk{rows: rows}) | |||
| 192 | + | rowCount -= chunkRows | |||
| 193 | + | } | |||
| 194 | + | return HistoryView{chunks: chunks, rows: totalRows} | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func historyPayloadWithDimensions(rowCount, width int) []byte { | |||
| 198 | + | data := make([]byte, 9) | |||
| 199 | + | copy(data, historyMagic) | |||
| 200 | + | data[4] = historyVersion | |||
| 201 | + | chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows | |||
| 202 | + | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |||
| 203 | + | for rowCount > 0 { | |||
| 204 | + | chunkRows := min(rowCount, maxHistoryChunkRows) | |||
| 205 | + | data = binary.BigEndian.AppendUint32(data, uint32(chunkRows)) | |||
| 206 | + | for range chunkRows { | |||
| 207 | + | data = binary.BigEndian.AppendUint32(data, uint32(width)) | |||
| 208 | + | data = append(data, make([]byte, width*historyCellBytes)...) | |||
| 209 | + | } | |||
| 210 | + | rowCount -= chunkRows | |||
| 211 | + | } | |||
| 212 | + | return data | |||
| 213 | + | } | |||
| 214 | + | ||||
| 120 | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | 215 | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | ||
| 121 | data := make([]byte, 9, 9+chunkCount*(4+rowCount*4)) | 216 | data := make([]byte, 9, 9+chunkCount*(4+rowCount*4)) | ||
| 122 | copy(data, historyMagic) | 217 | copy(data, historyMagic) | ||