From 38d92c50c9c8c19f543a13c126aa8710f9f6e856 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Jul 2026 21:02:19 -0700 Subject: [PATCH] terminal/snapshot: kaitai verification Describe the complete version 1 snapshot format with a Kaitai schema and make every golden fixture self-describing for automatic discovery. Add a verifier that compiles the schema, parses all fixtures, and checks record checksums, checkpoint digests, and cross-record invariants. Preserve Kaitai metadata when generating fixture candidates and provide the compiler and Python runtime dependencies through the development shell. Keep the mode registry portable to Kaitai JavaScript targets so the complete fixture also works in the web IDE. --- nix/devShell.nix | 16 +- src/terminal/snapshot/envelope.zig | 4 +- src/terminal/snapshot/fixture.zig | 71 +- src/terminal/snapshot/history.zig | 12 +- src/terminal/snapshot/hyperlink.zig | 9 +- src/terminal/snapshot/page.zig | 14 +- src/terminal/snapshot/screen.zig | 9 +- src/terminal/snapshot/snapshot.ksy | 998 ++++++++++++++++++ src/terminal/snapshot/style.zig | 4 +- .../snapshot/testdata/checkpoint-ready-v1.hex | 3 + .../snapshot/testdata/complete-v1.hex | 3 + .../snapshot/testdata/envelope-v1.hex | 3 + src/terminal/snapshot/testdata/grid-v1.hex | 3 + .../snapshot/testdata/history-header-v1.hex | 3 + .../testdata/hyperlink-explicit-v1.hex | 3 + .../testdata/hyperlink-implicit-v1.hex | 3 + .../testdata/page-empty-record-v1.hex | 3 + .../snapshot/testdata/page-header-v1.hex | 3 + src/terminal/snapshot/testdata/page-v1.hex | 3 + .../testdata/record-page-header-v1.hex | 3 + .../snapshot/testdata/screen-header-v1.hex | 3 + .../testdata/screen-saved-cursor-v1.hex | 3 + src/terminal/snapshot/testdata/style-v1.hex | 3 + .../snapshot/testdata/terminal-header-v1.hex | 3 + src/terminal/snapshot/verify-kaitai.py | 422 ++++++++ 25 files changed, 1553 insertions(+), 51 deletions(-) create mode 100644 src/terminal/snapshot/snapshot.ksy create mode 100755 src/terminal/snapshot/verify-kaitai.py diff --git a/nix/devShell.nix b/nix/devShell.nix index 6fa1f14b1..5401ba0d1 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -59,6 +59,7 @@ zlib, alejandra, jq, + kaitai-struct-compiler, minisign, pandoc, pinact, @@ -85,6 +86,11 @@ gi_typelib_path = import ./build-support/gi-typelib-path.nix { inherit pkgs lib stdenv; }; + python = python3.withPackages (python-pkgs: [ + python-pkgs.blake3 + python-pkgs.kaitaistruct + python-pkgs.ucs-detect + ]); in mkShell { name = "ghostty"; @@ -116,9 +122,10 @@ in # Testing parallel - python3 + python vttest hyperfine + kaitai-struct-compiler # wasm wabt @@ -138,11 +145,6 @@ in blueprint-compiler libadwaita gtk4 - - # Python packages - (python3.withPackages (python-pkgs: [ - python-pkgs.ucs-detect - ])) ] ++ lib.optionals stdenv.hostPlatform.isLinux [ # My nix shell environment installs the non-interactive version @@ -242,6 +244,6 @@ in # We need to remove "xcrun" from the PATH. It is injected by # some dependency but we need to rely on system Xcode tools export PATH=$(echo "$PATH" | awk -v RS=: -v ORS=: '$0 !~ /xcrun/ || $0 == "/usr/bin" {print}' | sed 's/:$//') - export PATH="/opt/homebrew/opt/llvm/bin:/opt/homebrew/bin:/usr/local/opt/llvm/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" + export PATH="${python}/bin:/opt/homebrew/opt/llvm/bin:/opt/homebrew/bin:/usr/local/opt/llvm/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" ''); } diff --git a/src/terminal/snapshot/envelope.zig b/src/terminal/snapshot/envelope.zig index 9963ced2a..21fcefb9d 100644 --- a/src/terminal/snapshot/envelope.zig +++ b/src/terminal/snapshot/envelope.zig @@ -66,9 +66,7 @@ fn computeLen() usize { } } -const test_golden_fixture = test_fixture.parse( - @embedFile("testdata/envelope-v1.hex"), -); +const test_golden_fixture = test_fixture.parse(@embedFile("testdata/envelope-v1.hex")); test "golden encoding" { var buf: [encoded_len]u8 = undefined; diff --git a/src/terminal/snapshot/fixture.zig b/src/terminal/snapshot/fixture.zig index 6d256ac3d..d5c86b2b7 100644 --- a/src/terminal/snapshot/fixture.zig +++ b/src/terminal/snapshot/fixture.zig @@ -49,6 +49,23 @@ //! ); //! } //! ``` +//! +//! ## Kaitai +//! +//! Every fixture has three machine-readable comment fields: +//! +//! ```text +//! # Kaitai type: page_payload +//! # Kaitai params: +//! # Kaitai offset: 0 +//! ``` +//! +//! These select a type from `snapshot.ksy`, its whitespace-separated +//! parameters, and the byte offset where parsing begins. The Kaitai verifier +//! discovers fixtures from the filesystem and uses this metadata instead of +//! maintaining a parallel filename table. Generated candidates copy the +//! metadata from their checked-in reference. +//! const std = @import("std"); const envelope = @import("envelope.zig"); @@ -128,6 +145,17 @@ pub fn expectEqual( var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); + // Kaitai parser selection lives in the reference itself. Read the textual + // source so generated candidates preserve that metadata even though the + // byte comparison below uses the comptime-decoded reference. + const reference_source = try std.Io.Dir.cwd().readFileAlloc( + testing.io, + reference_path, + alloc, + .unlimited, + ); + defer alloc.free(reference_source); + // Always materialize a candidate, even when it will match. This keeps the // generation path exercised on every test run while avoiding repository // artifacts for the normal successful case. @@ -143,7 +171,12 @@ pub fn expectEqual( testing.io, &write_buffer, ); - try format(kind, actual, &candidate_writer.interface); + try format( + kind, + reference_source, + actual, + &candidate_writer.interface, + ); try candidate_writer.interface.flush(); } @@ -267,14 +300,16 @@ fn hexNibble(comptime value: u8) u8 { fn format( kind: Kind, + reference_source: []const u8, bytes: []const u8, writer: *std.Io.Writer, -) std.Io.Writer.Error!void { +) (std.Io.Writer.Error || error{InvalidKaitaiMetadata})!void { // Every generated candidate starts with enough maintenance context to be // understandable when copied out of a failing test's temp directory. + try writer.writeAll("# Ghostty snapshot fixture\n"); + try writeKaitaiMetadata(reference_source, writer); try writer.print( - "# Ghostty snapshot fixture\n" ++ - "# Wire version: {}\n" ++ + "# Wire version: {}\n" ++ "# Generated by its snapshot test; review before replacing.\n" ++ "# On mismatch, the candidate is copied to the repository root.\n", .{envelope.version}, @@ -295,6 +330,34 @@ fn format( } } +fn writeKaitaiMetadata( + reference_source: []const u8, + writer: *std.Io.Writer, +) (std.Io.Writer.Error || error{InvalidKaitaiMetadata})!void { + const prefixes = [_][]const u8{ + "# Kaitai type:", + "# Kaitai params:", + "# Kaitai offset:", + }; + var found = [_]bool{false} ** prefixes.len; + + var lines = std.mem.splitScalar(u8, reference_source, '\n'); + while (lines.next()) |untrimmed| { + const line = std.mem.trim(u8, untrimmed, " \r"); + for (prefixes, &found) |prefix, *seen| { + if (!std.mem.startsWith(u8, line, prefix)) continue; + if (seen.*) return error.InvalidKaitaiMetadata; + seen.* = true; + try writer.print("{s}\n", .{line}); + break; + } + } + + for (found) |seen| { + if (!seen) return error.InvalidKaitaiMetadata; + } +} + fn formatPage( bytes: []const u8, writer: *std.Io.Writer, diff --git a/src/terminal/snapshot/history.zig b/src/terminal/snapshot/history.zig index 8422c4f73..d4ea5a60d 100644 --- a/src/terminal/snapshot/history.zig +++ b/src/terminal/snapshot/history.zig @@ -288,9 +288,7 @@ pub fn decode( // the destination PageList to allocate the final backing memory once. var decoder: page.Decoder = undefined; try decoder.init(source); - var allocation = try terminal_screen.pages.allocatePage( - decoder.capacity(), - ); + var allocation = try terminal_screen.pages.allocatePage(decoder.capacity()); defer allocation.deinit(); try decoder.decode(allocation.page(), alloc); @@ -319,9 +317,7 @@ pub fn decode( } fn hasSemanticPrompt(terminal_page: *const TerminalPage) bool { - const rows = terminal_page.rows.ptr( - terminal_page.memory, - )[0..terminal_page.size.rows]; + const rows = terminal_page.rows.ptr(terminal_page.memory)[0..terminal_page.size.rows]; for (rows) |row| { if (row.semantic_prompt != .none) return true; @@ -335,9 +331,7 @@ fn hasSemanticPrompt(terminal_page: *const TerminalPage) bool { return false; } -const test_header_fixture = test_fixture.parse( - @embedFile("testdata/history-header-v1.hex"), -); +const test_header_fixture = test_fixture.parse(@embedFile("testdata/history-header-v1.hex")); test "HISTORY header golden encoding and decoding" { const expected: Header = .{ diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig index 6baade906..ec3b14efe 100644 --- a/src/terminal/snapshot/hyperlink.zig +++ b/src/terminal/snapshot/hyperlink.zig @@ -271,13 +271,8 @@ fn decodePageString( }; } -const test_implicit_fixture = test_fixture.parse( - @embedFile("testdata/hyperlink-implicit-v1.hex"), -); - -const test_explicit_fixture = test_fixture.parse( - @embedFile("testdata/hyperlink-explicit-v1.hex"), -); +const test_implicit_fixture = test_fixture.parse(@embedFile("testdata/hyperlink-implicit-v1.hex")); +const test_explicit_fixture = test_fixture.parse(@embedFile("testdata/hyperlink-explicit-v1.hex")); test "golden implicit encoding" { const value: terminal_hyperlink.Hyperlink = .{ diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 2c6c1b4d6..05ad3c03a 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -547,17 +547,9 @@ fn pageHyperlink( }; } -const test_page_fixture = test_fixture.parse( - @embedFile("testdata/page-v1.hex"), -); - -const test_header_fixture = test_fixture.parse( - @embedFile("testdata/page-header-v1.hex"), -); - -const test_empty_framed_page_fixture = test_fixture.parse( - @embedFile("testdata/page-empty-record-v1.hex"), -); +const test_page_fixture = test_fixture.parse(@embedFile("testdata/page-v1.hex")); +const test_header_fixture = test_fixture.parse(@embedFile("testdata/page-header-v1.hex")); +const test_empty_framed_page_fixture = test_fixture.parse(@embedFile("testdata/page-empty-record-v1.hex")); test "PAGE header golden encoding and decoding" { const header: Header = .{ diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index e9de03c66..505c9c5ff 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -1117,13 +1117,8 @@ pub fn decodeCursorHyperlink( return try hyperlink.decode(reader, alloc); } -const test_header_fixture = test_fixture.parse( - @embedFile("testdata/screen-header-v1.hex"), -); - -const test_saved_cursor_fixture = test_fixture.parse( - @embedFile("testdata/screen-saved-cursor-v1.hex"), -); +const test_header_fixture = test_fixture.parse(@embedFile("testdata/screen-header-v1.hex")); +const test_saved_cursor_fixture = test_fixture.parse(@embedFile("testdata/screen-saved-cursor-v1.hex")); fn testCharsetState() TerminalScreen.CharsetState { var result: TerminalScreen.CharsetState = .{ diff --git a/src/terminal/snapshot/snapshot.ksy b/src/terminal/snapshot/snapshot.ksy new file mode 100644 index 000000000..a800b2782 --- /dev/null +++ b/src/terminal/snapshot/snapshot.ksy @@ -0,0 +1,998 @@ +meta: + id: ghostty_snapshot + title: Ghostty terminal snapshot + application: Ghostty + license: MIT + endian: le +doc: | + Ghostty terminal snapshot format version 1. + + A complete snapshot contains an envelope, terminal-wide state, one or two + renderable screen sequences, a READY checkpoint, matching history sequences, + and a FINISH checkpoint. SCREEN pages are oldest-to-newest. HISTORY pages are + newest-to-oldest. + + Record CRC32C values and checkpoint BLAKE3-256 digests are represented here + but cannot be calculated by portable Kaitai Struct expressions. The adjacent + verify-kaitai.py script validates those values after parsing. +seq: + - id: envelope + type: envelope + - id: terminal + type: terminal_record + - id: screens + type: screen_sequence + repeat: expr + repeat-expr: terminal.payload.header.screen_count + - id: ready + type: checkpoint_record(5) + - id: histories + type: history_sequence + repeat: expr + repeat-expr: terminal.payload.header.screen_count + - id: finish + type: checkpoint_record(6) + - id: trailing_data + size-eos: true + valid: + expr: _.size == 0 +enums: + record_tag: + 1: terminal + 2: screen + 3: page + 4: history + 5: ready + 6: finish + screen_key: + 0: primary + 1: alternate + cursor_style: + 0: bar + 1: block + 2: underline + 3: block_hollow + status_display: + 0: main + 1: status_line + shell_redraw: + 0: full + 1: none + 2: last + mouse_event: + 0: none + 1: x10 + 2: normal + 3: button + 4: any + mouse_format: + 0: x10 + 1: utf8 + 2: sgr + 3: urxvt + 4: sgr_pixels + mouse_shape: + 0: default + 1: context_menu + 2: help + 3: pointer + 4: progress + 5: wait + 6: cell + 7: crosshair + 8: text + 9: vertical_text + 10: alias + 11: copy + 12: move + 13: no_drop + 14: not_allowed + 15: grab + 16: grabbing + 17: all_scroll + 18: col_resize + 19: row_resize + 20: n_resize + 21: e_resize + 22: s_resize + 23: w_resize + 24: ne_resize + 25: nw_resize + 26: se_resize + 27: sw_resize + 28: ew_resize + 29: ns_resize + 30: nesw_resize + 31: nwse_resize + 32: zoom_in + 33: zoom_out + protected_mode: + 0: "off" + 1: iso + 2: dec + semantic_click_kind: + 0: none + 1: click_events + 2: cl + color_kind: + 0: none + 1: palette + 2: rgb + hyperlink_kind: + 0: none + 1: implicit + 2: explicit + cell_content_kind: + 0: codepoint + 1: background_palette + 2: background_rgb + cell_width: + 0: narrow + 1: wide + 2: spacer_tail + 3: spacer_head + underline: + 0: none + 1: single + 2: double + 3: curly + 4: dotted + 5: dashed + semantic_content: + 0: output + 1: input + 2: prompt + semantic_prompt: + 0: none + 1: prompt + 2: prompt_continuation +types: + envelope: + doc: Fixed ten-byte snapshot identification and version header. + seq: + - id: magic + contents: [0x47, 0x48, 0x4f, 0x53, 0x54, 0x53, 0x4e, 0x50] + - id: version + type: u2 + valid: 1 + + record_header: + doc: | + Common record framing. crc32c covers the encoded tag, payload length, + and payload bytes, excluding the crc32c field itself. + params: + - id: expected_tag + type: u2 + seq: + - id: tag + type: u2 + valid: expected_tag + - id: payload_length + type: u4 + - id: crc32c + type: u4 + + terminal_record: + seq: + - id: header + type: record_header(1) + - id: payload + type: terminal_payload + size: header.payload_length + + screen_record: + seq: + - id: header + type: record_header(2) + - id: payload + type: screen_payload + size: header.payload_length + + page_record: + seq: + - id: header + type: record_header(3) + - id: payload + type: page_payload + size: header.payload_length + + history_record: + seq: + - id: header + type: record_header(4) + - id: payload + type: history_payload + size: header.payload_length + + checkpoint_record: + params: + - id: expected_tag + type: u2 + seq: + - id: header + type: record_header(expected_tag) + - id: payload + type: checkpoint_payload + size: header.payload_length + + screen_sequence: + doc: SCREEN followed by its declared PAGE records, oldest-to-newest. + seq: + - id: screen + type: screen_record + - id: pages + type: page_record + repeat: expr + repeat-expr: screen.payload.header.page_count + + history_sequence: + doc: HISTORY followed by its declared PAGE records, newest-to-oldest. + seq: + - id: history + type: history_record + - id: pages + type: page_record + repeat: expr + repeat-expr: history.payload.page_count + + checkpoint_payload: + seq: + - id: prefix_digest + size: 32 + - id: trailing_data + size-eos: true + valid: + expr: _.size == 0 + + terminal_payload: + seq: + - id: header + type: terminal_header + - id: tab_stops + type: tab_stops(header.columns) + - id: original_palette + type: rgb + repeat: expr + repeat-expr: 256 + - id: palette_override_mask + size: 32 + - id: palette_overrides + type: palette_override(palette_override_mask, _index) + repeat: expr + repeat-expr: 256 + - id: len_pwd + type: u4 + - id: pwd + size: len_pwd + - id: len_title + type: u4 + - id: title + size: len_title + - id: trailing_data + size-eos: true + valid: + expr: _.size == 0 + + terminal_header: + seq: + - id: columns + type: u2 + valid: + min: 1 + - id: rows + type: u2 + valid: + min: 1 + - id: width_px + type: u4 + - id: height_px + type: u4 + - id: scrolling_region_top + type: u2 + - id: scrolling_region_bottom + type: u2 + valid: + expr: _ >= scrolling_region_top and _ < rows + - id: scrolling_region_left + type: u2 + - id: scrolling_region_right + type: u2 + valid: + expr: _ >= scrolling_region_left and _ < columns + - id: status_display + type: u1 + enum: status_display + valid: + expr: _.to_i <= 1 + - id: active_screen_key + type: u2 + enum: screen_key + valid: + expr: _.to_i <= 1 + - id: screen_count + type: u2 + valid: + expr: (_ == 1 or _ == 2) and (active_screen_key.to_i == 0 or _ == 2) + - id: previous_codepoint + type: u4 + valid: + expr: _ == 0xffffffff or (_ <= 0x10ffff and not (_ >= 0xd800 and _ <= 0xdfff)) + - id: cursor_is_default + type: u1 + valid: + max: 1 + - id: cursor_default_style + type: u1 + enum: cursor_style + valid: + expr: _.to_i <= 3 + - id: cursor_default_blink + type: u1 + valid: + max: 2 + - id: shell_redraw + type: u1 + enum: shell_redraw + valid: + expr: _.to_i <= 2 + - id: modify_other_keys_2 + type: u1 + valid: + max: 1 + - id: mouse_event + type: u1 + enum: mouse_event + valid: + expr: _.to_i <= 4 + - id: mouse_format + type: u1 + enum: mouse_format + valid: + expr: _.to_i <= 4 + - id: mouse_shift_capture + type: u1 + valid: + max: 2 + - id: mouse_shape + type: u1 + enum: mouse_shape + valid: + expr: _.to_i <= 33 + - id: password_input + type: u1 + valid: + max: 1 + - id: current_modes + type: mode_set + - id: saved_modes + type: mode_set + - id: default_modes + type: mode_set + - id: background + type: dynamic_rgb + - id: foreground + type: dynamic_rgb + - id: cursor_color + type: dynamic_rgb + - id: max_scrollback_bytes + type: u8 + - id: max_scrollback_rows + type: u8 + + mode_set: + doc: | + The stable packed registry shared by current, saved, and default modes. + Each named instance exposes one bit from the little-endian integer. + Arithmetic division is used instead of bitwise operations because the + JavaScript target implements those operations with signed 32-bit values. + All values remain exact because the registry occupies only 42 bits, + within JavaScript's 53-bit safe integer range. + seq: + - id: raw + type: u8 + valid: + max: 4398046511103 + instances: + disable_keyboard: + value: (raw / 1) % 2 != 0 + insert: + value: (raw / 2) % 2 != 0 + send_receive_mode: + value: (raw / 4) % 2 != 0 + linefeed: + value: (raw / 8) % 2 != 0 + cursor_keys: + value: (raw / 16) % 2 != 0 + column_132: + value: (raw / 32) % 2 != 0 + slow_scroll: + value: (raw / 64) % 2 != 0 + reverse_colors: + value: (raw / 128) % 2 != 0 + origin: + value: (raw / 256) % 2 != 0 + wraparound: + value: (raw / 512) % 2 != 0 + autorepeat: + value: (raw / 1024) % 2 != 0 + mouse_event_x10: + value: (raw / 2048) % 2 != 0 + cursor_blinking: + value: (raw / 4096) % 2 != 0 + cursor_visible: + value: (raw / 8192) % 2 != 0 + enable_mode_3: + value: (raw / 16384) % 2 != 0 + reverse_wrap: + value: (raw / 32768) % 2 != 0 + alt_screen_legacy: + value: (raw / 65536) % 2 != 0 + keypad_keys: + value: (raw / 131072) % 2 != 0 + backarrow_key_mode: + value: (raw / 262144) % 2 != 0 + enable_left_and_right_margin: + value: (raw / 524288) % 2 != 0 + mouse_event_normal: + value: (raw / 1048576) % 2 != 0 + mouse_event_button: + value: (raw / 2097152) % 2 != 0 + mouse_event_any: + value: (raw / 4194304) % 2 != 0 + focus_event: + value: (raw / 8388608) % 2 != 0 + mouse_format_utf8: + value: (raw / 16777216) % 2 != 0 + mouse_format_sgr: + value: (raw / 33554432) % 2 != 0 + mouse_alternate_scroll: + value: (raw / 67108864) % 2 != 0 + mouse_format_urxvt: + value: (raw / 134217728) % 2 != 0 + mouse_format_sgr_pixels: + value: (raw / 268435456) % 2 != 0 + ignore_keypad_with_numlock: + value: (raw / 536870912) % 2 != 0 + alt_esc_prefix: + value: (raw / 1073741824) % 2 != 0 + alt_sends_escape: + value: (raw / 2147483648) % 2 != 0 + reverse_wrap_extended: + value: (raw / 4294967296) % 2 != 0 + alt_screen: + value: (raw / 8589934592) % 2 != 0 + save_cursor: + value: (raw / 17179869184) % 2 != 0 + alt_screen_save_cursor_clear_enter: + value: (raw / 34359738368) % 2 != 0 + bracketed_paste: + value: (raw / 68719476736) % 2 != 0 + synchronized_output: + value: (raw / 137438953472) % 2 != 0 + grapheme_cluster: + value: (raw / 274877906944) % 2 != 0 + report_color_scheme: + value: (raw / 549755813888) % 2 != 0 + report_visibility: + value: (raw / 1099511627776) % 2 != 0 + in_band_size_reports: + value: (raw / 2199023255552) % 2 != 0 + + tab_stops: + params: + - id: columns + type: u2 + seq: + - id: complete_bytes + size: columns / 8 + - id: partial_byte + type: u1 + if: columns % 8 != 0 + valid: + expr: _ < (1 << (columns % 8)) + + rgb: + seq: + - id: red + type: u1 + - id: green + type: u1 + - id: blue + type: u1 + + dynamic_rgb: + seq: + - id: default_present + type: u1 + valid: + max: 1 + - id: default_red + type: u1 + valid: + expr: default_present == 1 or _ == 0 + - id: default_green + type: u1 + valid: + expr: default_present == 1 or _ == 0 + - id: default_blue + type: u1 + valid: + expr: default_present == 1 or _ == 0 + - id: override_present + type: u1 + valid: + max: 1 + - id: override_red + type: u1 + valid: + expr: override_present == 1 or _ == 0 + - id: override_green + type: u1 + valid: + expr: override_present == 1 or _ == 0 + - id: override_blue + type: u1 + valid: + expr: override_present == 1 or _ == 0 + + palette_override: + params: + - id: mask + type: bytes + - id: index + type: u2 + seq: + - id: color + type: rgb + if: (mask[index / 8] & (1 << (index % 8))) != 0 + + screen_payload: + seq: + - id: header + type: screen_header + - id: saved_cursor + type: saved_cursor + if: header.saved_cursor_present == 1 + - id: cursor_hyperlink + type: hyperlink(true, false) + - id: trailing_data + size-eos: true + valid: + expr: _.size == 0 + + screen_header: + seq: + - id: key + type: u2 + enum: screen_key + valid: + expr: _.to_i <= 1 + - id: page_count + type: u2 + valid: + min: 1 + - id: cursor_x + type: u2 + - id: cursor_y + type: u2 + - id: cursor_style + type: u1 + enum: cursor_style + valid: + expr: _.to_i <= 3 + - id: cursor_flags + type: cursor_flags + - id: cursor_pen + type: style + - id: hyperlink_implicit_id + type: u4 + - id: charset + type: charset_state + - id: protected_mode + type: u1 + enum: protected_mode + valid: + expr: _.to_i <= 2 + - id: kitty_keyboard_index + type: u1 + valid: + max: 7 + - id: kitty_keyboard_flags + type: kitty_keyboard_flags + repeat: expr + repeat-expr: 8 + - id: semantic_click_kind + type: u1 + enum: semantic_click_kind + valid: + expr: _.to_i <= 2 + - id: semantic_click_value + type: u1 + valid: + expr: | + semantic_click_kind.to_i == 0 ? _ == 0 : + semantic_click_kind.to_i == 1 ? _ <= 1 : + _ <= 3 + - id: saved_cursor_present + type: u1 + valid: + max: 1 + + cursor_flags: + seq: + - id: raw + type: u1 + valid: + expr: (_ & 0xe0) == 0 and ((_ >> 2) & 0x3) <= 2 + instances: + pending_wrap: + value: (raw & (1 << 0)) != 0 + protected: + value: (raw & (1 << 1)) != 0 + semantic_content: + value: (raw >> 2) & 0x3 + semantic_content_clear_eol: + value: (raw & (1 << 4)) != 0 + + charset_state: + doc: | + Four selected character sets, the GL and GR slots, and an optional + single-shift slot. single_shift is zero for none or one plus a slot. + seq: + - id: raw + type: u2 + valid: + expr: (_ & 0x8000) == 0 and ((_ >> 12) & 0x7) <= 4 + instances: + g0: + value: (raw >> 0) & 0x3 + g1: + value: (raw >> 2) & 0x3 + g2: + value: (raw >> 4) & 0x3 + g3: + value: (raw >> 6) & 0x3 + gl: + value: (raw >> 8) & 0x3 + gr: + value: (raw >> 10) & 0x3 + single_shift: + value: (raw >> 12) & 0x7 + + kitty_keyboard_flags: + seq: + - id: raw + type: u1 + valid: + expr: (_ & 0xe0) == 0 + instances: + disambiguate: + value: (raw & (1 << 0)) != 0 + report_events: + value: (raw & (1 << 1)) != 0 + report_alternates: + value: (raw & (1 << 2)) != 0 + report_all: + value: (raw & (1 << 3)) != 0 + report_associated: + value: (raw & (1 << 4)) != 0 + + saved_cursor: + seq: + - id: x + type: u2 + - id: y + type: u2 + - id: pen + type: style + - id: flags + type: saved_cursor_flags + - id: charset + type: charset_state + + saved_cursor_flags: + seq: + - id: raw + type: u1 + valid: + expr: (_ & 0xf8) == 0 + instances: + protected: + value: (raw & (1 << 0)) != 0 + pending_wrap: + value: (raw & (1 << 1)) != 0 + origin: + value: (raw & (1 << 2)) != 0 + + history_payload: + seq: + - id: key + type: u2 + enum: screen_key + valid: + expr: _.to_i <= 1 + - id: page_count + type: u4 + - id: total_rows + type: u8 + - id: screen_overlap_rows + type: u2 + - id: trailing_data + size-eos: true + valid: + expr: _.size == 0 + + page_payload: + seq: + - id: header + type: page_header + - id: styles + type: style_table_entry + repeat: expr + repeat-expr: header.style_count + - id: hyperlinks + type: hyperlink_table_entry + repeat: expr + repeat-expr: header.hyperlink_count + - id: grid + type: grid(header.rows, header.columns) + - id: trailing_data + size-eos: true + valid: + expr: _.size == 0 + + page_header: + seq: + - id: columns + type: u2 + valid: + min: 1 + - id: rows + type: u2 + valid: + min: 1 + - id: style_count + type: u2 + - id: hyperlink_count + type: u2 + - id: style_capacity + type: u2 + - id: hyperlink_capacity_bytes + type: u2 + - id: grapheme_capacity_bytes + type: u4 + - id: string_capacity_bytes + type: u4 + + style_table_entry: + seq: + - id: encoded_id + type: u2 + valid: + min: 1 + - id: value + type: style + + hyperlink_table_entry: + seq: + - id: encoded_id + type: u2 + valid: + min: 1 + - id: value + type: hyperlink(false, true) + + style: + seq: + - id: foreground + type: style_color + - id: background + type: style_color + - id: underline_color + type: style_color + - id: flags + type: style_flags + - id: reserved + type: u2 + valid: 0 + + style_flags: + seq: + - id: raw + type: u2 + valid: + expr: (_ & 0xf800) == 0 and ((_ >> 8) & 0x7) <= 5 + instances: + bold: + value: (raw & (1 << 0)) != 0 + italic: + value: (raw & (1 << 1)) != 0 + faint: + value: (raw & (1 << 2)) != 0 + blink: + value: (raw & (1 << 3)) != 0 + inverse: + value: (raw & (1 << 4)) != 0 + invisible: + value: (raw & (1 << 5)) != 0 + strikethrough: + value: (raw & (1 << 6)) != 0 + overline: + value: (raw & (1 << 7)) != 0 + underline: + value: (raw >> 8) & 0x7 + + style_color: + seq: + - id: kind + type: u1 + enum: color_kind + valid: + expr: _.to_i <= 2 + - id: first + type: u1 + valid: + expr: kind.to_i != 0 or _ == 0 + - id: second + type: u1 + valid: + expr: kind.to_i == 2 or _ == 0 + - id: third + type: u1 + valid: + expr: kind.to_i == 2 or _ == 0 + + hyperlink: + params: + - id: allow_none + type: bool + - id: allow_empty + type: bool + seq: + - id: kind + type: u1 + enum: hyperlink_kind + valid: + expr: _.to_i <= 2 and (allow_none or _.to_i != 0) + - id: implicit + type: implicit_hyperlink(allow_empty) + if: kind.to_i == 1 + - id: explicit + type: explicit_hyperlink(allow_empty) + if: kind.to_i == 2 + + implicit_hyperlink: + params: + - id: allow_empty + type: bool + seq: + - id: id + type: u4 + - id: len_uri + type: u4 + valid: + expr: allow_empty or _ >= 1 + - id: uri + size: len_uri + + explicit_hyperlink: + params: + - id: allow_empty + type: bool + seq: + - id: len_id + type: u4 + valid: + expr: allow_empty or _ >= 1 + - id: id + size: len_id + - id: len_uri + type: u4 + valid: + expr: allow_empty or _ >= 1 + - id: uri + size: len_uri + + grid: + params: + - id: num_rows + type: u2 + - id: columns + type: u2 + seq: + - id: rows + type: grid_row(columns) + repeat: expr + repeat-expr: num_rows + + grid_row: + params: + - id: num_cells + type: u2 + seq: + - id: flags + type: grid_row_flags + - id: cells + type: grid_cell(_index, num_cells, flags.wrap) + repeat: expr + repeat-expr: num_cells + + grid_row_flags: + seq: + - id: raw + type: u1 + valid: + expr: (_ & 0xf0) == 0 and ((_ >> 2) & 0x3) <= 2 + instances: + wrap: + value: (raw & 1) != 0 + wrap_continuation: + value: (raw & 2) != 0 + semantic_prompt: + value: (raw >> 2) & 0x3 + + grid_cell: + params: + - id: index + type: u2 + - id: columns + type: u2 + - id: row_wrap + type: bool + seq: + - id: content_kind + type: u1 + valid: + max: 2 + - id: width + type: u1 + valid: + expr: | + _ <= 3 and + (_ != 2 or + (index > 0 and _parent.cells[index - 1].width == 1)) and + (_ != 3 or + (index + 1 == columns and row_wrap)) + - id: flags + type: grid_cell_flags + - id: reserved + type: u1 + valid: 0 + - id: style_id + type: u2 + - id: hyperlink_id + type: u2 + - id: value + type: u4 + valid: + expr: | + content_kind == 0 ? + (_ <= 0x10ffff and not (_ >= 0xd800 and _ <= 0xdfff) and _ != 0x10eeee) : + content_kind == 1 ? + _ <= 0xff : + _ <= 0xffffff + - id: num_graphemes + type: u4 + valid: + expr: | + content_kind == 0 ? + (_ == 0 or value != 0) : + _ == 0 + - id: graphemes + type: u4 + repeat: expr + repeat-expr: num_graphemes + valid: + expr: _ <= 0x10ffff and not (_ >= 0xd800 and _ <= 0xdfff) and _ != 0x10eeee + + grid_cell_flags: + seq: + - id: raw + type: u1 + valid: + expr: (_ & 0xf8) == 0 and ((_ >> 1) & 0x3) <= 2 + instances: + protected: + value: (raw & (1 << 0)) != 0 + semantic_content: + value: (raw >> 1) & 0x3 diff --git a/src/terminal/snapshot/style.zig b/src/terminal/snapshot/style.zig index 268c29289..69e0973c2 100644 --- a/src/terminal/snapshot/style.zig +++ b/src/terminal/snapshot/style.zig @@ -216,9 +216,7 @@ fn computeLen() usize { } } -const test_golden_fixture = test_fixture.parse( - @embedFile("testdata/style-v1.hex"), -); +const test_golden_fixture = test_fixture.parse(@embedFile("testdata/style-v1.hex")); test "golden encoding and decoding" { const value: terminal_style.Style = .{ diff --git a/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex b/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex index b3a24104b..51381196c 100644 --- a/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex +++ b/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: checkpoint_record +# Kaitai params: 5 +# Kaitai offset: 3 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/complete-v1.hex b/src/terminal/snapshot/testdata/complete-v1.hex index 8982c2add..01a72f1ea 100644 --- a/src/terminal/snapshot/testdata/complete-v1.hex +++ b/src/terminal/snapshot/testdata/complete-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: ghostty_snapshot +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/envelope-v1.hex b/src/terminal/snapshot/testdata/envelope-v1.hex index 3e4ddadfa..04a7eb6d5 100644 --- a/src/terminal/snapshot/testdata/envelope-v1.hex +++ b/src/terminal/snapshot/testdata/envelope-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: envelope +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/grid-v1.hex b/src/terminal/snapshot/testdata/grid-v1.hex index 83a668326..3389a876f 100644 --- a/src/terminal/snapshot/testdata/grid-v1.hex +++ b/src/terminal/snapshot/testdata/grid-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: grid +# Kaitai params: 2 3 +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/history-header-v1.hex b/src/terminal/snapshot/testdata/history-header-v1.hex index 7949ca620..6e44379dc 100644 --- a/src/terminal/snapshot/testdata/history-header-v1.hex +++ b/src/terminal/snapshot/testdata/history-header-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: history_payload +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex b/src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex index 6f925099a..7643b203a 100644 --- a/src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex +++ b/src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: hyperlink +# Kaitai params: false false +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex b/src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex index 8ae474676..cb15cf123 100644 --- a/src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex +++ b/src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: hyperlink +# Kaitai params: false false +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/page-empty-record-v1.hex b/src/terminal/snapshot/testdata/page-empty-record-v1.hex index 06db7cc54..4b759b704 100644 --- a/src/terminal/snapshot/testdata/page-empty-record-v1.hex +++ b/src/terminal/snapshot/testdata/page-empty-record-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: page_record +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/page-header-v1.hex b/src/terminal/snapshot/testdata/page-header-v1.hex index 811ad80fc..b7b00765f 100644 --- a/src/terminal/snapshot/testdata/page-header-v1.hex +++ b/src/terminal/snapshot/testdata/page-header-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: page_header +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/page-v1.hex b/src/terminal/snapshot/testdata/page-v1.hex index d3c04d0c4..dfc53eeaa 100644 --- a/src/terminal/snapshot/testdata/page-v1.hex +++ b/src/terminal/snapshot/testdata/page-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: page_payload +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/record-page-header-v1.hex b/src/terminal/snapshot/testdata/record-page-header-v1.hex index 339246dab..28e6269ae 100644 --- a/src/terminal/snapshot/testdata/record-page-header-v1.hex +++ b/src/terminal/snapshot/testdata/record-page-header-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: record_header +# Kaitai params: 3 +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/screen-header-v1.hex b/src/terminal/snapshot/testdata/screen-header-v1.hex index 3b8f9ef92..950d5736b 100644 --- a/src/terminal/snapshot/testdata/screen-header-v1.hex +++ b/src/terminal/snapshot/testdata/screen-header-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: screen_header +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex b/src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex index 62470761c..f4bd7e3ab 100644 --- a/src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex +++ b/src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: saved_cursor +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/style-v1.hex b/src/terminal/snapshot/testdata/style-v1.hex index 94e381cda..0e834f870 100644 --- a/src/terminal/snapshot/testdata/style-v1.hex +++ b/src/terminal/snapshot/testdata/style-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: style +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/terminal-header-v1.hex b/src/terminal/snapshot/testdata/terminal-header-v1.hex index 690891c65..9c9eb4bfb 100644 --- a/src/terminal/snapshot/testdata/terminal-header-v1.hex +++ b/src/terminal/snapshot/testdata/terminal-header-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: terminal_header +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/verify-kaitai.py b/src/terminal/snapshot/verify-kaitai.py new file mode 100755 index 000000000..1a88ab41c --- /dev/null +++ b/src/terminal/snapshot/verify-kaitai.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Compile the snapshot Kaitai schema and parse every checked-in fixture. + +Run this from a Ghostty development shell: + + src/terminal/snapshot/verify-kaitai.py + +To turn an annotated fixture into a binary suitable for the Kaitai Web IDE: + + src/terminal/snapshot/verify-kaitai.py \ + --write-binary \ + src/terminal/snapshot/testdata/complete-v1.hex \ + /tmp/complete-v1.bin + +Then load `snapshot.ksy` and the generated binary into +https://ide.kaitai.io/. + +The generated Python parser exists only in a temporary directory. This keeps +snapshot.ksy as the source of truth and ensures this check cannot accidentally +pass against stale generated code. After structural parsing, the script checks +record CRC32C values, checkpoint BLAKE3 digests, and cross-record invariants +that portable Kaitai expressions cannot represent. +""" + +from __future__ import annotations + +import argparse +import importlib +import io +import shutil +import struct +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +try: + from blake3 import blake3 + from kaitaistruct import KaitaiStream +except ImportError as error: + raise SystemExit( + "missing Kaitai verifier dependencies; run this inside " + "`nix develop`" + ) from error + + +SNAPSHOT_DIR = Path(__file__).resolve().parent +SCHEMA_PATH = SNAPSHOT_DIR / "snapshot.ksy" +TESTDATA_DIR = SNAPSHOT_DIR / "testdata" +SNAPSHOT_ENVELOPE_SIZE = 10 +RECORD_HEADER_SIZE = struct.calcsize(" Fixture: + """Load one self-describing annotated hexadecimal fixture.""" + metadata: dict[str, str] = {} + chunks: list[str] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + content, separator, comment = line.partition("#") + chunks.extend(content.split()) + + if not separator: + continue + comment = comment.strip() + if not comment.startswith("Kaitai "): + continue + key, colon, value = comment.removeprefix("Kaitai ").partition(":") + if not colon or key not in {"type", "params", "offset"}: + raise ValueError(f"{path}: invalid Kaitai metadata: {comment}") + if key in metadata: + raise ValueError(f"{path}: duplicate Kaitai {key} metadata") + metadata[key] = value.strip() + + try: + data = bytes.fromhex(" ".join(chunks)) + except ValueError as error: + raise ValueError(f"{path}: invalid annotated hexadecimal") from error + + required = {"type", "params", "offset"} + if metadata.keys() != required: + missing = sorted(required - metadata.keys()) + extra = sorted(metadata.keys() - required) + raise ValueError( + f"{path}: Kaitai metadata drift: missing={missing}, extra={extra}" + ) + if not metadata["type"]: + raise ValueError(f"{path}: empty Kaitai type") + + params: list[object] = [] + for value in metadata["params"].split(): + if value == "true": + params.append(True) + elif value == "false": + params.append(False) + else: + try: + params.append(int(value, 0)) + except ValueError as error: + raise ValueError( + f"{path}: invalid Kaitai parameter {value!r}" + ) from error + + try: + offset = int(metadata["offset"], 0) + except ValueError as error: + raise ValueError(f"{path}: invalid Kaitai offset") from error + if offset < 0 or offset > len(data): + raise ValueError(f"{path}: Kaitai offset is outside the fixture") + + return Fixture( + path=path, + type_name=metadata["type"], + params=tuple(params), + offset=offset, + data=data, + ) + + +def parse_type( + parser_type: type[Any], + data: bytes, + *params: object, +) -> Any: + """Construct one generated Kaitai type and require exact exhaustion.""" + stream = KaitaiStream(io.BytesIO(data)) + result = parser_type(*params, stream) + if not stream.is_eof(): + raise ValueError( + f"{parser_type.__name__} left " + f"{stream.size() - stream.pos()} trailing bytes" + ) + return result + + +def crc32c(data: bytes) -> int: + """Return CRC32C using the reflected Castagnoli polynomial.""" + result = 0xFFFFFFFF + for byte in data: + result ^= byte + for _ in range(8): + result = ( + (result >> 1) ^ 0x82F63B78 + if result & 1 + else result >> 1 + ) + return result ^ 0xFFFFFFFF + + +def validate_record(record: Any, data: bytes, offset: int) -> int: + """Validate one parsed record's source bytes and CRC32C.""" + payload = record._raw_payload + header = record.header + if header.payload_length != len(payload): + raise ValueError( + f"record at 0x{offset:x}: declared {header.payload_length} " + f"payload bytes but parsed {len(payload)}" + ) + + encoded_header = data[offset : offset + RECORD_HEADER_SIZE] + expected_header = struct.pack( + " list[Any]: + """Return complete-snapshot records in their authenticated wire order.""" + records = [snapshot.terminal] + for sequence in snapshot.screens: + records.append(sequence.screen) + records.extend(sequence.pages) + records.append(snapshot.ready) + for sequence in snapshot.histories: + records.append(sequence.history) + records.extend(sequence.pages) + records.append(snapshot.finish) + return records + + +def validate_complete_snapshot(snapshot: Any, data: bytes) -> None: + """Validate ordering relationships, record CRCs, and checkpoints.""" + screen_keys = [ + sequence.screen.payload.header.key for sequence in snapshot.screens + ] + history_keys = [ + sequence.history.payload.key for sequence in snapshot.histories + ] + if len(set(screen_keys)) != len(screen_keys): + raise ValueError("complete snapshot contains a duplicate SCREEN key") + if len(set(history_keys)) != len(history_keys): + raise ValueError("complete snapshot contains a duplicate HISTORY key") + if set(screen_keys) != set(history_keys): + raise ValueError("SCREEN and HISTORY keys do not match") + + terminal_header = snapshot.terminal.payload.header + expected_key_values = ( + {0} if terminal_header.screen_count == 1 else {0, 1} + ) + screen_key_values = {key.value for key in screen_keys} + if screen_key_values != expected_key_values: + raise ValueError( + f"TERMINAL declares screen keys {expected_key_values}, " + f"but SCREEN records contain {screen_key_values}" + ) + + screens_by_key = { + sequence.screen.payload.header.key: sequence + for sequence in snapshot.screens + } + for sequence in snapshot.screens: + header = sequence.screen.payload.header + if ( + header.cursor_x >= terminal_header.columns + or header.cursor_y >= terminal_header.rows + or ( + header.cursor_flags.pending_wrap + and header.cursor_x != terminal_header.columns - 1 + ) + ): + raise ValueError(f"SCREEN key {header.key}: invalid cursor position") + + for sequence in snapshot.histories: + payload = sequence.history.payload + screen = screens_by_key[payload.key] + screen_rows = sum( + page.payload.header.rows for page in screen.pages + ) + if screen_rows < terminal_header.rows: + raise ValueError( + f"SCREEN key {payload.key}: PAGE rows do not cover " + "the active area" + ) + overlap_rows = screen_rows - terminal_header.rows + if payload.screen_overlap_rows != overlap_rows: + raise ValueError( + f"HISTORY key {payload.key}: screen_overlap_rows " + f"{payload.screen_overlap_rows} does not match {overlap_rows}" + ) + + total_rows = payload.screen_overlap_rows + sum( + page.payload.header.rows for page in sequence.pages + ) + if total_rows != payload.total_rows: + raise ValueError( + f"HISTORY key {payload.key}: total_rows " + f"{payload.total_rows} does not match {total_rows}" + ) + + for record in all_snapshot_records(snapshot): + if not hasattr(record, "payload"): + continue + payload = record.payload + if not hasattr(payload, "styles"): + continue + style_ids = [entry.encoded_id for entry in payload.styles] + hyperlink_ids = [entry.encoded_id for entry in payload.hyperlinks] + if len(set(style_ids)) != len(style_ids): + raise ValueError("PAGE contains a duplicate style ID") + if len(set(hyperlink_ids)) != len(hyperlink_ids): + raise ValueError("PAGE contains a duplicate hyperlink ID") + + offset = SNAPSHOT_ENVELOPE_SIZE + for record in all_snapshot_records(snapshot): + if record is snapshot.ready: + expected = blake3(data[:offset]).digest() + if record.payload.prefix_digest != expected: + raise ValueError("READY BLAKE3-256 digest does not match") + elif record is snapshot.finish: + expected = blake3(data[:offset]).digest() + if record.payload.prefix_digest != expected: + raise ValueError("FINISH BLAKE3-256 digest does not match") + offset = validate_record(record, data, offset) + + if offset != len(data): + raise ValueError( + f"complete snapshot accounted for {offset} of {len(data)} bytes" + ) + + +def compile_schema(output_dir: Path) -> type[Any]: + """Compile snapshot.ksy and import its temporary Python parser.""" + compiler = shutil.which("kaitai-struct-compiler") or shutil.which("ksc") + if compiler is None: + raise SystemExit( + "kaitai-struct-compiler is not available; run this inside " + "`nix develop`" + ) + + subprocess.run( + [ + compiler, + "--target=python", + f"--outdir={output_dir}", + str(SCHEMA_PATH), + ], + check=True, + ) + + sys.path.insert(0, str(output_dir)) + try: + module = importlib.import_module("ghostty_snapshot") + finally: + sys.path.pop(0) + return module.GhosttySnapshot + + +def resolve_type(parser: type[Any], type_name: str) -> type[Any]: + """Resolve one KSY snake-case type name on the generated parser.""" + root_type_name = "".join( + f"_{character.lower()}" if character.isupper() else character + for character in parser.__name__ + ).lstrip("_") + if type_name == root_type_name: + return parser + + class_name = "".join( + part[:1].upper() + part[1:] for part in type_name.split("_") + ) + try: + return getattr(parser, class_name) + except AttributeError as error: + raise ValueError(f"snapshot.ksy has no type {type_name!r}") from error + + +def main() -> int: + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument( + "--write-binary", + nargs=2, + metavar=("FIXTURE", "OUTPUT"), + type=Path, + help="decode one annotated fixture into a raw binary", + ) + args = cli.parse_args() + + if args.write_binary: + fixture_path, output_path = args.write_binary + fixture = load_fixture(fixture_path) + output_path.write_bytes(fixture.data) + print(f"wrote {len(fixture.data)} bytes to {output_path}") + return 0 + + with tempfile.TemporaryDirectory(prefix="ghostty-kaitai-") as temp: + parser = compile_schema(Path(temp)) + + fixture_paths = sorted(TESTDATA_DIR.rglob("*.hex")) + if not fixture_paths: + raise ValueError(f"{TESTDATA_DIR}: no snapshot fixtures") + + for path in fixture_paths: + fixture = load_fixture(path) + parser_type = resolve_type(parser, fixture.type_name) + fixture_label = path.relative_to(TESTDATA_DIR) + try: + parsed = parse_type( + parser_type, + fixture.data[fixture.offset:], + *fixture.params, + ) + except Exception as error: + raise ValueError( + f"{fixture_label}: Kaitai parse failed" + ) from error + + if parser_type is parser: + if fixture.offset != 0: + raise ValueError( + f"{fixture_label}: root parser must begin at offset zero" + ) + validate_complete_snapshot(parsed, fixture.data) + elif hasattr(parsed, "_raw_payload"): + record_end = validate_record( + parsed, + fixture.data, + fixture.offset, + ) + if record_end != len(fixture.data): + raise ValueError( + f"{fixture_label}: record has trailing data" + ) + if hasattr(parsed.payload, "prefix_digest"): + expected = blake3( + fixture.data[:fixture.offset] + ).digest() + if parsed.payload.prefix_digest != expected: + raise ValueError( + f"{fixture_label}: checkpoint digest does not match" + ) + + print(f"ok {fixture_label}") + + print(f"validated {len(fixture_paths)} snapshot fixtures") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())