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())