From a508720a89c5e899e6bc8883e1cc23851ea4ddb8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 09:29:10 -0700 Subject: [PATCH] terminal/snapshot: grid decode robustness principle Keep snapshot grid encoding strict by rejecting malformed wide-cell relationships before they can produce invalid wire data. Decode untrusted grids liberally while preserving record alignment. Unknown semantic values and content kinds degrade to safe defaults, optional graphemes and hyperlinks are dropped when invalid or over capacity, and malformed wide-cell markers normalize to narrow cells. --- src/terminal/snapshot/AGENTS.md | 7 + src/terminal/snapshot/grid.zig | 445 +++++++++++++++++++------------- src/terminal/snapshot/page.zig | 149 +++++++---- 3 files changed, 374 insertions(+), 227 deletions(-) create mode 100644 src/terminal/snapshot/AGENTS.md diff --git a/src/terminal/snapshot/AGENTS.md b/src/terminal/snapshot/AGENTS.md new file mode 100644 index 000000000..0ce37b5ed --- /dev/null +++ b/src/terminal/snapshot/AGENTS.md @@ -0,0 +1,7 @@ +# Terminal Binary Snapshot + +- Apply the robustness principle: "be conservative in what you do, be liberal + in what you accept from others." + - Validate on encode + - Gracefully degrade on decode, for example invalid styles degrade to + ignoring the style. diff --git a/src/terminal/snapshot/grid.zig b/src/terminal/snapshot/grid.zig index 8225cd262..18e7955ba 100644 --- a/src/terminal/snapshot/grid.zig +++ b/src/terminal/snapshot/grid.zig @@ -38,7 +38,7 @@ //! | 1 | Prompt | //! | 2 | Prompt continuation | //! -//! Value 3 is invalid in snapshot version 1. +//! Value 3 is not emitted in snapshot version 1. Decoders treat it as none. //! //! Native row cache flags are not encoded. In particular, the Kitty virtual //! placeholder hint is derived while decoding cells containing U+10EEEE. @@ -97,7 +97,7 @@ //! | 1 | Input | //! | 2 | Prompt | //! -//! Value 3 is invalid in snapshot version 1. +//! Value 3 is not emitted in snapshot version 1. Decoders treat it as output. //! //! Style and hyperlink ID zero mean no style and no hyperlink. Other IDs //! refer to entries in the containing record's separate style and hyperlink @@ -135,18 +135,21 @@ pub const StyleRemap = std.AutoHashMap(TerminalStyleId, TerminalStyleId); /// Hyperlink ID zero is implicit and does not need an entry. pub const HyperlinkRemap = std.AutoHashMap(TerminalHyperlinkId, TerminalHyperlinkId); -pub const EncodeError = std.Io.Writer.Error; +pub const EncodeError = std.Io.Writer.Error || error{ + /// Wide and spacer cells do not form a valid row. + InvalidWideCell, +}; /// Encode every row and cell directly from a page. +/// +/// If an error is returned, partial data may have been written. If you +/// want transactional writing, the caller is responsible for using something +/// like a seekable stream and rolling back. pub fn encode( page: *const TerminalPage, writer: *std.Io.Writer, ) EncodeError!void { - // Encoding assumes a structurally valid page. This assertion performs the - // comprehensive check in slow-safety builds without adding an allocator to - // the encoder API. - page.assertIntegrity(); - + defer page.assertIntegrity(); for (0..page.size.rows) |y| { // Row header const row = page.getRow(y); @@ -159,7 +162,26 @@ pub fn encode( // Cells const cells = page.getCells(row); - for (cells) |*cell| { + for (cells, 0..) |*cell, x| { + // Validate the wide state of this cell, we don't want + // to encode corrupt data. + switch (cell.wide) { + .narrow => {}, + .wide => if (x + 1 == cells.len or + cells[x + 1].wide != .spacer_tail) + { + return error.InvalidWideCell; + }, + .spacer_tail => if (x == 0 or + cells[x - 1].wide != .wide) + { + return error.InvalidWideCell; + }, + .spacer_head => if (x + 1 != cells.len or !row.wrap) { + return error.InvalidWideCell; + }, + } + const graphemes: []const u21 = if (cell.hasGrapheme()) page.lookupGrapheme(cell) orelse unreachable else @@ -212,39 +234,13 @@ pub fn encode( } } -pub const DecodeError = CellHeader.DecodeError || error{ - /// A row contains undefined flag values. - InvalidRow, - - /// A cell contains an undefined kind, width, flag, or reserved value. - InvalidCell, - - /// A grapheme suffix is invalid or attached to non-codepoint content. - InvalidGrapheme, - - /// The advertised grapheme capacity cannot hold the encoded suffixes. - InvalidGraphemeCapacity, - - /// A codepoint is not a Unicode scalar value. - InvalidCodepoint, - - /// A packed background color has nonzero reserved bytes. - InvalidColor, - - /// Wide and spacer cells do not form a valid row. - InvalidWideCell, - - /// The hyperlink map cannot hold the encoded cell references. - InvalidHyperlinkCapacity, -}; - /// Decode every row and cell directly into an initialized, empty page. /// /// The grid does not encode dimensions, so `page` must already have the exact /// row and column count expected by the containing record. This function reads -/// exactly `page.size.rows` rows with `page.size.cols` cells each. The page -/// must also have enough grapheme and hyperlink-map capacity for the decoded -/// contents. +/// exactly `page.size.rows` rows with `page.size.cols` cells each. Capacity +/// hints are advisory. Graphemes and cell hyperlink references that do not fit +/// are discarded without affecting the rest of the grid. /// /// Style and hyperlink table entries must be inserted into `page` before /// calling this function. As each table entry is inserted, the caller records @@ -252,143 +248,170 @@ pub const DecodeError = CellHeader.DecodeError || error{ /// ID zero always means the default style or no hyperlink. A nonzero ID missing /// from its remap is also treated as zero so unknown table references do not /// prevent the rest of the grid from decoding. +/// +/// Invalid semantic data is normalized into a degraded form while preserving +/// the declared byte boundaries. Unknown semantic values use their neutral +/// variants, invalid Unicode becomes U+FFFD, invalid optional data is ignored, +/// and malformed wide-cell relationships become narrow cells. pub fn decode( page: *TerminalPage, reader: *std.Io.Reader, style_remap: *const StyleRemap, hyperlink_remap: *const HyperlinkRemap, -) DecodeError!void { +) std.Io.Reader.Error!void { for (0..page.size.rows) |y| { const row_raw = try reader.takeByte(); - - // Validate the enum field before bitcasting into the packed native - // representation. The remaining value is checked through its named - // padding field below. - const semantic_prompt_raw: u2 = @truncate(row_raw >> 2); - _ = std.enums.fromInt( - TerminalRow.SemanticPrompt, - semantic_prompt_raw, - ) orelse return error.InvalidRow; - const row_header: RowHeader = @bitCast(row_raw); - if (row_header._padding != 0) return error.InvalidRow; + const semantic_prompt_raw: u2 = @truncate(row_raw >> @bitOffsetOf(RowHeader, "semantic_prompt")); + // Reserved bits do not change the known fields. The semantic enum is + // the only non-exhaustive field, so give its unknown value a default. const row = page.getRow(y); row.wrap = row_header.wrap; row.wrap_continuation = row_header.wrap_continuation; - row.semantic_prompt = row_header.semantic_prompt; + row.semantic_prompt = std.enums.fromInt( + TerminalRow.SemanticPrompt, + semantic_prompt_raw, + ) orelse .none; const cells = page.getCells(row); for (cells, 0..) |*cell, x| { - const header = try CellHeader.decode(reader); - - // IDs belong to the encoded page. Translate them to IDs assigned - // by the destination page before storing them on cells. - const encoded_style_id = header.style_id; - const style_id = if (encoded_style_id == 0) - 0 - else - style_remap.get(encoded_style_id) orelse 0; - - const encoded_hyperlink_id = header.hyperlink_id; - const hyperlink_id = if (encoded_hyperlink_id == 0) - 0 - else - hyperlink_remap.get(encoded_hyperlink_id) orelse 0; + const decoded_header = try CellHeader.decode(reader); cell.* = .init(0); - switch (header.content_kind) { - .codepoint => { - const cp = std.math.cast(u21, header.value.codepoint) orelse - return error.InvalidCodepoint; - if (cp > 0x10FFFF or - (cp >= 0xD800 and cp <= 0xDFFF)) - { - return error.InvalidCodepoint; - } - if (header.grapheme_count > 0 and cp == 0) { - return error.InvalidGrapheme; - } - cell.content = .{ .codepoint = .{ .data = cp } }; + var accept_graphemes = false; + const grapheme_count: u32 = switch (decoded_header) { + // The fixed header was fully consumed, but its content kind + // cannot be interpreted. Keep the cell empty and consume only + // the suffix bytes needed to reach the next cell. + .invalid => |count| count, - // Kitty image and placement state is not part of this - // snapshot version, but the placeholder is still a valid - // Unicode scalar. Preserve it and derive the native row - // hint so later row operations remain correct. - if (cp == kitty.graphics.unicode.placeholder) { - row.kitty_virtual_placeholder = true; + .valid => |header| valid: { + // IDs belong to the encoded page. Translate them to IDs + // assigned by the destination page before storing them on + // cells. + const encoded_style_id = header.style_id; + const style_id = if (encoded_style_id == 0) + 0 + else + style_remap.get(encoded_style_id) orelse 0; + + const encoded_hyperlink_id = header.hyperlink_id; + const hyperlink_id = if (encoded_hyperlink_id == 0) + 0 + else + hyperlink_remap.get(encoded_hyperlink_id) orelse 0; + + switch (header.content_kind) { + .codepoint => { + var cp = std.math.cast( + u21, + header.value.codepoint, + ) orelse 0xFFFD; + if (cp > 0x10FFFF or + (cp >= 0xD800 and cp <= 0xDFFF)) + { + cp = 0xFFFD; + } + cell.content = .{ .codepoint = .{ .data = cp } }; + accept_graphemes = cp != 0; + + // Kitty image and placement state is not part of this + // snapshot version, but the placeholder is still a + // valid Unicode scalar. Preserve it and derive the + // native row hint so later row operations remain + // correct. + if (cp == kitty.graphics.unicode.placeholder) { + row.kitty_virtual_placeholder = true; + } + }, + .bg_color_palette => { + const palette = header.value.bg_color_palette; + cell.content_tag = .bg_color_palette; + cell.content = .{ + .color_palette = .{ .data = palette.index }, + }; + }, + .bg_color_rgb => { + const rgb = header.value.bg_color_rgb; + cell.content_tag = .bg_color_rgb; + cell.content = .{ .color_rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + } }; + }, } + + cell.wide = header.width; + cell.protected = header.protected; + cell.semantic_content = header.semantic_content; + + if (style_id != 0) { + // The table owns one reference and each decoded cell owns + // one additional reference. + page.styles.use(page.memory, style_id); + cell.style_id = style_id; + row.styled = true; + } + + if (hyperlink_id != 0) { + // setHyperlink records the cell mapping but intentionally + // does not increment the set's reference count. If its map + // is full, undo our reference and leave this cell unlinked. + page.hyperlink_set.use(page.memory, hyperlink_id); + page.setHyperlink(row, cell, hyperlink_id) catch { + page.hyperlink_set.release(page.memory, hyperlink_id); + }; + } + + break :valid header.grapheme_count; }, - .bg_color_palette => { - const palette = header.value.bg_color_palette; - if (palette._padding != 0) return error.InvalidColor; - if (header.grapheme_count != 0) { - return error.InvalidGrapheme; - } - cell.content_tag = .bg_color_palette; - cell.content = .{ - .color_palette = .{ .data = palette.index }, - }; - }, - .bg_color_rgb => { - const rgb = header.value.bg_color_rgb; - if (rgb._padding != 0) return error.InvalidColor; - if (header.grapheme_count != 0) { - return error.InvalidGrapheme; - } - cell.content_tag = .bg_color_rgb; - cell.content = .{ .color_rgb = .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - } }; - }, - } + }; - cell.wide = header.width; - cell.protected = header.protected; - cell.semantic_content = header.semantic_content; - - if (style_id != 0) { - // The table owns one reference and each decoded cell owns one - // additional reference. - page.styles.use(page.memory, style_id); - cell.style_id = style_id; - row.styled = true; - } - - if (hyperlink_id != 0) { - // setHyperlink records the cell mapping but intentionally does - // not increment the set's reference count. - page.hyperlink_set.use(page.memory, hyperlink_id); - page.setHyperlink(row, cell, hyperlink_id) catch - return error.InvalidHyperlinkCapacity; - } - - // Append directly into page-owned grapheme storage so no - // intermediate suffix buffer is needed. - for (0..header.grapheme_count) |_| { + // Always consume every declared suffix. Invalid scalars, suffixes + // on non-text cells, and suffixes that exceed the native capacity + // are optional detail and can be dropped independently. + for (0..grapheme_count) |_| { const suffix_raw = try io.readInt(reader, u32); - const suffix = std.math.cast(u21, suffix_raw) orelse - return error.InvalidCodepoint; + if (!accept_graphemes) continue; + + const suffix = std.math.cast(u21, suffix_raw) orelse continue; if (suffix > 0x10FFFF or (suffix >= 0xD800 and suffix <= 0xDFFF)) { - return error.InvalidCodepoint; + continue; } - page.appendGrapheme(row, cell, suffix) catch - return error.InvalidGraphemeCapacity; + page.appendGrapheme(row, cell, suffix) catch { + accept_graphemes = false; + }; } - switch (header.width) { - .narrow, .wide => {}, + // A following cell resolves whether the previous wide marker owns + // a tail. Normalize the current marker immediately when possible, + // including a wide marker at the row end. + if (x > 0 and + cells[x - 1].wide == .wide and + cell.wide != .spacer_tail) + { + cells[x - 1].wide = .narrow; + } + switch (cell.wide) { + .narrow => {}, + + // A non-final wide marker remains pending until the next cell. + .wide => if (x + 1 == cells.len) { + cell.wide = .narrow; + }, + .spacer_tail => if (x == 0 or cells[x - 1].wide != .wide) { - return error.InvalidWideCell; + cell.wide = .narrow; }, + .spacer_head => if (x + 1 != cells.len or !row.wrap) { - return error.InvalidWideCell; + cell.wide = .narrow; }, } } @@ -439,9 +462,6 @@ const CellHeader = struct { /// Number of grapheme suffix codepoints immediately following the header. grapheme_count: u32 = 0, - /// Errors possible while decoding a fixed cell header. - pub const DecodeError = std.Io.Reader.Error || error{InvalidCell}; - /// Encode the fixed cell header. pub fn encode( self: CellHeader, @@ -467,45 +487,63 @@ const CellHeader = struct { try io.writeInt(writer, u32, self.grapheme_count); } - /// Decode and validate the fixed cell header. - pub fn decode(reader: *std.Io.Reader) CellHeader.DecodeError!CellHeader { - const content_kind = std.enums.fromInt( - Kind, - try reader.takeByte(), - ) orelse return error.InvalidCell; + /// A valid header, or the suffix count from an uninterpretable header. + const DecodeResult = union(enum) { + valid: CellHeader, + invalid: u32, + }; - const width = std.enums.fromInt( - TerminalCell.Wide, - try reader.takeByte(), - ) orelse return error.InvalidCell; + /// Decode a fixed cell header, normalizing unknown semantic values. + pub fn decode(reader: *std.Io.Reader) std.Io.Reader.Error!DecodeResult { + // Decode various fields. We need to be very defensive here + // because it can come from an untrusted source and may be invalid. + const content_kind_optional = kind: { + const raw = try reader.takeByte(); + break :kind std.enums.fromInt(Kind, raw); + }; + const width = width: { + const raw = try reader.takeByte(); + break :width std.enums.fromInt(TerminalCell.Wide, raw) orelse .narrow; + }; - const flags_raw = try reader.takeByte(); + const flags: Flags = @bitCast(try reader.takeByte()); - // As with the row header, validate the exhaustive native enum before - // bitcasting the complete packed byte. - const semantic_content_raw: u2 = @truncate(flags_raw >> 1); - _ = std.enums.fromInt( - TerminalCell.SemanticContent, - semantic_content_raw, - ) orelse return error.InvalidCell; - - const flags: Flags = @bitCast(flags_raw); - if (flags._padding != 0) return error.InvalidCell; + // We can't trust `flags` to have a valid semantic content so + // we need extract the bits and do a safe conversion. + const semantic_content = semantic: { + const flags_raw: u8 = @bitCast(flags); + const raw: u2 = @truncate(flags_raw >> @bitOffsetOf(Flags, "semantic_content")); + break :semantic std.enums.fromInt( + TerminalCell.SemanticContent, + raw, + ) orelse .output; + }; // This byte is reserved so the IDs and content value remain naturally - // aligned within the fixed cell header. - if (try reader.takeByte() != 0) return error.InvalidCell; + // aligned within the fixed cell header. Ignore it so future versions + // can give it meaning without making old decoders reject the cell. + _ = try reader.takeByte(); - return .{ + const style_id = try io.readInt(reader, TerminalStyleId); + const hyperlink_id = try io.readInt(reader, TerminalHyperlinkId); + const value: Value = @bitCast(try io.readInt(reader, u32)); + const grapheme_count = try io.readInt(reader, u32); + + // If we don't have a valid content kind, we return invalid and + // note the suffix bytes so that the reader can discard + const content_kind = content_kind_optional orelse + return .{ .invalid = grapheme_count }; + + return .{ .valid = .{ .content_kind = content_kind, .width = width, .protected = flags.protected, - .semantic_content = flags.semantic_content, - .style_id = try io.readInt(reader, TerminalStyleId), - .hyperlink_id = try io.readInt(reader, TerminalHyperlinkId), - .value = @bitCast(try io.readInt(reader, u32)), - .grapheme_count = try io.readInt(reader, u32), - }; + .semantic_content = semantic_content, + .style_id = style_id, + .hyperlink_id = hyperlink_id, + .value = value, + .grapheme_count = grapheme_count, + } }; } /// Computes the fixed header size using the encoder itself. @@ -707,3 +745,60 @@ test "grid golden encoding and decoding" { rewriter.buffered(), ); } + +test "grid normalizes incomplete wide cells" { + const testing = std.testing; + // One column puts the wide cell at the row end. Two columns put an + // ordinary narrow cell after it. Neither case supplies a spacer tail. + for ([_]u16{ 1, 2 }) |columns| { + const capacity: terminal_page.Capacity = .{ + .cols = columns, + .rows = 1, + }; + + // Craft the malformed row directly on the wire. Decoding keeps its + // content but makes the incomplete wide cell narrow. + var payload: [64]u8 = undefined; + var payload_writer: std.Io.Writer = .fixed(&payload); + try payload_writer.writeByte(@bitCast(RowHeader{})); + try (CellHeader{ + .width = .wide, + .value = .{ .codepoint = 'A' }, + }).encode(&payload_writer); + if (capacity.cols > 1) try (CellHeader{ + .value = .{ .codepoint = 'B' }, + }).encode(&payload_writer); + + var destination = try TerminalPage.init(capacity); + defer destination.deinit(); + var style_remap = StyleRemap.init(testing.allocator); + defer style_remap.deinit(); + var hyperlink_remap = HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(); + + var payload_reader: std.Io.Reader = .fixed( + payload_writer.buffered(), + ); + try decode( + &destination, + &payload_reader, + &style_remap, + &hyperlink_remap, + ); + try destination.verifyIntegrity(testing.allocator); + + try testing.expectEqual( + @as(u21, 'A'), + destination.getRowAndCell(0, 0).cell.codepoint(), + ); + try testing.expectEqual( + TerminalCell.Wide.narrow, + destination.getRowAndCell(0, 0).cell.wide, + ); + if (columns > 1) { + const second = destination.getRowAndCell(1, 0).cell; + try testing.expectEqual(@as(u21, 'B'), second.codepoint()); + try testing.expectEqual(TerminalCell.Wide.narrow, second.wide); + } + } +} diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 05ad3c03a..5d7583f59 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -120,7 +120,6 @@ const TerminalStyleSet = terminal_style.Set; const PayloadEncodeError = hyperlink.EncodeError || grid.EncodeError; const PayloadDecodeError = style.DecodeError || - grid.DecodeError || Header.CapacityError || error{ /// The hyperlink kind is not defined by snapshot version 1. @@ -829,6 +828,35 @@ test "framed PAGE golden empty record" { ); } +test "framed PAGE rejects incomplete wide cells transactionally" { + const testing = std.testing; + + // One column puts the wide cell at the row end. Two columns put an + // ordinary narrow cell after it. Neither case supplies a spacer tail. + for ([_]u16{ 1, 2 }) |columns| { + var page = try TerminalPage.init(.{ + .cols = columns, + .rows = 1, + }); + defer page.deinit(); + const first = page.getRowAndCell(0, 0).cell; + first.* = .init('A'); + first.wide = .wide; + if (columns > 1) { + page.getRowAndCell(1, 0).cell.* = .init('B'); + } + + var destination: std.Io.Writer.Allocating = .init(testing.allocator); + defer destination.deinit(); + try destination.writer.writeAll("prefix"); + try testing.expectError( + error.InvalidWideCell, + encode(&page, &destination), + ); + try testing.expectEqualStrings("prefix", destination.written()); + } +} + test "framed PAGE rejects a different record tag" { var wrong_tag = test_empty_framed_page_fixture; std.mem.writeInt( @@ -1044,9 +1072,9 @@ test "decode defaults missing sparse cell references" { ); } -test "decode rejects undefined row and cell values" { +test "decode normalizes invalid grid semantics" { const header: Header = .{ - .columns = 1, + .columns = 3, .rows = 1, .style_count = 0, .hyperlink_count = 0, @@ -1056,62 +1084,79 @@ test "decode rejects undefined row and cell values" { .string_capacity_bytes = 0, }; - var invalid_row: [Header.len + 1]u8 = undefined; - var row_writer: std.Io.Writer = .fixed(&invalid_row); - try header.encode(&row_writer); - try row_writer.writeByte(0x10); + var encoded: [Header.len + 1 + 3 * 16 + 12]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try header.encode(&writer); - var row_reader: std.Io.Reader = .fixed(row_writer.buffered()); - try std.testing.expectError( - error.InvalidRow, - decodePayload(&row_reader, std.testing.allocator), + // Preserve wrap while degrading the unknown semantic-prompt value to none + // and ignoring all reserved row bits. + try writer.writeByte(0xFD); + + // An unknown content and width kind, invalid semantic content, reserved + // bytes, and suffix data all degrade to a blank narrow output cell. + try writer.writeAll(&.{ 3, 4, 0xFF, 0xFF }); + try io.writeInt(&writer, TerminalStyleId, 0); + try io.writeInt(&writer, TerminalHyperlinkId, 0); + try io.writeInt(&writer, u32, 0xD800); + try io.writeInt(&writer, u32, 1); + try io.writeInt(&writer, u32, 0x110000); + + // A recognized text cell replaces an invalid Unicode scalar with U+FFFD. + // Its valid suffix cannot fit the advertised zero capacity, so decoding + // preserves the base character and drops the optional suffix. + try writer.writeAll(&.{ 0, 0, 0, 0 }); + try io.writeInt(&writer, TerminalStyleId, 0); + try io.writeInt(&writer, TerminalHyperlinkId, 0); + try io.writeInt(&writer, u32, 0xD800); + try io.writeInt(&writer, u32, 1); + try io.writeInt(&writer, u32, 'x'); + + // Reserved palette bits and a nonsensical suffix do not obscure the valid + // low palette byte; the suffix is consumed and ignored. + try writer.writeAll(&.{ 1, 0, 0, 0xFF }); + try io.writeInt(&writer, TerminalStyleId, 0); + try io.writeInt(&writer, TerminalHyperlinkId, 0); + try io.writeInt(&writer, u32, 0xFFFFFF07); + try io.writeInt(&writer, u32, 1); + try io.writeInt(&writer, u32, 'x'); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + var page = try decodePayload(&reader, std.testing.allocator); + defer page.deinit(); + try page.verifyIntegrity(std.testing.allocator); + + const first = page.getRowAndCell(0, 0); + try std.testing.expect(first.row.wrap); + try std.testing.expectEqual( + terminal_page.Row.SemanticPrompt.none, + first.row.semantic_prompt, ); - - var invalid_cell: [Header.len + 2]u8 = undefined; - var cell_writer: std.Io.Writer = .fixed(&invalid_cell); - try header.encode(&cell_writer); - try cell_writer.writeByte(0); - try cell_writer.writeByte(3); - - var cell_reader: std.Io.Reader = .fixed(cell_writer.buffered()); - try std.testing.expectError( - error.InvalidCell, - decodePayload(&cell_reader, std.testing.allocator), + try std.testing.expectEqual(@as(u21, 0), first.cell.codepoint()); + try std.testing.expectEqual( + terminal_page.Cell.Wide.narrow, + first.cell.wide, ); - - var invalid_codepoint: [Header.len + 1 + 16]u8 = undefined; - var codepoint_writer: std.Io.Writer = .fixed(&invalid_codepoint); - try header.encode(&codepoint_writer); - try codepoint_writer.writeByte(0); - try codepoint_writer.writeAll(&.{ 0, 0, 0, 0 }); - try io.writeInt(&codepoint_writer, TerminalStyleId, 0); - try io.writeInt(&codepoint_writer, TerminalHyperlinkId, 0); - try io.writeInt(&codepoint_writer, u32, 0xD800); - try io.writeInt(&codepoint_writer, u32, 0); - - var codepoint_reader: std.Io.Reader = .fixed( - codepoint_writer.buffered(), - ); - try std.testing.expectError( - error.InvalidCodepoint, - decodePayload(&codepoint_reader, std.testing.allocator), + try std.testing.expectEqual( + terminal_page.Cell.SemanticContent.output, + first.cell.semantic_content, ); + try std.testing.expect(!first.cell.protected); + try std.testing.expect(!first.cell.hasGrapheme()); - var invalid_color: [Header.len + 1 + 16]u8 = undefined; - var color_writer: std.Io.Writer = .fixed(&invalid_color); - try header.encode(&color_writer); - try color_writer.writeByte(0); - try color_writer.writeAll(&.{ 1, 0, 0, 0 }); - try io.writeInt(&color_writer, TerminalStyleId, 0); - try io.writeInt(&color_writer, TerminalHyperlinkId, 0); - try io.writeInt(&color_writer, u32, 0x100); - try io.writeInt(&color_writer, u32, 0); + const second = page.getRowAndCell(1, 0).cell; + try std.testing.expectEqual(@as(u21, 0xFFFD), second.codepoint()); + try std.testing.expect(!second.hasGrapheme()); - var color_reader: std.Io.Reader = .fixed(color_writer.buffered()); - try std.testing.expectError( - error.InvalidColor, - decodePayload(&color_reader, std.testing.allocator), + const third = page.getRowAndCell(2, 0).cell; + try std.testing.expectEqual( + terminal_page.Cell.ContentTag.bg_color_palette, + third.content_tag, ); + try std.testing.expectEqual( + @as(u8, 7), + third.content.color_palette.data, + ); + try std.testing.expect(!third.hasGrapheme()); } test "decode validates dimensions and native table capacities" {