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.
This commit is contained in:
Mitchell Hashimoto
2026-07-30 21:02:19 -07:00
parent 13bc78b7f3
commit 38d92c50c9
25 changed files with 1553 additions and 51 deletions

View File

@@ -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"
'');
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 = .{

View File

@@ -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 = .{

View File

@@ -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 = .{

View File

@@ -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 = .{

View File

@@ -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

View File

@@ -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 = .{

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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("<HII")
@dataclass(frozen=True)
class Fixture:
path: Path
type_name: str
params: tuple[object, ...]
offset: int
data: bytes
def load_fixture(path: Path) -> 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(
"<HII",
header.tag,
header.payload_length,
header.crc32c,
)
if encoded_header != expected_header:
raise ValueError(f"record at 0x{offset:x}: parsed header drift")
covered = struct.pack("<HI", header.tag, header.payload_length) + payload
actual_crc = crc32c(covered)
if header.crc32c != actual_crc:
raise ValueError(
f"record at 0x{offset:x}: CRC32C 0x{header.crc32c:08x} "
f"does not match 0x{actual_crc:08x}"
)
return offset + RECORD_HEADER_SIZE + len(payload)
def all_snapshot_records(snapshot: Any) -> 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())