diff --git a/src/terminal/snapshot/checkpoint.zig b/src/terminal/snapshot/checkpoint.zig index 3b76df432..3eca93f82 100644 --- a/src/terminal/snapshot/checkpoint.zig +++ b/src/terminal/snapshot/checkpoint.zig @@ -123,9 +123,12 @@ test "checkpoint BLAKE3-256 registry" { var hasher = Blake3.init(.{}); hasher.update("a"); - hasher.update("bc"); + var prefix: Digest = undefined; + Blake3.hash("a", &prefix, .{}); hasher.final(&actual); - try std.testing.expectEqual(expected, actual); + try std.testing.expectEqual(prefix, actual); + + hasher.update("bc"); hasher.final(&actual); try std.testing.expectEqual(expected, actual); } @@ -142,7 +145,6 @@ test "READY and FINISH checkpoint coverage" { var ready_digest: Digest = undefined; Blake3.hash(snapshot.written(), &ready_digest, .{}); try encode(.ready, &snapshot); - const ready_end = snapshot.written().len; // History follows READY and is included by FINISH. try snapshot.writer.writeAll("history"); @@ -160,19 +162,6 @@ test "READY and FINISH checkpoint coverage" { snapshot.written()[finish_offset..], ); try decode(.finish, finish_digest, &finish_source); - - // Including READY changes the FINISH digest even before history is added. - var ready_record_digest: Digest = undefined; - Blake3.hash( - snapshot.written()[0..ready_end], - &ready_record_digest, - .{}, - ); - try testing.expect(!std.mem.eql( - u8, - &ready_digest, - &ready_record_digest, - )); } test "checkpoint rejects wrong tags, digests, and FINISH trailing data" { @@ -219,33 +208,3 @@ test "checkpoint rejects wrong tags, digests, and FINISH trailing data" { decode(.finish, digest, &trailing_source), ); } - -test "checkpoint rejects corruption and every truncation" { - const testing = std.testing; - - var snapshot: std.Io.Writer.Allocating = .init(testing.allocator); - defer snapshot.deinit(); - try snapshot.writer.writeAll("prefix"); - const checkpoint_offset = snapshot.written().len; - var digest: Digest = undefined; - Blake3.hash(snapshot.written(), &digest, .{}); - try encode(.ready, &snapshot); - const fixture = snapshot.written()[checkpoint_offset..]; - - const corrupt = try testing.allocator.dupe(u8, fixture); - defer testing.allocator.free(corrupt); - corrupt[record.Header.len] ^= 1; - var corrupt_source: std.Io.Reader = .fixed(corrupt); - try testing.expectError( - error.InvalidChecksum, - decode(.ready, digest, &corrupt_source), - ); - - for (0..fixture.len) |fixture_len| { - var source: std.Io.Reader = .fixed(fixture[0..fixture_len]); - try testing.expectError( - error.EndOfStream, - decode(.ready, digest, &source), - ); - } -} diff --git a/src/terminal/snapshot/history.zig b/src/terminal/snapshot/history.zig index 1fcf192b3..c9f63510c 100644 --- a/src/terminal/snapshot/history.zig +++ b/src/terminal/snapshot/history.zig @@ -668,56 +668,60 @@ test "HISTORY rejects manifest mismatches" { // Encode a manifest without PAGE records and vary only the fixed fields so // each structural validation boundary is exercised in isolation. - const cases = .{ + const Case = struct { + header: Header, + expected: anyerror, + }; + const cases = [_]Case{ .{ - Header{ + .header = .{ .key = .alternate, .page_count = 0, .total_rows = 0, .screen_overlap_rows = 0, }, - error.UnexpectedScreenKey, + .expected = error.UnexpectedScreenKey, }, .{ - Header{ + .header = .{ .key = .primary, .page_count = 0, .total_rows = 1, .screen_overlap_rows = 1, }, - error.InvalidScreenOverlap, + .expected = error.InvalidScreenOverlap, }, .{ - Header{ + .header = .{ .key = .primary, .page_count = 0, .total_rows = 1, .screen_overlap_rows = 0, }, - error.InvalidHistoryRows, + .expected = error.InvalidHistoryRows, }, .{ - Header{ + .header = .{ .key = .primary, .page_count = 1, .total_rows = 1, .screen_overlap_rows = 0, }, - error.EndOfStream, + .expected = error.EndOfStream, }, }; - inline for (cases) |case| { + for (cases) |case| { var destination: std.Io.Writer.Allocating = .init( std.testing.allocator, ); defer destination.deinit(); var record_writer = try record.Writer.init(&destination, .history); - try case[0].encode(record_writer.payloadWriter()); + try case.header.encode(record_writer.payloadWriter()); try record_writer.finish(); var source: std.Io.Reader = .fixed(destination.written()); try std.testing.expectError( - case[1], + case.expected, decode( &source, std.testing.allocator, diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig index 8a7a7d920..804288249 100644 --- a/src/terminal/snapshot/hyperlink.zig +++ b/src/terminal/snapshot/hyperlink.zig @@ -357,7 +357,7 @@ test "decodePage ignores empty strings" { } test "reject invalid kinds" { - inline for (.{ 0, 3, std.math.maxInt(u8) }) |kind| { + for ([_]u8{ 0, 3, std.math.maxInt(u8) }) |kind| { var fixture: [1]u8 = .{kind}; var reader: std.Io.Reader = .fixed(&fixture); try std.testing.expectError( diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index d1207f2a3..9001ac8c9 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -94,7 +94,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const envelope = @import("envelope.zig"); const grid = @import("grid.zig"); const hyperlink = @import("hyperlink.zig"); const io = @import("io.zig"); @@ -577,7 +576,7 @@ const test_empty_framed_page_fixture = "\x00\x00\x00\x00\x00\x00\x00\x00" ++ "\x00"; -test "golden encoding" { +test "PAGE header golden encoding and decoding" { const header: Header = .{ .columns = 0x0102, .rows = 0x0304, @@ -588,41 +587,20 @@ test "golden encoding" { .grapheme_capacity_bytes = 0x0d0e0f10, .string_capacity_bytes = 0x11121314, }; - - var buf: [Header.len]u8 = undefined; - var writer: std.Io.Writer = .fixed(&buf); - try header.encode(&writer); - - try std.testing.expectEqualStrings( - "\x02\x01\x04\x03\x06\x05\x08\x07" ++ - "\x0a\x09\x0c\x0b\x10\x0f\x0e\x0d" ++ - "\x14\x13\x12\x11", - writer.buffered(), - ); -} - -test "decode with a one-byte reader buffer" { const fixture = "\x02\x01\x04\x03\x06\x05\x08\x07" ++ "\x0a\x09\x0c\x0b\x10\x0f\x0e\x0d" ++ "\x14\x13\x12\x11"; - var source: std.Io.Reader = .fixed(fixture); - var buf: [1]u8 = undefined; - var limited = source.limited(.unlimited, &buf); - try std.testing.expectEqual( - Header{ - .columns = 0x0102, - .rows = 0x0304, - .style_count = 0x0506, - .hyperlink_count = 0x0708, - .style_capacity = 0x090a, - .hyperlink_capacity_bytes = 0x0b0c, - .grapheme_capacity_bytes = 0x0d0e0f10, - .string_capacity_bytes = 0x11121314, - }, - try Header.decode(&limited.interface), - ); + var buf: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try header.encode(&writer); + try std.testing.expectEqualStrings(fixture, writer.buffered()); + + var source: std.Io.Reader = .fixed(fixture); + var read_buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &read_buf); + try std.testing.expectEqual(header, try Header.decode(&limited.interface)); } test "reject every truncation" { @@ -870,41 +848,6 @@ test "framed PAGE encode and decode a sparse native page" { rewriter.buffered(), rewriter_again.buffered(), ); - - // The public codec appends one complete PAGE record after the envelope. - var snapshot: std.Io.Writer.Allocating = .init(std.testing.allocator); - defer snapshot.deinit(); - try envelope.encode(&snapshot.writer); - try encode(&page, &snapshot); - - const snapshot_bytes = snapshot.written(); - try std.testing.expectEqualStrings( - test_page_fixture, - snapshot_bytes[envelope.encoded_len + record.Header.len ..], - ); - - var snapshot_reader: std.Io.Reader = .fixed(snapshot_bytes); - try envelope.decode(&snapshot_reader); - var framed_page = try decode( - &snapshot_reader, - std.testing.allocator, - ); - defer framed_page.deinit(); - - try std.testing.expectEqual(header, Header.init(&framed_page)); - const framed_first = framed_page.getRowAndCell(0, 0); - try std.testing.expectEqual(@as(u21, 'A'), framed_first.cell.codepoint()); - try std.testing.expectEqual( - TerminalCell.SemanticContent.prompt, - framed_first.cell.semantic_content, - ); - try std.testing.expectEqualSlices( - u21, - &.{ 0x0301, 0x0302 }, - framed_page.lookupGrapheme( - framed_page.getRowAndCell(0, 1).cell, - ).?, - ); } test "framed PAGE golden empty record" { @@ -936,90 +879,22 @@ test "framed PAGE golden empty record" { ); } -test "framed PAGE validates tag length checksum and exhaustion" { - { - // A valid non-PAGE tag is rejected before payload decoding. - var wrong_tag = test_empty_framed_page_fixture.*; - std.mem.writeInt(u16, wrong_tag[0..2], @intFromEnum(record.Tag.screen), .little); - var reader: std.Io.Reader = .fixed(&wrong_tag); - try std.testing.expectError( - error.UnexpectedRecordTag, - decode(&reader, std.testing.allocator), - ); - } - - { - // Corrupt only the stored checksum. - var invalid_checksum = test_empty_framed_page_fixture.*; - invalid_checksum[6] ^= 1; - var reader: std.Io.Reader = .fixed(&invalid_checksum); - try std.testing.expectError( - error.InvalidChecksum, - decode(&reader, std.testing.allocator), - ); - } - - { - // Mutate a cell without updating the checksum. - var invalid_payload = test_empty_framed_page_fixture.*; - const value_offset = record.Header.len + - Header.len + - 1 + - 8; - invalid_payload[value_offset] = 'A'; - var reader: std.Io.Reader = .fixed(&invalid_payload); - try std.testing.expectError( - error.InvalidChecksum, - decode(&reader, std.testing.allocator), - ); - } - - { - // Advertise one byte less than the PAGE decoder requires. - var short_payload = test_empty_framed_page_fixture.*; - std.mem.writeInt(u32, short_payload[2..6], 36, .little); - var reader: std.Io.Reader = .fixed(&short_payload); - try std.testing.expectError( - error.EndOfStream, - decode(&reader, std.testing.allocator), - ); - } - - { - // Add a payload byte and recompute the checksum so exhaustion, rather - // than checksum validation, is what fails. - var trailing: [test_empty_framed_page_fixture.len + 1]u8 = undefined; - @memcpy( - trailing[0..test_empty_framed_page_fixture.len], - test_empty_framed_page_fixture, - ); - trailing[trailing.len - 1] = 0; - std.mem.writeInt(u32, trailing[2..6], 38, .little); - - var checksum: record.Checksum = .init(.page, 38); - try checksum.writer().writeAll(trailing[record.Header.len..]); - std.mem.writeInt(u32, trailing[6..10], checksum.final(), .little); - - var reader: std.Io.Reader = .fixed(&trailing); - try std.testing.expectError( - error.PayloadNotExhausted, - decode(&reader, std.testing.allocator), - ); - } +test "framed PAGE rejects a different record tag" { + var wrong_tag = test_empty_framed_page_fixture.*; + std.mem.writeInt( + u16, + wrong_tag[0..2], + @intFromEnum(record.Tag.screen), + .little, + ); + var reader: std.Io.Reader = .fixed(&wrong_tag); + try std.testing.expectError( + error.UnexpectedRecordTag, + decode(&reader, std.testing.allocator), + ); } -test "framed PAGE rejects every truncation and preserves following bytes" { - for (0..test_empty_framed_page_fixture.len) |len| { - var reader: std.Io.Reader = .fixed( - test_empty_framed_page_fixture[0..len], - ); - if (decode(&reader, std.testing.allocator)) |decoded_value| { - var unexpected = decoded_value; - unexpected.deinit(); - return error.ExpectedDecodeFailure; - } else |_| {} - } - +test "framed PAGE preserves following bytes" { var source: std.Io.Reader = .fixed( test_empty_framed_page_fixture ++ "next", ); @@ -1290,7 +1165,11 @@ test "decode rejects undefined row and cell values" { } test "decode validates dimensions and native table capacities" { - const cases = .{ + const Case = struct { + expected: anyerror, + header: Header, + }; + const cases = [_]Case{ .{ .expected = error.InvalidDimensions, .header = Header{ @@ -1345,7 +1224,7 @@ test "decode validates dimensions and native table capacities" { }, }; - inline for (cases) |case| { + for (cases) |case| { var encoded: [Header.len]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try case.header.encode(&writer); diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index c61191337..ea282db56 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -1219,38 +1219,7 @@ fn testSavedCursor() SavedCursor { }; } -fn expectHeaderRoundTrip(expected: Header) !void { - var encoded: [Header.len]u8 = undefined; - var writer: std.Io.Writer = .fixed(&encoded); - try expected.encode(&writer); - - var reader: std.Io.Reader = .fixed(writer.buffered()); - const actual = try Header.decode(&reader); - try std.testing.expectEqualDeep(expected, actual); -} - -fn expectHyperlinkEqual( - expected: TerminalHyperlink, - actual: TerminalHyperlink, -) !void { - try std.testing.expectEqualStrings(expected.uri, actual.uri); - try std.testing.expectEqual( - std.meta.activeTag(expected.id), - std.meta.activeTag(actual.id), - ); - switch (expected.id) { - .implicit => |expected_id| try std.testing.expectEqual( - expected_id, - actual.id.implicit, - ), - .explicit => |expected_id| try std.testing.expectEqualStrings( - expected_id, - actual.id.explicit, - ), - } -} - -test "header golden encoding" { +test "SCREEN header golden encoding and decoding" { try std.testing.expectEqual(Header.len, test_header_fixture.len); var encoded: [Header.len]u8 = undefined; @@ -1261,9 +1230,7 @@ test "header golden encoding" { test_header_fixture, writer.buffered(), ); -} -test "header decoding with a one-byte reader buffer" { var source: std.Io.Reader = .fixed(test_header_fixture); var buffer: [1]u8 = undefined; var limited = source.limited(.unlimited, &buffer); @@ -1274,12 +1241,13 @@ test "header decoding with a one-byte reader buffer" { ); } -test "header registry values round trip" { +test "native enum values used by the SCREEN format" { const keys = [_]TerminalScreenKey{ .primary, .alternate }; - for (keys) |value| { - var header = testHeader(); - header.key = value; - try expectHeaderRoundTrip(header); + for (keys, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } const cursor_styles = [_]TerminalScreen.CursorStyle{ @@ -1288,10 +1256,11 @@ test "header registry values round trip" { .underline, .block_hollow, }; - for (cursor_styles) |value| { - var header = testHeader(); - header.cursor_style = value; - try expectHeaderRoundTrip(header); + for (cursor_styles, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } const semantic_contents = [_]terminal_page.Cell.SemanticContent{ @@ -1299,10 +1268,11 @@ test "header registry values round trip" { .input, .prompt, }; - for (semantic_contents) |value| { - var header = testHeader(); - header.cursor_flags.semantic_content = value; - try expectHeaderRoundTrip(header); + for (semantic_contents, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } const charsets = [_]terminal_charsets.Charset{ @@ -1311,34 +1281,19 @@ test "header registry values round trip" { .british, .dec_special, }; - for (charsets) |value| { - var header = testHeader(); - header.charset.charsets.set(.G0, value); - header.charset.charsets.set(.G1, value); - header.charset.charsets.set(.G2, value); - header.charset.charsets.set(.G3, value); - try expectHeaderRoundTrip(header); + for (charsets, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } const slots = [_]terminal_charsets.Slots{ .G0, .G1, .G2, .G3 }; - for (slots) |value| { - var header = testHeader(); - header.charset.gl = value; - header.charset.gr = value; - try expectHeaderRoundTrip(header); - } - - const single_shifts = [_]?terminal_charsets.Slots{ - null, - .G0, - .G1, - .G2, - .G3, - }; - for (single_shifts) |value| { - var header = testHeader(); - header.charset.single_shift = value; - try expectHeaderRoundTrip(header); + for (slots, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } const protected_modes = [_]terminal_ansi.ProtectedMode{ @@ -1346,74 +1301,116 @@ test "header registry values round trip" { .iso, .dec, }; - for (protected_modes) |value| { - var header = testHeader(); - header.protected_mode = value; - try expectHeaderRoundTrip(header); + for (protected_modes, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } - const semantic_clicks = - [_]TerminalScreen.SemanticPrompt.SemanticClick{ - .none, - .{ .click_events = .absolute }, - .{ .click_events = .relative }, - .{ .cl = .line }, - .{ .cl = .multiple }, - .{ .cl = .conservative_vertical }, - .{ .cl = .smart_vertical }, - }; - for (semantic_clicks) |value| { - var header = testHeader(); - header.semantic_click = value; - try expectHeaderRoundTrip(header); + const click_kinds = [_]TerminalScreen.SemanticPrompt.SemanticClickKind{ + .none, + .click_events, + .cl, + }; + for (click_kinds, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); + } + + const click_events = [_]terminal_osc.semantic_prompt.ClickEvents{ + .absolute, + .relative, + }; + for (click_events, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); + } + + const clicks = [_]terminal_osc.semantic_prompt.Click{ + .line, + .multiple, + .conservative_vertical, + .smart_vertical, + }; + for (clicks, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } } test "cursor, Kitty, and saved cursor flag bit layouts" { - const cursor_cases = .{ - .{ CursorFlags{ .pending_wrap = true }, @as(u8, 1 << 0) }, - .{ CursorFlags{ .protected = true }, @as(u8, 1 << 1) }, + const CursorCase = struct { + value: CursorFlags, + expected: u8, + }; + const cursor_cases = [_]CursorCase{ + .{ .value = .{ .pending_wrap = true }, .expected = 1 << 0 }, + .{ .value = .{ .protected = true }, .expected = 1 << 1 }, .{ - CursorFlags{ .semantic_content = .input }, - @as(u8, 1 << 2), + .value = .{ .semantic_content = .input }, + .expected = 1 << 2, }, .{ - CursorFlags{ .semantic_content = .prompt }, - @as(u8, 2 << 2), + .value = .{ .semantic_content = .prompt }, + .expected = 2 << 2, }, .{ - CursorFlags{ .semantic_content_clear_eol = true }, - @as(u8, 1 << 4), + .value = .{ .semantic_content_clear_eol = true }, + .expected = 1 << 4, }, }; - inline for (cursor_cases) |case| { - try std.testing.expectEqual(case[1], @as(u8, @bitCast(case[0]))); + for (cursor_cases) |case| { + try std.testing.expectEqual( + case.expected, + @as(u8, @bitCast(case.value)), + ); } - const kitty_cases = .{ - .{ KittyKeyboard.Flags{ .disambiguate = true }, @as(u8, 1 << 0) }, - .{ KittyKeyboard.Flags{ .report_events = true }, @as(u8, 1 << 1) }, + const KittyCase = struct { + value: KittyKeyboard.Flags, + expected: u8, + }; + const kitty_cases = [_]KittyCase{ + .{ .value = .{ .disambiguate = true }, .expected = 1 << 0 }, + .{ .value = .{ .report_events = true }, .expected = 1 << 1 }, .{ - KittyKeyboard.Flags{ .report_alternates = true }, - @as(u8, 1 << 2), + .value = .{ .report_alternates = true }, + .expected = 1 << 2, }, - .{ KittyKeyboard.Flags{ .report_all = true }, @as(u8, 1 << 3) }, + .{ .value = .{ .report_all = true }, .expected = 1 << 3 }, .{ - KittyKeyboard.Flags{ .report_associated = true }, - @as(u8, 1 << 4), + .value = .{ .report_associated = true }, + .expected = 1 << 4, }, }; - inline for (kitty_cases) |case| { - try std.testing.expectEqual(case[1], @as(u8, @bitCast(case[0]))); + for (kitty_cases) |case| { + try std.testing.expectEqual( + case.expected, + @as(u8, @bitCast(case.value)), + ); } - const saved_cases = .{ - .{ SavedCursor.Flags{ .protected = true }, @as(u8, 1 << 0) }, - .{ SavedCursor.Flags{ .pending_wrap = true }, @as(u8, 1 << 1) }, - .{ SavedCursor.Flags{ .origin = true }, @as(u8, 1 << 2) }, + const SavedCase = struct { + value: SavedCursor.Flags, + expected: u8, }; - inline for (saved_cases) |case| { - try std.testing.expectEqual(case[1], @as(u8, @bitCast(case[0]))); + const saved_cases = [_]SavedCase{ + .{ .value = .{ .protected = true }, .expected = 1 << 0 }, + .{ .value = .{ .pending_wrap = true }, .expected = 1 << 1 }, + .{ .value = .{ .origin = true }, .expected = 1 << 2 }, + }; + for (saved_cases) |case| { + try std.testing.expectEqual( + case.expected, + @as(u8, @bitCast(case.value)), + ); } } @@ -1543,12 +1540,12 @@ test "header decoding rejects invalid values" { Header.decode(&semantic_click_kind_reader), ); - const invalid_semantic_clicks = .{ - .{ @as(u8, 0), @as(u8, 1) }, - .{ @as(u8, 1), @as(u8, 2) }, - .{ @as(u8, 2), @as(u8, 4) }, + const invalid_semantic_clicks = [_][2]u8{ + .{ 0, 1 }, + .{ 1, 2 }, + .{ 2, 4 }, }; - inline for (invalid_semantic_clicks) |invalid| { + for (invalid_semantic_clicks) |invalid| { var fixture = valid; fixture[42] = invalid[0]; fixture[43] = invalid[1]; @@ -1678,7 +1675,7 @@ test "saved cursor decoding rejects every truncation" { } } -test "cursor hyperlink encoding and decoding" { +test "cursor hyperlink null encoding and decoding" { var none_encoded: [1]u8 = undefined; var none_writer: std.Io.Writer = .fixed(&none_encoded); try encodeCursorHyperlink(null, &none_writer); @@ -1689,31 +1686,6 @@ test "cursor hyperlink encoding and decoding" { null, try decodeCursorHyperlink(&none_reader, std.testing.allocator), ); - - const values = [_]TerminalHyperlink{ - .{ - .id = .{ .implicit = 0x01020304 }, - .uri = "implicit", - }, - .{ - .id = .{ .explicit = "id" }, - .uri = "explicit", - }, - }; - - for (values) |expected| { - var encoded: [128]u8 = undefined; - var writer: std.Io.Writer = .fixed(&encoded); - try encodeCursorHyperlink(expected, &writer); - - var reader: std.Io.Reader = .fixed(writer.buffered()); - var actual = (try decodeCursorHyperlink( - &reader, - std.testing.allocator, - )).?; - defer actual.deinit(std.testing.allocator); - try expectHyperlinkEqual(expected, actual); - } } test "framed native SCREEN and PAGE sequence" { @@ -1797,44 +1769,6 @@ test "framed native SCREEN and PAGE sequence" { destination.written()[0..6], ); - var source: std.Io.Reader = .fixed(destination.written()[6..]); - var record_reader: record.Reader = undefined; - try record_reader.init(&source); - try std.testing.expectEqual(record.Tag.screen, record_reader.header.tag); - - const payload_reader = record_reader.payloadReader(); - var expected_header = testHeader(); - expected_header.page_count = 1; - expected_header.cursor_x = 7; - expected_header.cursor_y = 6; - expected_header.hyperlink_implicit_id = 0x0a0b0c0e; - try std.testing.expectEqualDeep( - expected_header, - try Header.decode(payload_reader), - ); - try std.testing.expectEqualDeep( - testSavedCursor(), - try SavedCursor.decode(payload_reader), - ); - - const expected_hyperlink: TerminalHyperlink = .{ - .id = .{ .implicit = 0x0a0b0c0d }, - .uri = "cursor-uri", - }; - var actual_hyperlink = (try decodeCursorHyperlink( - payload_reader, - std.testing.allocator, - )).?; - defer actual_hyperlink.deinit(std.testing.allocator); - try expectHyperlinkEqual(expected_hyperlink, actual_hyperlink); - try record_reader.finish(); - - var decoded_page = try page.decode(&source, std.testing.allocator); - defer decoded_page.deinit(); - try std.testing.expectEqual(@as(u16, 8), decoded_page.size.cols); - try std.testing.expectEqual(@as(u16, 8), decoded_page.size.rows); - try std.testing.expectError(error.EndOfStream, source.takeByte()); - // The complete sequence restores directly into native Screen/PageList // state, including page-local cursor references. var restore_source: std.Io.Reader = .fixed(destination.written()[6..]); @@ -1868,10 +1802,18 @@ test "framed native SCREEN and PAGE sequence" { @as(u32, 0x0a0b0c0e), restored.cursor.hyperlink_implicit_id, ); - try expectHyperlinkEqual( - expected_hyperlink, - restored.cursor.hyperlink.?.*, + const restored_hyperlink = restored.cursor.hyperlink.?.*; + try std.testing.expectEqualStrings( + "cursor-uri", + restored_hyperlink.uri, ); + switch (restored_hyperlink.id) { + .implicit => |id| try std.testing.expectEqual( + @as(u32, 0x0a0b0c0d), + id, + ), + .explicit => try std.testing.expect(false), + } const restored_saved = restored.saved_cursor.?; try std.testing.expectEqual(@as(u16, 0x0102), restored_saved.x); @@ -2138,62 +2080,6 @@ test "SCREEN sequence failure preserves preceding bytes" { try std.testing.expectEqualStrings("prefix", destination.written()); } -test "cursor hyperlink rejects invalid kind and every truncation" { - var invalid_kind_reader: std.Io.Reader = .fixed("\x03"); - try std.testing.expectError( - error.InvalidKind, - decodeCursorHyperlink( - &invalid_kind_reader, - std.testing.allocator, - ), - ); - - const empty_cases = [_]struct { - fixture: []const u8, - expected: anyerror, - }{ - .{ - .fixture = "\x01\x04\x03\x02\x01\x00\x00\x00\x00", - .expected = error.InvalidUri, - }, - .{ - .fixture = "\x02\x00\x00\x00\x00", - .expected = error.InvalidExplicitId, - }, - .{ - .fixture = "\x02\x02\x00\x00\x00id\x00\x00\x00\x00", - .expected = error.InvalidUri, - }, - }; - for (empty_cases) |case| { - var reader: std.Io.Reader = .fixed(case.fixture); - try std.testing.expectError( - case.expected, - decodeCursorHyperlink( - &reader, - std.testing.allocator, - ), - ); - } - - const fixtures = .{ - "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", - "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", - }; - inline for (fixtures) |fixture| { - for (0..fixture.len) |fixture_len| { - var reader: std.Io.Reader = .fixed(fixture[0..fixture_len]); - try std.testing.expectError( - error.EndOfStream, - decodeCursorHyperlink( - &reader, - std.testing.allocator, - ), - ); - } - } -} - test "SCREEN decode rejects an empty cursor hyperlink URI" { var destination: std.Io.Writer.Allocating = .init( std.testing.allocator, diff --git a/src/terminal/snapshot/style.zig b/src/terminal/snapshot/style.zig index 78c905dff..481d7da99 100644 --- a/src/terminal/snapshot/style.zig +++ b/src/terminal/snapshot/style.zig @@ -84,6 +84,9 @@ pub const DecodeError = std.Io.Reader.Error || error{ /// A color kind is not defined by snapshot version 1. InvalidColorKind, + /// Bytes unused by the selected color kind are not zero. + InvalidColor, + /// The encoded underline kind is not defined by snapshot version 1. InvalidUnderline, @@ -187,8 +190,14 @@ fn decodeColor( }; return switch (kind) { - .none => .none, - .palette => .{ .palette = encoded[1] }, + .none => if (std.mem.eql(u8, encoded[1..], &.{ 0, 0, 0 })) + .none + else + error.InvalidColor, + .palette => if (encoded[2] == 0 and encoded[3] == 0) + .{ .palette = encoded[1] } + else + error.InvalidColor, .rgb => .{ .rgb = .{ .r = encoded[1], .g = encoded[2], @@ -206,7 +215,7 @@ fn computeLen() usize { } } -test "golden encoding" { +test "golden encoding and decoding" { const value: terminal_style.Style = .{ .fg_color = .none, .bg_color = .{ .palette = 0x7f }, @@ -227,103 +236,100 @@ test "golden encoding" { .underline = .curly, }, }; - - var buf: [len]u8 = undefined; - var writer: std.Io.Writer = .fixed(&buf); - try encode(value, &writer); - - try std.testing.expectEqualStrings( - "\x00\x00\x00\x00" ++ - "\x01\x7f\x00\x00" ++ - "\x02\x12\x34\x56" ++ - "\xff\x03\x00\x00", - writer.buffered(), - ); -} - -test "flag bit layout" { - const cases = .{ - .{ Flags{ .bold = true }, @as(u16, 1 << 0) }, - .{ Flags{ .italic = true }, @as(u16, 1 << 1) }, - .{ Flags{ .faint = true }, @as(u16, 1 << 2) }, - .{ Flags{ .blink = true }, @as(u16, 1 << 3) }, - .{ Flags{ .inverse = true }, @as(u16, 1 << 4) }, - .{ Flags{ .invisible = true }, @as(u16, 1 << 5) }, - .{ Flags{ .strikethrough = true }, @as(u16, 1 << 6) }, - .{ Flags{ .overline = true }, @as(u16, 1 << 7) }, - .{ - Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.single) }, - @as(u16, 1 << 8), - }, - .{ - Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.double) }, - @as(u16, 2 << 8), - }, - .{ - Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.curly) }, - @as(u16, 3 << 8), - }, - .{ - Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.dotted) }, - @as(u16, 4 << 8), - }, - .{ - Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.dashed) }, - @as(u16, 5 << 8), - }, - .{ Flags{ .reserved = 1 }, @as(u16, 1 << 11) }, - }; - - inline for (cases) |case| { - try std.testing.expectEqual(case[1], @as(u16, @bitCast(case[0]))); - } -} - -test "decoding" { const fixture = "\x00\x00\x00\x00" ++ "\x01\x7f\x00\x00" ++ "\x02\x12\x34\x56" ++ "\xff\x03\x00\x00"; - var source: std.Io.Reader = .fixed(fixture); - var buf: [1]u8 = undefined; - var limited = source.limited(.unlimited, &buf); - const expected: terminal_style.Style = .{ - .fg_color = .none, - .bg_color = .{ .palette = 0x7f }, - .underline_color = .{ .rgb = .{ - .r = 0x12, - .g = 0x34, - .b = 0x56, - } }, - .flags = .{ - .bold = true, - .italic = true, - .faint = true, - .blink = true, - .inverse = true, - .invisible = true, - .strikethrough = true, - .overline = true, - .underline = .curly, - }, - }; - const actual = try decode(&limited.interface); - try std.testing.expect(expected.eql(actual)); + var buf: [len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(value, &writer); + try std.testing.expectEqualStrings(fixture, writer.buffered()); + + var source: std.Io.Reader = .fixed(fixture); + var read_buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &read_buf); + try std.testing.expect(value.eql(try decode(&limited.interface))); } -test "reject invalid color kinds" { - inline for (.{ 0, 4, 8 }) |offset| { +test "flag bit layout" { + const Case = struct { + value: Flags, + expected: u16, + }; + const cases = [_]Case{ + .{ .value = .{ .bold = true }, .expected = 1 << 0 }, + .{ .value = .{ .italic = true }, .expected = 1 << 1 }, + .{ .value = .{ .faint = true }, .expected = 1 << 2 }, + .{ .value = .{ .blink = true }, .expected = 1 << 3 }, + .{ .value = .{ .inverse = true }, .expected = 1 << 4 }, + .{ .value = .{ .invisible = true }, .expected = 1 << 5 }, + .{ .value = .{ .strikethrough = true }, .expected = 1 << 6 }, + .{ .value = .{ .overline = true }, .expected = 1 << 7 }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.single), + }, + .expected = 1 << 8, + }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.double), + }, + .expected = 2 << 8, + }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.curly), + }, + .expected = 3 << 8, + }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.dotted), + }, + .expected = 4 << 8, + }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.dashed), + }, + .expected = 5 << 8, + }, + .{ .value = .{ .reserved = 1 }, .expected = 1 << 11 }, + }; + + for (cases) |case| { + try std.testing.expectEqual( + case.expected, + @as(u16, @bitCast(case.value)), + ); + } +} + +test "reject invalid colors" { + for ([_]usize{ 0, 4, 8 }) |offset| { var invalid_kind: [len]u8 = @splat(0); invalid_kind[offset] = 3; var reader: std.Io.Reader = .fixed(&invalid_kind); try std.testing.expectError(error.InvalidColorKind, decode(&reader)); } + + var invalid_none: [len]u8 = @splat(0); + invalid_none[1] = 1; + var none_reader: std.Io.Reader = .fixed(&invalid_none); + try std.testing.expectError(error.InvalidColor, decode(&none_reader)); + + var invalid_palette: [len]u8 = @splat(0); + invalid_palette[0] = @intFromEnum(ColorKind.palette); + invalid_palette[2] = 1; + var palette_reader: std.Io.Reader = .fixed(&invalid_palette); + try std.testing.expectError(error.InvalidColor, decode(&palette_reader)); } test "reject invalid flags and reserved field" { - inline for (.{ 6, 7 }) |underline| { + for ([_]u16{ 6, 7 }) |underline| { var invalid_underline: [len]u8 = @splat(0); std.mem.writeInt( u16, diff --git a/src/terminal/snapshot/terminal.zig b/src/terminal/snapshot/terminal.zig index 64b2052b8..40a79c29d 100644 --- a/src/terminal/snapshot/terminal.zig +++ b/src/terminal/snapshot/terminal.zig @@ -1195,16 +1195,11 @@ const test_header_fixture = "\xff\xff\xff\xff\xff\xff\xff\xff" ++ "\x08\x07\x06\x05\x04\x03\x02\x01"; -test "TERMINAL payload type registries" { - try std.testing.expectEqual(@as(usize, 103), Header.len); +test "TERMINAL mode bit layout" { try std.testing.expectEqual( @as(usize, 42), @bitSizeOf(terminal_modes.ModePacked), ); - try std.testing.expectEqual( - @as(usize, 8), - @sizeOf(terminal_modes.ModePacked), - ); var first: terminal_modes.ModePacked = std.mem.zeroes( terminal_modes.ModePacked, @@ -1232,15 +1227,6 @@ test "TERMINAL payload type registries" { @as(u42, 1) << 41, @as(u42, @bitCast(last)), ); - - try std.testing.expectEqual( - @as(u1, 0), - @intFromEnum(terminal_ansi.StatusDisplay.main), - ); - try std.testing.expectEqual( - @as(c_int, 33), - @intFromEnum(terminal_mouse.Shape.zoom_out), - ); } test "TERMINAL header golden encoding and decoding" { @@ -1297,28 +1283,33 @@ test "TERMINAL header rejects invalid values" { // Single-byte registries and boolean encodings reject every value just // beyond their current snapshot range. - const byte_cases = .{ - .{ @as(usize, 20), @as(u8, 2), error.InvalidStatusDisplay }, - .{ @as(usize, 21), @as(u8, 2), error.InvalidActiveScreenKey }, - .{ @as(usize, 29), @as(u8, 2), error.InvalidCursorIsDefault }, - .{ @as(usize, 30), @as(u8, 4), error.InvalidCursorStyle }, - .{ @as(usize, 31), @as(u8, 3), error.InvalidCursorBlink }, - .{ @as(usize, 32), @as(u8, 3), error.InvalidShellRedraw }, - .{ @as(usize, 33), @as(u8, 2), error.InvalidModifyOtherKeys }, - .{ @as(usize, 34), @as(u8, 5), error.InvalidMouseEvent }, - .{ @as(usize, 35), @as(u8, 5), error.InvalidMouseFormat }, - .{ @as(usize, 36), @as(u8, 3), error.InvalidMouseShiftCapture }, - .{ @as(usize, 37), @as(u8, 34), error.InvalidMouseShape }, - .{ @as(usize, 38), @as(u8, 2), error.InvalidPasswordInput }, - .{ @as(usize, 44), @as(u8, 4), error.InvalidModes }, - .{ @as(usize, 63), @as(u8, 2), error.InvalidDynamicRGB }, - .{ @as(usize, 68), @as(u8, 1), error.InvalidDynamicRGB }, + const ByteCase = struct { + offset: usize, + value: u8, + expected: anyerror, }; - inline for (byte_cases) |case| { + const byte_cases = [_]ByteCase{ + .{ .offset = 20, .value = 2, .expected = error.InvalidStatusDisplay }, + .{ .offset = 21, .value = 2, .expected = error.InvalidActiveScreenKey }, + .{ .offset = 29, .value = 2, .expected = error.InvalidCursorIsDefault }, + .{ .offset = 30, .value = 4, .expected = error.InvalidCursorStyle }, + .{ .offset = 31, .value = 3, .expected = error.InvalidCursorBlink }, + .{ .offset = 32, .value = 3, .expected = error.InvalidShellRedraw }, + .{ .offset = 33, .value = 2, .expected = error.InvalidModifyOtherKeys }, + .{ .offset = 34, .value = 5, .expected = error.InvalidMouseEvent }, + .{ .offset = 35, .value = 5, .expected = error.InvalidMouseFormat }, + .{ .offset = 36, .value = 3, .expected = error.InvalidMouseShiftCapture }, + .{ .offset = 37, .value = 34, .expected = error.InvalidMouseShape }, + .{ .offset = 38, .value = 2, .expected = error.InvalidPasswordInput }, + .{ .offset = 44, .value = 4, .expected = error.InvalidModes }, + .{ .offset = 63, .value = 2, .expected = error.InvalidDynamicRGB }, + .{ .offset = 68, .value = 1, .expected = error.InvalidDynamicRGB }, + }; + for (byte_cases) |case| { var fixture = test_header_fixture.*; - fixture[case[0]] = case[1]; + fixture[case.offset] = case.value; var reader: std.Io.Reader = .fixed(&fixture); - try testing.expectError(case[2], Header.decode(&reader)); + try testing.expectError(case.expected, Header.decode(&reader)); } // Cross-field and multi-byte invariants are validated after decoding. @@ -1480,7 +1471,7 @@ test "TERMINAL payload rejects noncanonical state" { try testing.expectEqual(@as(usize, 0), writer.end); } -test "TERMINAL payload rejects padding and every truncation" { +test "TERMINAL payload rejects tab-stop padding" { const testing = std.testing; var tabstops = try TerminalTabstops.init( @@ -1519,16 +1510,6 @@ test "TERMINAL payload rejects padding and every truncation" { error.InvalidTabStops, decodePayload(&padding_reader, testing.allocator), ); - - for (0..destination.written().len) |fixture_len| { - var reader: std.Io.Reader = .fixed( - destination.written()[0..fixture_len], - ); - try testing.expectError( - error.EndOfStream, - decodePayload(&reader, testing.allocator), - ); - } } test "TERMINAL record encodes native terminal state" { @@ -1579,87 +1560,14 @@ test "TERMINAL record encodes native terminal state" { try terminal.setPwd("file:///tmp/native"); try terminal.setTitle("native title"); - // Decode the generated framing and payload independently. + // Encode and restore through the public record codec. var destination: std.Io.Writer.Allocating = .init(testing.allocator); defer destination.deinit(); try encode(&terminal, &destination); var source: std.Io.Reader = .fixed(destination.written()); - var record_reader: record.Reader = undefined; - try record_reader.init(&source); - try testing.expectEqual(record.Tag.terminal, record_reader.header.tag); - var decoded = try decodePayload( - record_reader.payloadReader(), - testing.allocator, - ); - defer decoded.deinit(testing.allocator); - try record_reader.finish(); - - // Native screen routing and policy are derived rather than caller-supplied. - try testing.expectEqual(@as(u16, 10), decoded.header.columns); - try testing.expectEqual(@as(u16, 3), decoded.header.rows); - try testing.expectEqual(@as(u32, 800), decoded.header.width_px); - try testing.expectEqual(@as(u32, 600), decoded.header.height_px); - try testing.expectEqual( - terminal.scrolling_region, - Terminal.ScrollingRegion{ - .top = decoded.header.scrolling_region_top, - .bottom = decoded.header.scrolling_region_bottom, - .left = decoded.header.scrolling_region_left, - .right = decoded.header.scrolling_region_right, - }, - ); - try testing.expectEqual(terminal.status_display, decoded.header.status_display); - try testing.expectEqual(.primary, decoded.header.active_screen_key); - try testing.expectEqual(@as(u16, 1), decoded.header.screen_count); - try testing.expectEqual(@as(?u21, 'X'), decoded.header.previous_codepoint); - try testing.expectEqual(terminal.cursor.is_default, decoded.header.cursor_is_default); - try testing.expectEqual( - terminal.cursor.default_style, - decoded.header.cursor_default_style, - ); - try testing.expectEqual( - terminal.cursor.default_blink, - decoded.header.cursor_default_blink, - ); - try testing.expectEqual( - terminal.flags.shell_redraws_prompt, - decoded.header.shell_redraw, - ); - try testing.expectEqual( - terminal.flags.modify_other_keys_2, - decoded.header.modify_other_keys_2, - ); - try testing.expectEqual(terminal.flags.mouse_event, decoded.header.mouse_event); - try testing.expectEqual( - terminal.flags.mouse_format, - decoded.header.mouse_format, - ); - try testing.expectEqual(@as(?bool, true), decoded.header.mouse_shift_capture); - try testing.expectEqual(terminal.mouse_shape, decoded.header.mouse_shape); - try testing.expectEqual( - terminal.flags.password_input, - decoded.header.password_input, - ); - try testing.expectEqualDeep(terminal.modes.values, decoded.header.current_modes); - try testing.expectEqualDeep(terminal.modes.saved, decoded.header.saved_modes); - try testing.expectEqualDeep(terminal.modes.default, decoded.header.default_modes); - try testing.expectEqualDeep( - terminal.colors.background, - decoded.header.background, - ); - try testing.expectEqualDeep(terminal.colors.palette, decoded.palette); - try testing.expectEqual(@as(?u64, 12_345), decoded.header.max_scrollback_bytes); - try testing.expectEqual(@as(?u64, 67), decoded.header.max_scrollback_rows); - try testing.expect(decoded.tabstops.get(1)); - try testing.expect(decoded.tabstops.get(9)); - try testing.expectEqualStrings("file:///tmp/native", decoded.pwd); - try testing.expectEqualStrings("native title", decoded.title); - - // The public decoder installs the same payload into native ownership. - var native_source: std.Io.Reader = .fixed(destination.written()); var restored = try decode( - &native_source, + &source, testing.io, testing.allocator, ); @@ -1675,6 +1583,31 @@ test "TERMINAL record encodes native terminal state" { try testing.expect(restored.screens.get(.alternate) == null); try testing.expectEqual(@as(?u21, 'X'), restored.previous_char); try testing.expectEqualDeep(terminal.cursor, restored.cursor); + try testing.expectEqual( + terminal.flags.shell_redraws_prompt, + restored.flags.shell_redraws_prompt, + ); + try testing.expectEqual( + terminal.flags.modify_other_keys_2, + restored.flags.modify_other_keys_2, + ); + try testing.expectEqual( + terminal.flags.mouse_event, + restored.flags.mouse_event, + ); + try testing.expectEqual( + terminal.flags.mouse_format, + restored.flags.mouse_format, + ); + try testing.expectEqual( + terminal.flags.mouse_shift_capture, + restored.flags.mouse_shift_capture, + ); + try testing.expectEqual( + terminal.flags.password_input, + restored.flags.password_input, + ); + try testing.expectEqual(terminal.mouse_shape, restored.mouse_shape); try testing.expectEqualDeep(terminal.modes, restored.modes); try testing.expectEqualDeep(terminal.colors, restored.colors); try testing.expectEqual(