diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bc0de2691..ff60754c3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -111,6 +111,7 @@ jobs: - test-sentry-linux - test-i18n - test-fuzz-libghostty + - test-kaitai - test-lib-vt - test-lib-vt-pkgconfig - test-macos @@ -1319,6 +1320,31 @@ jobs: - name: Test run: nix develop -c zig build test-lib-vt + test-kaitai: + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' + needs: skip + runs-on: namespace-profile-ghostty-xsm + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 + with: + path: /nix + + # Install Nix so the verifier uses the pinned compiler and runtimes. + - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Verify Snapshot Kaitai Schema + run: nix develop -c src/terminal/snapshot/verify-kaitai.py + test-gtk: strategy: fail-fast: false 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/Screen.zig b/src/terminal/Screen.zig index 1acb30c16..8cc2787e1 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -8,6 +8,7 @@ const ansi = @import("ansi.zig"); const charsets = @import("charsets.zig"); const fastmem = @import("../fastmem.zig"); const kitty = @import("kitty.zig"); +const lib = @import("lib.zig"); const sgr = @import("sgr.zig"); const tripwire = @import("../tripwire.zig"); const unicode = @import("../unicode/main.zig"); @@ -114,7 +115,12 @@ pub const SemanticPrompt = struct { .click = .none, }; - pub const SemanticClick = union(enum) { + pub const SemanticClickKind = lib.Enum( + lib.target, + &.{ "none", "click_events", "cl" }, + ); + + pub const SemanticClick = union(SemanticClickKind) { none, click_events: osc.semantic_prompt.ClickEvents, cl: osc.semantic_prompt.Click, diff --git a/src/terminal/ScreenSet.zig b/src/terminal/ScreenSet.zig index 64e95e70c..28fced985 100644 --- a/src/terminal/ScreenSet.zig +++ b/src/terminal/ScreenSet.zig @@ -30,9 +30,9 @@ active: *Screen, /// All screens that are initialized. all: std.EnumMap(Key, *Screen), -/// Monotonic generation counter for each screen key. This changes whenever a -/// screen is removed so external handles can distinguish a newly initialized -/// screen from stale references into destroyed screen storage. +/// Monotonic generation counter for each screen key. This changes whenever +/// screen storage is removed or replaced so external handles can distinguish a +/// newly initialized screen from stale references into destroyed storage. generations: std.EnumMap(Key, usize), pub fn init( diff --git a/src/terminal/ansi.zig b/src/terminal/ansi.zig index 4e777b7c6..4e28995d5 100644 --- a/src/terminal/ansi.zig +++ b/src/terminal/ansi.zig @@ -99,8 +99,11 @@ pub const ModifyKeyFormat = lib.Enum( /// The protection modes that can be set for the terminal. See DECSCA and /// ESC V, W. -pub const ProtectedMode = enum { - off, - iso, // ESC V, W - dec, // CSI Ps " q -}; +pub const ProtectedMode = lib.Enum( + lib.target, + &.{ + "off", + "iso", // ESC V, W + "dec", // CSI Ps " q + }, +); diff --git a/src/terminal/cursor.zig b/src/terminal/cursor.zig index 136ee085a..f5f66075a 100644 --- a/src/terminal/cursor.zig +++ b/src/terminal/cursor.zig @@ -1,15 +1,25 @@ +const lib = @import("../lib/main.zig"); + /// The visual style of the cursor. Whether or not it blinks /// is determined by mode 12 (modes.zig). This mode is synchronized /// with CSI q, the same as xterm. -pub const Style = enum { - bar, // DECSCUSR 5, 6 - block, // DECSCUSR 1, 2 - underline, // DECSCUSR 3, 4 +/// +/// Bar, block, and underline correspond to DECSCUSR 5/6, 1/2, and 3/4. +/// Hollow block is Ghostty-specific and is reported as DECSCUSR 1 or 2. +pub const Style = lib.Enum(.zig, &.{ + // DECSCUSR 5, 6 + "bar", - /// The cursor styles below aren't known by DESCUSR and are custom - /// implemented in Ghostty. They are reported as some standard style - /// if requested, though. - /// Hollow block cursor. This is a block cursor with the center empty. - /// Reported as DECSCUSR 1 or 2 (block). - block_hollow, -}; + // DECSCUSR 1, 2 + "block", + + // DECSCUSR 3, 4 + "underline", + + // The cursor styles below aren't known by DESCUSR and are custom + // implemented in Ghostty. They are reported as some standard style + // if requested, though. + // Hollow block cursor. This is a block cursor with the center empty. + // Reported as DECSCUSR 1 or 2 (block). + "block_hollow", +}); diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig index 35a16a2ae..68f914f89 100644 --- a/src/terminal/hyperlink.zig +++ b/src/terminal/hyperlink.zig @@ -194,12 +194,12 @@ pub const PageEntry = struct { const alloc = &page.string_alloc; switch (self.id) { .implicit => {}, - .explicit => |v| alloc.free( + .explicit => |v| if (v.len > 0) alloc.free( page.memory, v.slice(page.memory), ), } - alloc.free( + if (self.uri.len > 0) alloc.free( page.memory, self.uri.slice(page.memory), ); diff --git a/src/terminal/main.zig b/src/terminal/main.zig index af277f976..cee4bcb94 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -21,6 +21,7 @@ pub const modes = @import("modes.zig"); pub const page = @import("page.zig"); pub const parse_table = @import("parse_table.zig"); pub const search = @import("search.zig"); +pub const snapshot = @import("snapshot/main.zig"); pub const sgr = @import("sgr.zig"); pub const size = @import("size.zig"); pub const size_report = @import("size_report.zig"); diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index 97212fbb3..e060f8bf4 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -1,6 +1,7 @@ //! https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md const std = @import("std"); +const lib = @import("../../lib.zig"); const Parser = @import("../../osc.zig").Parser; const OSCCommand = @import("../../osc.zig").Command; const string_encoding = @import("../../../os/string_encoding.zig"); @@ -68,7 +69,10 @@ pub const Command = struct { // See https://github.com/ghostty-org/ghostty/issues/10865 and // https://github.com/kovidgoyal/kitty/issues/9500 // for further details. -pub const ClickEvents = enum { absolute, relative }; +pub const ClickEvents = lib.Enum( + lib.target, + &.{ "absolute", "relative" }, +); pub const Option = enum { aid, @@ -199,7 +203,7 @@ pub const Option = enum { return switch (self) { .aid => value, - .cl => .init(value), + .cl => parseClick(value), .prompt_kind => if (value.len == 1) PromptKind.init(value[0]) else null, .err => value, .redraw => if (std.mem.eql(u8, value, "0")) @@ -234,42 +238,50 @@ pub const Option = enum { /// The `cl` option specifies what kind of cursor key sequences are handled /// by the application for click-to-move-cursor functionality. -pub const Click = enum { - /// Value: "line". Allows motion within a single input line using standard - /// left/right arrow escape sequences. Only a single left/right sequence - /// should be emitted for double-width characters. - line, +/// +/// `line` allows movement within one input line. `multiple` allows movement +/// across lines with left/right sequences. The two vertical modes additionally +/// allow up/down sequences, with `smart_vertical` permitting editor-aware +/// column clamping. +pub const Click = lib.Enum( + lib.target, + &.{ + // Value: "line". Allows motion within a single input line using + // standard left/right arrow escape sequences. Only a single left/right + // sequence should be emitted for double-width characters. + "line", - /// Value: "m". Allows movement between different lines in the same group, - /// but only using left/right arrow escape sequences. - multiple, + // Value: "m". Allows movement between different lines in the same + // group, but only using left/right arrow escape sequences. + "multiple", - /// Value: "v". Like `multiple` but cursor up/down should be used. The - /// terminal should be conservative when moving between lines: move the - /// cursor left to the start of line, emit the needed up/down sequences, - /// then move the cursor right to the clicked destination. - conservative_vertical, + // Value: "v". Like `multiple` but cursor up/down should be used. The + // terminal should be conservative when moving between lines: move the + // cursor left to the start of line, emit the needed up/down sequences, + // then move the cursor right to the clicked destination. + "conservative_vertical", - /// Value: "w". Like `conservative_vertical` but specifies that there are - /// no spurious spaces at the end of the line, and the application editor - /// handles "smart vertical movement" (moving 2 lines up from position 20, - /// where the intermediate line is 15 chars wide and the destination is - /// 18 chars wide, ends at position 18). - smart_vertical, + // Value: "w". Like `conservative_vertical` but specifies that there + // are no spurious spaces at the end of the line, and the application + // editor handles "smart vertical movement" (moving 2 lines up from + // position 20, where the intermediate line is 15 chars wide and the + // destination is 18 chars wide, ends at position 18). + "smart_vertical", + }, +); - pub fn init(value: []const u8) ?Click { - return if (value.len == 1) switch (value[0]) { - 'm' => .multiple, - 'v' => .conservative_vertical, - 'w' => .smart_vertical, - else => null, - } else if (std.mem.eql( - u8, - value, - "line", - )) .line else null; - } -}; +fn parseClick(value: []const u8) ?Click { + return if (value.len == 1) switch (value[0]) { + 'm' => .multiple, + 'v' => .conservative_vertical, + 'w' => .smart_vertical, + else => null, + } else if (std.mem.eql( + u8, + value, + "line", + )) .line else null; +} pub const PromptKind = enum { initial, diff --git a/src/terminal/snapshot/AGENTS.md b/src/terminal/snapshot/AGENTS.md new file mode 100644 index 000000000..0ce37b5ed --- /dev/null +++ b/src/terminal/snapshot/AGENTS.md @@ -0,0 +1,7 @@ +# Terminal Binary Snapshot + +- Apply the robustness principle: "be conservative in what you do, be liberal + in what you accept from others." + - Validate on encode + - Gracefully degrade on decode, for example invalid styles degrade to + ignoring the style. diff --git a/src/terminal/snapshot/checkpoint.zig b/src/terminal/snapshot/checkpoint.zig new file mode 100644 index 000000000..79b04242f --- /dev/null +++ b/src/terminal/snapshot/checkpoint.zig @@ -0,0 +1,275 @@ +//! READY and FINISH snapshot checkpoint records. +//! +//! Each checkpoint payload is one BLAKE3-256 digest of every snapshot byte +//! before that checkpoint's record header. This binds record order and detects +//! omitted or duplicated records in ways that independent record CRCs cannot. +//! +//! READY covers the envelope, TERMINAL, and all SCREEN/PAGE sequences. FINISH +//! covers that same prefix plus the complete READY record and all HISTORY/PAGE +//! sequences. Neither digest includes its own record. FINISH terminates one +//! snapshot; bytes after it belong to the containing transport. +//! +//! The digest does not replace record framing. Its 32-byte payload is still +//! protected by the record's CRC32C. +//! +//! ## Binary Format +//! +//! READY and FINISH use the same fixed payload: +//! +//! ```text +//! 0 +----------------------------+ +//! | BLAKE3-256 prefix digest | +//! | 32 bytes | +//! 32 +----------------------------+ +//! ``` + +const std = @import("std"); +const test_fixture = @import("fixture.zig"); +const record = @import("record.zig"); + +const Blake3 = std.crypto.hash.Blake3; + +/// The prefix digest exchanged by checkpoint codecs and the snapshot driver. +pub const Digest = record.PrefixDigest; + +comptime { + std.debug.assert(@sizeOf(Digest) == 32); +} + +/// Selects one of the two checkpoint positions in a snapshot. +pub const Kind = enum { + ready, + finish, + + fn tag(self: Kind) record.Tag { + return switch (self) { + .ready => .ready, + .finish => .finish, + }; + } +}; + +pub const EncodeError = std.Io.Writer.Error || + record.Writer.FinishError; + +/// Append one checkpoint covering every byte already emitted by `stream`. +/// +/// Finalizing does not consume the running hasher. READY is therefore included +/// as the stream continues toward FINISH. +pub fn encode( + kind: Kind, + stream: *record.Writer, +) EncodeError!void { + const digest = stream.prefixDigest(); + + const payload = stream.begin(kind.tag()); + errdefer stream.cancel(); + try payload.writeAll(&digest); + try stream.finish(); +} + +pub const DecodeError = record.Reader.InitError || + record.Reader.FinishError || + error{ + /// The next record is valid but is not the expected checkpoint. + UnexpectedRecordTag, + + /// The checkpoint does not describe the preceding snapshot bytes. + InvalidDigest, + }; + +/// Decode and validate one checkpoint against the running prefix digest. +/// +/// READY is consumed through the hashing reader so FINISH covers it. FINISH is +/// consumed from the underlying source so neither checkpoint includes itself. +pub fn decode( + kind: Kind, + stream: *record.StreamReader, +) DecodeError!void { + const expected = stream.prefixDigest(); + const source = if (kind == .finish) + stream.source() + else + stream.reader(); + + var record_reader: record.Reader = undefined; + try record_reader.init(source); + if (record_reader.header.tag != kind.tag()) { + return error.UnexpectedRecordTag; + } + + var actual: Digest = undefined; + try record_reader.payloadReader().readSliceAll(&actual); + try record_reader.finish(); + if (!std.mem.eql(u8, &expected, &actual)) return error.InvalidDigest; +} + +const test_ready_fixture = test_fixture.parse( + @embedFile("testdata/checkpoint-ready-v1.hex"), +); + +test "READY golden encoding and BLAKE3-256 registry" { + const prefix = "abc"; + + var snapshot: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer snapshot.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &snapshot.writer, + ); + defer stream.deinit(); + try stream.writer().writeAll(prefix); + try encode(.ready, &stream); + + try test_fixture.expectEqual( + .bytes, + "src/terminal/snapshot/testdata/checkpoint-ready-v1.hex", + "snapshot_fixture-checkpoint-ready-v1.hex", + &test_ready_fixture, + snapshot.written(), + ); + + // The checked-in payload independently locks the BLAKE3-256 registry. + var actual: Digest = undefined; + Blake3.hash(prefix, &actual, .{}); + const payload_offset = prefix.len + record.Header.len; + try std.testing.expectEqualSlices( + u8, + test_ready_fixture[payload_offset..][0..actual.len], + &actual, + ); + + // Finalizing a streaming hasher neither consumes nor resets it. + var hasher = Blake3.init(.{}); + hasher.update("a"); + var prefix_digest: Digest = undefined; + Blake3.hash("a", &prefix_digest, .{}); + hasher.final(&actual); + try std.testing.expectEqual(prefix_digest, actual); + + hasher.update("bc"); + hasher.final(&actual); + try std.testing.expectEqualSlices( + u8, + test_ready_fixture[payload_offset..][0..actual.len], + &actual, + ); + + // Decode the checked-in record rather than the generated candidate. + var source: std.Io.Reader = .fixed(&test_ready_fixture); + var source_stream: record.StreamReader = .init(&source); + try source_stream.reader().discardAll(prefix.len); + try decode(.ready, &source_stream); +} + +test "READY and FINISH checkpoint coverage" { + const testing = std.testing; + + var snapshot: std.Io.Writer.Allocating = .init(testing.allocator); + defer snapshot.deinit(); + var stream: record.Writer = .init( + testing.allocator, + &snapshot.writer, + ); + defer stream.deinit(); + try stream.writer().writeAll("prefix"); + + // READY covers only the bytes that precede its own record. + const ready_offset = snapshot.written().len; + const ready_digest = stream.prefixDigest(); + try encode(.ready, &stream); + + // History follows READY and is included by FINISH. + try stream.writer().writeAll("history"); + const finish_offset = snapshot.written().len; + const finish_digest = stream.prefixDigest(); + try encode(.finish, &stream); + + // The running stream reaches both checkpoints without rehashing its prefix. + var source: std.Io.Reader = .fixed(snapshot.written()); + var source_stream: record.StreamReader = .init(&source); + try source_stream.reader().discardAll(ready_offset); + try testing.expectEqual(ready_digest, source_stream.prefixDigest()); + try decode(.ready, &source_stream); + try source_stream.reader().discardAll("history".len); + try testing.expectEqual(finish_digest, source_stream.prefixDigest()); + try decode(.finish, &source_stream); + + try testing.expectEqual( + snapshot.written().len - finish_offset, + record.Header.len + @sizeOf(Digest), + ); +} + +test "checkpoint rejects wrong tags and digests" { + const testing = std.testing; + + var snapshot: std.Io.Writer.Allocating = .init(testing.allocator); + defer snapshot.deinit(); + var stream: record.Writer = .init( + testing.allocator, + &snapshot.writer, + ); + defer stream.deinit(); + try stream.writer().writeAll("prefix"); + const checkpoint_offset = snapshot.written().len; + try encode(.ready, &stream); + + var wrong_tag_source: std.Io.Reader = .fixed( + snapshot.written()[checkpoint_offset..], + ); + var wrong_tag: record.StreamReader = .init(&wrong_tag_source); + try testing.expectError( + error.UnexpectedRecordTag, + decode(.finish, &wrong_tag), + ); + + // Build a correctly framed checkpoint containing an unrelated digest. + var invalid: std.Io.Writer.Allocating = .init(testing.allocator); + defer invalid.deinit(); + var invalid_stream: record.Writer = .init( + testing.allocator, + &invalid.writer, + ); + defer invalid_stream.deinit(); + try invalid_stream.writer().writeAll("prefix"); + var invalid_digest = invalid_stream.prefixDigest(); + invalid_digest[0] ^= 1; + const invalid_payload = invalid_stream.begin(.ready); + errdefer invalid_stream.cancel(); + try invalid_payload.writeAll(&invalid_digest); + try invalid_stream.finish(); + + var invalid_digest_source: std.Io.Reader = .fixed(invalid.written()); + var invalid_digest_stream: record.StreamReader = .init( + &invalid_digest_source, + ); + try invalid_digest_stream.reader().discardAll("prefix".len); + try testing.expectError( + error.InvalidDigest, + decode(.ready, &invalid_digest_stream), + ); +} + +test "FINISH leaves continuation bytes unread" { + const testing = std.testing; + + var finished: std.Io.Writer.Allocating = .init(testing.allocator); + defer finished.deinit(); + var finished_stream: record.Writer = .init( + testing.allocator, + &finished.writer, + ); + defer finished_stream.deinit(); + try finished_stream.writer().writeAll("prefix"); + try encode(.finish, &finished_stream); + try finished.writer.writeByte(0); + + var trailing_source: std.Io.Reader = .fixed(finished.written()); + var trailing_stream: record.StreamReader = .init( + &trailing_source, + ); + try trailing_stream.reader().discardAll("prefix".len); + try decode(.finish, &trailing_stream); + try testing.expectEqual(@as(u8, 0), try trailing_source.takeByte()); +} diff --git a/src/terminal/snapshot/envelope.zig b/src/terminal/snapshot/envelope.zig new file mode 100644 index 000000000..21fcefb9d --- /dev/null +++ b/src/terminal/snapshot/envelope.zig @@ -0,0 +1,104 @@ +//! Snapshot envelope. +//! +//! Every snapshot binary blob begins with exactly one envelope at byte zero. +//! It is followed by a set of records. Each record provides their own +//! tag, payload length, CRC, etc. +//! +//! The envelope identifies the bytes as a terminal snapshot and selects the +//! single version governing the entire blob. A decoder validates both fields +//! before reading any records. +//! +//! The envelope is exactly ten bytes. All integers are unsigned and +//! little-endian. +//! +//! | Offset | Size | Field | +//! | -----: | ---: | :------------------- | +//! | 0 | 8 | Magic (`GHOSTSNP`) | +//! | 8 | 2 | Version (`u16`) | + +const std = @import("std"); +const test_fixture = @import("fixture.zig"); +const io = @import("io.zig"); + +/// Identifies a Ghostty terminal snapshot and rejects unrelated input before +/// any record decoding begins. All eight bytes are part of the wire value. +pub const magic = "GHOSTSNP"; + +/// The complete compatibility boundary for snapshot layout and behavior. +/// Version 1 readers require this value to match exactly. +pub const version: u16 = 1; + +/// Number of bytes in the fixed envelope: magic followed by version. +pub const encoded_len = computeLen(); + +comptime { + // We expect this so if it changes we should think carefully. + std.debug.assert(encoded_len == 10); +} + +pub const DecodeError = std.Io.Reader.Error || error{ + InvalidMagic, + UnsupportedVersion, +}; + +/// Encode the envelope. +pub fn encode(writer: *std.Io.Writer) std.Io.Writer.Error!void { + try writer.writeAll(magic); + try io.writeInt(writer, u16, version); +} + +/// Decode and validate the envelope. +pub fn decode(reader: *std.Io.Reader) DecodeError!void { + var actual_magic: [magic.len]u8 = undefined; + try reader.readSliceAll(&actual_magic); + if (!std.mem.eql(u8, magic, &actual_magic)) return error.InvalidMagic; + + const actual_version = try io.readInt(reader, u16); + if (actual_version != version) return error.UnsupportedVersion; +} + +fn computeLen() usize { + comptime { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + encode(&writer) catch unreachable; + return writer.end; + } +} + +const test_golden_fixture = test_fixture.parse(@embedFile("testdata/envelope-v1.hex")); + +test "golden encoding" { + var buf: [encoded_len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer); + + try test_fixture.expectEqual( + .bytes, + "src/terminal/snapshot/testdata/envelope-v1.hex", + "snapshot_fixture-envelope-v1.hex", + &test_golden_fixture, + writer.buffered(), + ); + + var reader: std.Io.Reader = .fixed(&test_golden_fixture); + try decode(&reader); +} + +test "reject invalid magic and version" { + var invalid_magic: std.Io.Reader = .fixed("GHOSTSNX\x01\x00"); + try std.testing.expectError(error.InvalidMagic, decode(&invalid_magic)); + + var invalid_version: std.Io.Reader = .fixed("GHOSTSNP\x00\x00"); + try std.testing.expectError( + error.UnsupportedVersion, + decode(&invalid_version), + ); +} + +test "reject every truncation" { + for (0..encoded_len) |len| { + var reader: std.Io.Reader = .fixed(test_golden_fixture[0..len]); + try std.testing.expectError(error.EndOfStream, decode(&reader)); + } +} diff --git a/src/terminal/snapshot/fixture.zig b/src/terminal/snapshot/fixture.zig new file mode 100644 index 000000000..d5c86b2b7 --- /dev/null +++ b/src/terminal/snapshot/fixture.zig @@ -0,0 +1,479 @@ +//! Test-only support for reviewable snapshot golden fixtures. +//! +//! Snapshot compatibility references are stored as annotated hexadecimal text +//! under `testdata/`. Each byte is written as exactly two hexadecimal digits. +//! Whitespace is insignificant, and `#` begins a comment through the end of the +//! line. This permits field, record, and absolute-offset annotations without +//! changing the bytes embedded by a test. +//! +//! `parse` validates and decodes an `@embedFile` at comptime. Tests should +//! encode their native value, compare the generated bytes with that embedded +//! reference using `expectEqual`, and then decode the reference itself. This +//! keeps encoding regression coverage separate from backwards-decoding +//! coverage. +//! +//! `expectEqual` formats a fresh candidate on every run. Matching candidates +//! remain in the test's temporary directory. A mismatch copies the candidate +//! to the repository root, reports its path and first differing byte, and +//! fails without modifying the checked-in reference. After reviewing an +//! intentional wire change, a maintainer manually replaces the reference. +//! Fixture filenames include the snapshot version so a future format adds new +//! references instead of overwriting compatibility data for an older version. +//! +//! A typical fixed-value test looks like this: +//! +//! ```zig +//! const fixture = @import("fixture.zig"); +//! +//! const reference = fixture.parse( +//! @embedFile("testdata/example-v1.hex"), +//! ); +//! +//! test "example golden encoding and decoding" { +//! var encoded: [Example.len]u8 = undefined; +//! var writer: std.Io.Writer = .fixed(&encoded); +//! try Example.encode(value, &writer); +//! +//! try fixture.expectEqual( +//! .bytes, +//! "src/terminal/snapshot/testdata/example-v1.hex", +//! "snapshot_fixture-example-v1.hex", +//! &reference, +//! writer.buffered(), +//! ); +//! +//! var reader: std.Io.Reader = .fixed(&reference); +//! try std.testing.expectEqualDeep( +//! value, +//! try Example.decode(&reader), +//! ); +//! } +//! ``` +//! +//! ## 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"); +const record = @import("record.zig"); + +const log = std.log.scoped(.snapshot_fixture); + +pub const Kind = enum { + bytes, + page, + snapshot, +}; + +/// Decode an annotated hexadecimal fixture at comptime. +/// +/// Whitespace is ignored. `#` begins a comment that continues through the end +/// of the line. Every other token must be exactly two hexadecimal digits. +pub fn parse(comptime source: []const u8) [decodedLen(source)]u8 { + comptime { + // Large complete-snapshot fixtures exceed Zig's default comptime + // branch budget while scanning their comments and byte tokens. + @setEvalBranchQuota(100_000_000); + + // The first pass in decodedLen gives this result its exact array type. + // Keeping it an array lets callers use the fixture in comptime slices. + var result: [decodedLen(source)]u8 = undefined; + var source_index: usize = 0; + var result_index: usize = 0; + + while (source_index < source.len) { + // Comments and layout are purely for reviewers and contribute no + // bytes to the embedded reference. + skipIgnored(source, &source_index); + if (source_index == source.len) break; + + // Every data token is one complete byte. Parse it directly rather + // than accepting variable-width integers or other hex syntax. + if (source_index + 1 >= source.len) { + @compileError("snapshot fixture ends with one hex digit"); + } + result[result_index] = + hexNibble(source[source_index]) << 4 | + hexNibble(source[source_index + 1]); + result_index += 1; + source_index += 2; + + // Requiring a separator catches accidental third digits and makes + // the accepted grammar match the formatter below. + if (source_index < source.len and + !std.ascii.isWhitespace(source[source_index]) and + source[source_index] != '#') + { + @compileError( + "snapshot fixture hex bytes must be separated by whitespace", + ); + } + } + + return result; + } +} + +/// Compare generated bytes with a checked-in reference. +/// +/// A formatted candidate is always created in a temporary directory. On a +/// mismatch it is copied into the repository root before the test fails. +pub fn expectEqual( + kind: Kind, + reference_path: []const u8, + candidate_name: []const u8, + expected: []const u8, + actual: []const u8, +) !void { + const testing = std.testing; + const alloc = testing.allocator; + + 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. + { + var candidate_file = try tmp.dir.createFile( + testing.io, + candidate_name, + .{}, + ); + defer candidate_file.close(testing.io); + var write_buffer: [4096]u8 = undefined; + var candidate_writer = candidate_file.writer( + testing.io, + &write_buffer, + ); + try format( + kind, + reference_source, + actual, + &candidate_writer.interface, + ); + try candidate_writer.interface.flush(); + } + + // Matching candidates remain in the temporary directory and disappear + // with its cleanup. + if (std.mem.eql(u8, expected, actual)) return; + + // The test temp directory is not necessarily below the working directory. + // Resolve both paths before copying the candidate to the stable, + // user-visible repository-root name. + const tmp_path = try tmp.dir.realPathFileAlloc( + testing.io, + candidate_name, + alloc, + ); + defer alloc.free(tmp_path); + const cwd = try std.Io.Dir.cwd().realPathFileAlloc( + testing.io, + ".", + alloc, + ); + defer alloc.free(cwd); + const candidate_path = try std.fs.path.join( + alloc, + &.{ cwd, candidate_name }, + ); + defer alloc.free(candidate_path); + try std.Io.Dir.copyFileAbsolute( + tmp_path, + candidate_path, + testing.io, + .{}, + ); + + // Report the first useful comparison point. When one byte sequence is a + // prefix of the other, this is the shared length where they diverge. + const difference = difference: { + const shared_len = @min(expected.len, actual.len); + for ( + expected[0..shared_len], + actual[0..shared_len], + 0.., + ) |expected_byte, actual_byte, index| { + if (expected_byte != actual_byte) break :difference index; + } + break :difference shared_len; + }; + log.err( + "snapshot fixture differs reference={s} candidate={s} " ++ + "offset=0x{x} expected_len={} actual_len={}", + .{ + reference_path, + candidate_path, + difference, + expected.len, + actual.len, + }, + ); + return error.TestExpectedEqual; +} + +fn decodedLen(comptime source: []const u8) usize { + comptime { + // This is a complete validation pass, not just a count. Invalid syntax + // therefore fails before parse allocates and fills its result array. + @setEvalBranchQuota(100_000_000); + + var index: usize = 0; + var result: usize = 0; + while (index < source.len) { + skipIgnored(source, &index); + if (index == source.len) break; + + if (index + 1 >= source.len) { + @compileError("snapshot fixture ends with one hex digit"); + } + _ = hexNibble(source[index]); + _ = hexNibble(source[index + 1]); + result += 1; + index += 2; + + if (index < source.len and + !std.ascii.isWhitespace(source[index]) and + source[index] != '#') + { + @compileError( + "snapshot fixture hex bytes must be separated by whitespace", + ); + } + } + return result; + } +} + +fn skipIgnored(comptime source: []const u8, index: *usize) void { + while (index.* < source.len) { + // Layout whitespace is unrestricted so fixtures can group fields and + // keep deterministic sixteen-byte output lines. + if (std.ascii.isWhitespace(source[index.*])) { + index.* += 1; + continue; + } + + // A comment consumes the rest of its line. The newline itself is + // consumed by the whitespace case on the next iteration. + if (source[index.*] != '#') return; + while (index.* < source.len and source[index.*] != '\n') { + index.* += 1; + } + } +} + +fn hexNibble(comptime value: u8) u8 { + return switch (value) { + '0'...'9' => value - '0', + 'a'...'f' => value - 'a' + 10, + 'A'...'F' => value - 'A' + 10, + else => @compileError("snapshot fixture contains a non-hex digit"), + }; +} + +fn format( + kind: Kind, + reference_source: []const u8, + bytes: []const u8, + writer: *std.Io.Writer, +) (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( + "# 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}, + ); + + // Small values only need a hex dump. Larger compound formats get + // structure-aware section labels without changing their decoded bytes. + switch (kind) { + .bytes => try writeSection( + "encoded bytes", + bytes, + 0, + bytes.len, + writer, + ), + .page => try formatPage(bytes, writer), + .snapshot => try formatSnapshot(bytes, writer), + } +} + +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, +) std.Io.Writer.Error!void { + // PAGE version 1 has a twenty-byte fixed header. The remainder is + // self-describing through the counts and dimensions in that header. + const header_end = @min(bytes.len, 20); + try writeSection("PAGE header", bytes, 0, header_end, writer); + if (header_end < bytes.len) { + try writeSection( + "style and hyperlink tables, rows, and cells", + bytes, + header_end, + bytes.len, + writer, + ); + } +} + +fn formatSnapshot( + bytes: []const u8, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { + // A complete snapshot begins with its unframed envelope. Clamp this first + // section so a partially generated candidate can still be inspected. + const envelope_end = @min(bytes.len, envelope.encoded_len); + try writeSection("snapshot envelope", bytes, 0, envelope_end, writer); + + // Walk record framing only far enough to annotate boundaries. The emitted + // hex remains authoritative; this formatter never repairs candidate data. + var offset: usize = envelope_end; + while (offset < bytes.len) { + // A partial header cannot provide a safe payload boundary, so preserve + // the remaining bytes as one explicitly incomplete section. + if (bytes.len - offset < record.Header.len) { + try writeSection( + "trailing partial record", + bytes, + offset, + bytes.len, + writer, + ); + return; + } + + // Tag and payload length are the first six bytes of every record + // header. CRC validation belongs to the codec test, not this formatter. + const tag_raw = std.mem.readInt( + u16, + bytes[offset..][0..2], + .little, + ); + const payload_len: u32 = std.mem.readInt( + u32, + bytes[offset + 2 ..][0..4], + .little, + ); + + // Clamp the section to the available candidate bytes. This makes a + // truncated payload reviewable instead of indexing beyond the slice. + const record_end: usize = @min( + bytes.len, + offset + record.Header.len + @as(usize, payload_len), + ); + + // Known tags make the annotated diff readable. Preserve unknown tags + // numerically so formatter output is still useful for encoder bugs. + if (std.enums.fromInt(record.Tag, tag_raw)) |tag| { + try writer.print( + "\n# offset 0x{x:0>8}: {s} record, payload {} bytes\n", + .{ offset, @tagName(tag), payload_len }, + ); + } else { + try writer.print( + "\n# offset 0x{x:0>8}: unknown record {}, payload {} bytes\n", + .{ offset, tag_raw, payload_len }, + ); + } + try writeHex(bytes, offset, record_end, writer); + offset = record_end; + } +} + +fn writeSection( + name: []const u8, + bytes: []const u8, + start: usize, + end: usize, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { + // Sections begin on a fresh line and carry their absolute wire offset. + try writer.print( + "\n# offset 0x{x:0>8}: {s}\n", + .{ start, name }, + ); + try writeHex(bytes, start, end, writer); +} + +fn writeHex( + bytes: []const u8, + start: usize, + end: usize, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { + const digits = "0123456789abcdef"; + var offset = start; + while (offset < end) { + // Lowercase, sixteen-byte lines provide stable textual diffs. The + // trailing offset is a comment and is ignored by parse. + const line_end = @min(end, offset + 16); + for (bytes[offset..line_end], 0..) |byte, index| { + if (index != 0) try writer.writeByte(' '); + try writer.writeByte(digits[byte >> 4]); + try writer.writeByte(digits[byte & 0x0f]); + } + try writer.print(" # 0x{x:0>8}\n", .{offset}); + offset = line_end; + } +} diff --git a/src/terminal/snapshot/grid.zig b/src/terminal/snapshot/grid.zig new file mode 100644 index 000000000..18e7955ba --- /dev/null +++ b/src/terminal/snapshot/grid.zig @@ -0,0 +1,804 @@ +//! Grid (rows and cells) encoding. +//! +//! A grid contains the rows and cells of a terminal page. Its dimensions are +//! supplied by the containing record rather than repeated here. The encoder +//! writes exactly `rows` row records, and every row contains exactly `columns` +//! cell records. +//! +//! All records are tightly packed with no padding between them. All integers +//! are unsigned and little-endian. +//! +//! ## Row +//! +//! Each row has the following format: +//! +//! | Offset | Size | Field | +//! | -----: | -------: | :-------------------------- | +//! | 0 | 1 | Row flags | +//! | 1 | variable | Exactly `columns` cells | +//! +//! Cells are encoded consecutively. Since cells may contain a variable number +//! of grapheme codepoints, the next row begins immediately after the final +//! cell and its grapheme codepoints. +//! +//! The row flag byte has the following format: +//! +//! | Bits | Field | +//! | ---: | :------------------------ | +//! | 0 | Wrap | +//! | 1 | Wrap continuation | +//! | 2-3 | Semantic prompt | +//! | 4-7 | Reserved, zero | +//! +//! Semantic prompt values are: +//! +//! | Value | Meaning | +//! | ----: | :------------------ | +//! | 0 | None | +//! | 1 | Prompt | +//! | 2 | Prompt continuation | +//! +//! Value 3 is not emitted in snapshot version 1. Decoders treat it as none. +//! +//! Native row cache flags are not encoded. In particular, the Kitty virtual +//! placeholder hint is derived while decoding cells containing U+10EEEE. +//! +//! ## Cell +//! +//! Each cell has the following format: +//! +//! | Offset | Size | Field | +//! | -----: | --------: | :---------------------------- | +//! | 0 | 1 | Content kind | +//! | 1 | 1 | Width kind | +//! | 2 | 1 | Protected and semantic flags | +//! | 3 | 1 | Reserved, zero | +//! | 4 | 2 | Style ID | +//! | 6 | 2 | Hyperlink ID | +//! | 8 | 4 | Codepoint or packed color | +//! | 12 | 4 | Grapheme suffix count | +//! | 16 | 4 * count | Grapheme suffix codepoints | +//! +//! Content kinds are: +//! +//! | Value | Meaning | +//! | ----: | :----------------- | +//! | 0 | Codepoint | +//! | 1 | Palette background | +//! | 2 | RGB background | +//! +//! For codepoint content, the value is a Unicode scalar encoded as a `u32`. +//! For a palette background, the low byte is the palette index and the other +//! three bytes are zero. For an RGB background, the low three bytes are red, +//! green, and blue, and the high byte is zero. +//! +//! Width kinds are: +//! +//! | Value | Meaning | +//! | ----: | :---------- | +//! | 0 | Narrow | +//! | 1 | Wide | +//! | 2 | Spacer tail | +//! | 3 | Spacer head | +//! +//! The cell flag byte has the following format: +//! +//! | Bits | Field | +//! | ---: | :---------------- | +//! | 0 | Protected | +//! | 1-2 | Semantic content | +//! | 3-7 | Reserved, zero | +//! +//! Semantic content values are: +//! +//! | Value | Meaning | +//! | ----: | :------ | +//! | 0 | Output | +//! | 1 | Input | +//! | 2 | Prompt | +//! +//! Value 3 is not emitted in snapshot version 1. Decoders treat it as output. +//! +//! Style and hyperlink ID zero mean no style and no hyperlink. Other IDs +//! refer to entries in the containing record's separate style and hyperlink +//! tables. +//! +//! Grapheme suffixes are valid only for codepoint content. Each suffix is a +//! Unicode scalar encoded as a `u32`. A nonzero suffix count requires a +//! nonzero base codepoint. + +const std = @import("std"); +const test_fixture = @import("fixture.zig"); +const io = @import("io.zig"); +const kitty = @import("../kitty.zig"); +const terminal_hyperlink = @import("../hyperlink.zig"); +const terminal_page = @import("../page.zig"); +const terminal_style = @import("../style.zig"); + +const TerminalCell = terminal_page.Cell; +const TerminalHyperlinkId = terminal_hyperlink.Id; +const TerminalPage = terminal_page.Page; +const TerminalRow = terminal_page.Row; +const TerminalStyleId = terminal_style.Id; + +/// Maps encoded style table IDs to IDs assigned by the destination page. +/// +/// Build this by inserting each decoded style into the page, then recording +/// the encoded ID and the ID returned by the page's style set. Style ID zero is +/// implicit and does not need an entry. +pub const StyleRemap = std.AutoHashMap(TerminalStyleId, TerminalStyleId); + +/// Maps encoded hyperlink table IDs to IDs assigned by the destination page. +/// +/// Build this by inserting each decoded hyperlink into the page, then +/// recording the encoded ID and the ID returned by the page's hyperlink set. +/// Hyperlink ID zero is implicit and does not need an entry. +pub const HyperlinkRemap = std.AutoHashMap(TerminalHyperlinkId, TerminalHyperlinkId); + +pub const EncodeError = std.Io.Writer.Error || error{ + /// Wide and spacer cells do not form a valid row. + InvalidWideCell, +}; + +/// Encode every row and cell directly from a page. +/// +/// If an error is returned, partial data may have been written. If you +/// want transactional writing, the caller is responsible for using something +/// like a seekable stream and rolling back. +pub fn encode( + page: *const TerminalPage, + writer: *std.Io.Writer, +) EncodeError!void { + defer page.assertIntegrity(); + for (0..page.size.rows) |y| { + // Row header + const row = page.getRow(y); + const row_header: RowHeader = .{ + .wrap = row.wrap, + .wrap_continuation = row.wrap_continuation, + .semantic_prompt = row.semantic_prompt, + }; + try writer.writeByte(@bitCast(row_header)); + + // Cells + const cells = page.getCells(row); + for (cells, 0..) |*cell, x| { + // Validate the wide state of this cell, we don't want + // to encode corrupt data. + switch (cell.wide) { + .narrow => {}, + .wide => if (x + 1 == cells.len or + cells[x + 1].wide != .spacer_tail) + { + return error.InvalidWideCell; + }, + .spacer_tail => if (x == 0 or + cells[x - 1].wide != .wide) + { + return error.InvalidWideCell; + }, + .spacer_head => if (x + 1 != cells.len or !row.wrap) { + return error.InvalidWideCell; + }, + } + + const graphemes: []const u21 = if (cell.hasGrapheme()) + page.lookupGrapheme(cell) orelse unreachable + else + &.{}; + + // The page has two codepoint tags depending on whether suffixes + // exist, but the wire represents both with one content kind. + const kind: CellHeader.Kind = switch (cell.content_tag) { + .codepoint, .codepoint_grapheme => .codepoint, + .bg_color_palette => .bg_color_palette, + .bg_color_rgb => .bg_color_rgb, + }; + const value: CellHeader.Value = switch (kind) { + .codepoint => .{ + .codepoint = cell.content.codepoint.data, + }, + .bg_color_palette => .{ .bg_color_palette = .{ + .index = cell.content.color_palette.data, + } }, + .bg_color_rgb => .{ .bg_color_rgb = .{ + .r = cell.content.color_rgb.r, + .g = cell.content.color_rgb.g, + .b = cell.content.color_rgb.b, + } }, + }; + + const style_id = cell.style_id; + const hyperlink_id: TerminalHyperlinkId = if (cell.hyperlink) + page.lookupHyperlink(cell) orelse unreachable + else + 0; + + const header: CellHeader = .{ + .content_kind = kind, + .width = cell.wide, + .protected = cell.protected, + .semantic_content = cell.semantic_content, + .style_id = style_id, + .hyperlink_id = hyperlink_id, + .value = value, + .grapheme_count = @intCast(graphemes.len), + }; + try header.encode(writer); + for (graphemes) |suffix| try io.writeInt( + writer, + u32, + suffix, + ); + } + } +} + +/// Decode every row and cell directly into an initialized, empty page. +/// +/// The grid does not encode dimensions, so `page` must already have the exact +/// row and column count expected by the containing record. This function reads +/// exactly `page.size.rows` rows with `page.size.cols` cells each. Capacity +/// hints are advisory. Graphemes and cell hyperlink references that do not fit +/// are discarded without affecting the rest of the grid. +/// +/// Style and hyperlink table entries must be inserted into `page` before +/// calling this function. As each table entry is inserted, the caller records +/// its encoded ID and page-assigned ID in `style_remap` or `hyperlink_remap`. +/// ID zero always means the default style or no hyperlink. A nonzero ID missing +/// from its remap is also treated as zero so unknown table references do not +/// prevent the rest of the grid from decoding. +/// +/// Invalid semantic data is normalized into a degraded form while preserving +/// the declared byte boundaries. Unknown semantic values use their neutral +/// variants, invalid Unicode becomes U+FFFD, invalid optional data is ignored, +/// and malformed wide-cell relationships become narrow cells. +pub fn decode( + page: *TerminalPage, + reader: *std.Io.Reader, + style_remap: *const StyleRemap, + hyperlink_remap: *const HyperlinkRemap, +) std.Io.Reader.Error!void { + for (0..page.size.rows) |y| { + const row_raw = try reader.takeByte(); + const row_header: RowHeader = @bitCast(row_raw); + const semantic_prompt_raw: u2 = @truncate(row_raw >> @bitOffsetOf(RowHeader, "semantic_prompt")); + + // Reserved bits do not change the known fields. The semantic enum is + // the only non-exhaustive field, so give its unknown value a default. + const row = page.getRow(y); + row.wrap = row_header.wrap; + row.wrap_continuation = row_header.wrap_continuation; + row.semantic_prompt = std.enums.fromInt( + TerminalRow.SemanticPrompt, + semantic_prompt_raw, + ) orelse .none; + + const cells = page.getCells(row); + for (cells, 0..) |*cell, x| { + const decoded_header = try CellHeader.decode(reader); + + cell.* = .init(0); + var accept_graphemes = false; + const grapheme_count: u32 = switch (decoded_header) { + // The fixed header was fully consumed, but its content kind + // cannot be interpreted. Keep the cell empty and consume only + // the suffix bytes needed to reach the next cell. + .invalid => |count| count, + + .valid => |header| valid: { + // IDs belong to the encoded page. Translate them to IDs + // assigned by the destination page before storing them on + // cells. + const encoded_style_id = header.style_id; + const style_id = if (encoded_style_id == 0) + 0 + else + style_remap.get(encoded_style_id) orelse 0; + + const encoded_hyperlink_id = header.hyperlink_id; + const hyperlink_id = if (encoded_hyperlink_id == 0) + 0 + else + hyperlink_remap.get(encoded_hyperlink_id) orelse 0; + + switch (header.content_kind) { + .codepoint => { + var cp = std.math.cast( + u21, + header.value.codepoint, + ) orelse 0xFFFD; + if (cp > 0x10FFFF or + (cp >= 0xD800 and cp <= 0xDFFF)) + { + cp = 0xFFFD; + } + cell.content = .{ .codepoint = .{ .data = cp } }; + accept_graphemes = cp != 0; + + // Kitty image and placement state is not part of this + // snapshot version, but the placeholder is still a + // valid Unicode scalar. Preserve it and derive the + // native row hint so later row operations remain + // correct. + if (cp == kitty.graphics.unicode.placeholder) { + row.kitty_virtual_placeholder = true; + } + }, + .bg_color_palette => { + const palette = header.value.bg_color_palette; + cell.content_tag = .bg_color_palette; + cell.content = .{ + .color_palette = .{ .data = palette.index }, + }; + }, + .bg_color_rgb => { + const rgb = header.value.bg_color_rgb; + cell.content_tag = .bg_color_rgb; + cell.content = .{ .color_rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + } }; + }, + } + + cell.wide = header.width; + cell.protected = header.protected; + cell.semantic_content = header.semantic_content; + + if (style_id != 0) { + // The table owns one reference and each decoded cell owns + // one additional reference. + page.styles.use(page.memory, style_id); + cell.style_id = style_id; + row.styled = true; + } + + if (hyperlink_id != 0) { + // setHyperlink records the cell mapping but intentionally + // does not increment the set's reference count. If its map + // is full, undo our reference and leave this cell unlinked. + page.hyperlink_set.use(page.memory, hyperlink_id); + page.setHyperlink(row, cell, hyperlink_id) catch { + page.hyperlink_set.release(page.memory, hyperlink_id); + }; + } + + break :valid header.grapheme_count; + }, + }; + + // Always consume every declared suffix. Invalid scalars, suffixes + // on non-text cells, and suffixes that exceed the native capacity + // are optional detail and can be dropped independently. + for (0..grapheme_count) |_| { + const suffix_raw = try io.readInt(reader, u32); + if (!accept_graphemes) continue; + + const suffix = std.math.cast(u21, suffix_raw) orelse continue; + if (suffix > 0x10FFFF or + (suffix >= 0xD800 and suffix <= 0xDFFF)) + { + continue; + } + page.appendGrapheme(row, cell, suffix) catch { + accept_graphemes = false; + }; + } + + // A following cell resolves whether the previous wide marker owns + // a tail. Normalize the current marker immediately when possible, + // including a wide marker at the row end. + if (x > 0 and + cells[x - 1].wide == .wide and + cell.wide != .spacer_tail) + { + cells[x - 1].wide = .narrow; + } + switch (cell.wide) { + .narrow => {}, + + // A non-final wide marker remains pending until the next cell. + .wide => if (x + 1 == cells.len) { + cell.wide = .narrow; + }, + + .spacer_tail => if (x == 0 or + cells[x - 1].wide != .wide) + { + cell.wide = .narrow; + }, + + .spacer_head => if (x + 1 != cells.len or !row.wrap) { + cell.wide = .narrow; + }, + } + } + } +} + +/// The header before every row. +const RowHeader = packed struct(u8) { + wrap: bool = false, + wrap_continuation: bool = false, + semantic_prompt: TerminalRow.SemanticPrompt = .none, + _padding: u4 = 0, +}; + +/// The fixed fields that precede a cell's grapheme suffix codepoints. +const CellHeader = struct { + /// Number of bytes written by `encode`, calculated using the encoder itself + /// so this remains synchronized with the field-by-field wire format. + pub const len = computeLen(); + + comptime { + // This size is part of the wire format. If it changes, the snapshot + // version and golden fixtures must also change. + std.debug.assert(len == 16); + } + + /// Interpretation of `value`. + content_kind: Kind = .codepoint, + + /// Display width and spacer role of the cell. + width: TerminalCell.Wide = .narrow, + + /// Whether selective erase operations protect the cell. + protected: bool = false, + + /// Semantic role assigned by shell integration. + semantic_content: TerminalCell.SemanticContent = .output, + + /// ID in the encoded page's style table, or zero for the default style. + style_id: TerminalStyleId = 0, + + /// ID in the encoded page's hyperlink table, or zero for no hyperlink. + hyperlink_id: TerminalHyperlinkId = 0, + + /// Codepoint or packed background color selected by `content_kind`. + value: Value = .{ .codepoint = 0 }, + + /// Number of grapheme suffix codepoints immediately following the header. + grapheme_count: u32 = 0, + + /// Encode the fixed cell header. + pub fn encode( + self: CellHeader, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + const flags: Flags = .{ + .protected = self.protected, + .semantic_content = self.semantic_content, + }; + + // 0 1 2 3 4 6 8 12 16 + // +-------+-------+-------+-------+--------+--------+--------+--------+ + // | kind | width | flags | zero | style | link | value | count | + // | u8 | u8 | u8 | u8 | u16 LE | u16 LE | u32 LE | u32 LE | + // +-------+-------+-------+-------+--------+--------+--------+--------+ + try writer.writeByte(@intFromEnum(self.content_kind)); + try writer.writeByte(@intFromEnum(self.width)); + try writer.writeByte(@bitCast(flags)); + try writer.writeByte(0); + try io.writeInt(writer, TerminalStyleId, self.style_id); + try io.writeInt(writer, TerminalHyperlinkId, self.hyperlink_id); + try io.writeInt(writer, u32, @bitCast(self.value)); + try io.writeInt(writer, u32, self.grapheme_count); + } + + /// A valid header, or the suffix count from an uninterpretable header. + const DecodeResult = union(enum) { + valid: CellHeader, + invalid: u32, + }; + + /// Decode a fixed cell header, normalizing unknown semantic values. + pub fn decode(reader: *std.Io.Reader) std.Io.Reader.Error!DecodeResult { + // Decode various fields. We need to be very defensive here + // because it can come from an untrusted source and may be invalid. + const content_kind_optional = kind: { + const raw = try reader.takeByte(); + break :kind std.enums.fromInt(Kind, raw); + }; + const width = width: { + const raw = try reader.takeByte(); + break :width std.enums.fromInt(TerminalCell.Wide, raw) orelse .narrow; + }; + + const flags: Flags = @bitCast(try reader.takeByte()); + + // We can't trust `flags` to have a valid semantic content so + // we need extract the bits and do a safe conversion. + const semantic_content = semantic: { + const flags_raw: u8 = @bitCast(flags); + const raw: u2 = @truncate(flags_raw >> @bitOffsetOf(Flags, "semantic_content")); + break :semantic std.enums.fromInt( + TerminalCell.SemanticContent, + raw, + ) orelse .output; + }; + + // This byte is reserved so the IDs and content value remain naturally + // aligned within the fixed cell header. Ignore it so future versions + // can give it meaning without making old decoders reject the cell. + _ = try reader.takeByte(); + + const style_id = try io.readInt(reader, TerminalStyleId); + const hyperlink_id = try io.readInt(reader, TerminalHyperlinkId); + const value: Value = @bitCast(try io.readInt(reader, u32)); + const grapheme_count = try io.readInt(reader, u32); + + // If we don't have a valid content kind, we return invalid and + // note the suffix bytes so that the reader can discard + const content_kind = content_kind_optional orelse + return .{ .invalid = grapheme_count }; + + return .{ .valid = .{ + .content_kind = content_kind, + .width = width, + .protected = flags.protected, + .semantic_content = semantic_content, + .style_id = style_id, + .hyperlink_id = hyperlink_id, + .value = value, + .grapheme_count = grapheme_count, + } }; + } + + /// Computes the fixed header size using the encoder itself. + fn computeLen() usize { + comptime { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + CellHeader.encode(.{}, &writer) catch unreachable; + return writer.end; + } + } + + /// Determines how `value` is interpreted. + pub const Kind = enum(u8) { + codepoint = 0, + bg_color_palette = 1, + bg_color_rgb = 2, + }; + + /// The alternate interpretations of the four-byte content field. + pub const Value = packed union(u32) { + codepoint: u32, + bg_color_palette: packed struct(u32) { + index: u8, + _padding: u24 = 0, + }, + bg_color_rgb: packed struct(u32) { + r: u8, + g: u8, + b: u8, + _padding: u8 = 0, + }, + }; + + const Flags = packed struct(u8) { + protected: bool = false, + semantic_content: TerminalCell.SemanticContent = .output, + _padding: u5 = 0, + }; +}; + +const test_golden_fixture = test_fixture.parse( + @embedFile("testdata/grid-v1.hex"), +); + +test "grid golden encoding and decoding" { + const capacity: terminal_page.Capacity = .{ + .cols = 3, + .rows = 2, + .styles = 0, + .hyperlink_bytes = 0, + .grapheme_bytes = 64, + .string_bytes = 0, + }; + var source = try TerminalPage.init(capacity); + defer source.deinit(); + + // The first row covers semantic metadata, a wide-cell pair, and a + // multi-codepoint grapheme. + const wide = source.getRowAndCell(0, 0); + wide.row.semantic_prompt = .prompt; + wide.cell.* = .init('A'); + wide.cell.wide = .wide; + wide.cell.protected = true; + wide.cell.semantic_content = .prompt; + + const tail = source.getRowAndCell(1, 0); + tail.cell.wide = .spacer_tail; + tail.cell.semantic_content = .input; + + const grapheme = source.getRowAndCell(2, 0); + grapheme.cell.* = .init('x'); + try source.setGraphemes( + grapheme.row, + grapheme.cell, + &.{ 0x0301, 0x0302 }, + ); + + // The second row covers both background-color kinds and a wrapped spacer + // head with the remaining row semantic value. + const palette = source.getRowAndCell(0, 1); + palette.cell.content_tag = .bg_color_palette; + palette.cell.content = .{ .color_palette = .{ .data = 7 } }; + + const rgb = source.getRowAndCell(1, 1); + rgb.cell.content_tag = .bg_color_rgb; + rgb.cell.content = .{ .color_rgb = .{ + .r = 0xaa, + .g = 0xbb, + .b = 0xcc, + } }; + rgb.cell.protected = true; + + const head = source.getRowAndCell(2, 1); + head.cell.wide = .spacer_head; + head.row.wrap = true; + head.row.wrap_continuation = true; + head.row.semantic_prompt = .prompt_continuation; + + var encoded: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try encode(&source, &writer); + try test_fixture.expectEqual( + .bytes, + "src/terminal/snapshot/testdata/grid-v1.hex", + "snapshot_fixture-grid-v1.hex", + &test_golden_fixture, + writer.buffered(), + ); + + var destination = try TerminalPage.init(capacity); + defer destination.deinit(); + var style_remap = StyleRemap.init(std.testing.allocator); + defer style_remap.deinit(); + var hyperlink_remap = HyperlinkRemap.init(std.testing.allocator); + defer hyperlink_remap.deinit(); + + // Decode the checked-in reference through a one-byte reader buffer. + var fixture_reader: std.Io.Reader = .fixed(&test_golden_fixture); + var read_buffer: [1]u8 = undefined; + var limited = fixture_reader.limited(.unlimited, &read_buffer); + try decode( + &destination, + &limited.interface, + &style_remap, + &hyperlink_remap, + ); + try destination.verifyIntegrity(std.testing.allocator); + + const decoded_wide = destination.getRowAndCell(0, 0); + try std.testing.expectEqual(@as(u21, 'A'), decoded_wide.cell.codepoint()); + try std.testing.expectEqual(TerminalCell.Wide.wide, decoded_wide.cell.wide); + try std.testing.expect(decoded_wide.cell.protected); + try std.testing.expectEqual( + TerminalCell.SemanticContent.prompt, + decoded_wide.cell.semantic_content, + ); + try std.testing.expectEqual( + TerminalRow.SemanticPrompt.prompt, + decoded_wide.row.semantic_prompt, + ); + + const decoded_tail = destination.getRowAndCell(1, 0); + try std.testing.expectEqual( + TerminalCell.Wide.spacer_tail, + decoded_tail.cell.wide, + ); + try std.testing.expectEqual( + TerminalCell.SemanticContent.input, + decoded_tail.cell.semantic_content, + ); + + const decoded_grapheme = destination.getRowAndCell(2, 0); + try std.testing.expectEqualSlices( + u21, + &.{ 0x0301, 0x0302 }, + destination.lookupGrapheme(decoded_grapheme.cell).?, + ); + + const decoded_palette = destination.getRowAndCell(0, 1); + try std.testing.expectEqual( + TerminalCell.ContentTag.bg_color_palette, + decoded_palette.cell.content_tag, + ); + try std.testing.expectEqual( + @as(u8, 7), + decoded_palette.cell.content.color_palette.data, + ); + + const decoded_rgb = destination.getRowAndCell(1, 1); + try std.testing.expectEqual( + TerminalCell.ContentTag.bg_color_rgb, + decoded_rgb.cell.content_tag, + ); + try std.testing.expectEqual( + TerminalCell.RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + decoded_rgb.cell.content.color_rgb, + ); + try std.testing.expect(decoded_rgb.cell.protected); + + const decoded_head = destination.getRowAndCell(2, 1); + try std.testing.expectEqual( + TerminalCell.Wide.spacer_head, + decoded_head.cell.wide, + ); + try std.testing.expect(decoded_head.row.wrap); + try std.testing.expect(decoded_head.row.wrap_continuation); + try std.testing.expectEqual( + TerminalRow.SemanticPrompt.prompt_continuation, + decoded_head.row.semantic_prompt, + ); + + // A re-encode proves the decoded native page retains every wire field. + var reencoded: [128]u8 = undefined; + var rewriter: std.Io.Writer = .fixed(&reencoded); + try encode(&destination, &rewriter); + try std.testing.expectEqualStrings( + &test_golden_fixture, + rewriter.buffered(), + ); +} + +test "grid normalizes incomplete wide cells" { + const testing = std.testing; + // One column puts the wide cell at the row end. Two columns put an + // ordinary narrow cell after it. Neither case supplies a spacer tail. + for ([_]u16{ 1, 2 }) |columns| { + const capacity: terminal_page.Capacity = .{ + .cols = columns, + .rows = 1, + }; + + // Craft the malformed row directly on the wire. Decoding keeps its + // content but makes the incomplete wide cell narrow. + var payload: [64]u8 = undefined; + var payload_writer: std.Io.Writer = .fixed(&payload); + try payload_writer.writeByte(@bitCast(RowHeader{})); + try (CellHeader{ + .width = .wide, + .value = .{ .codepoint = 'A' }, + }).encode(&payload_writer); + if (capacity.cols > 1) try (CellHeader{ + .value = .{ .codepoint = 'B' }, + }).encode(&payload_writer); + + var destination = try TerminalPage.init(capacity); + defer destination.deinit(); + var style_remap = StyleRemap.init(testing.allocator); + defer style_remap.deinit(); + var hyperlink_remap = HyperlinkRemap.init(testing.allocator); + defer hyperlink_remap.deinit(); + + var payload_reader: std.Io.Reader = .fixed( + payload_writer.buffered(), + ); + try decode( + &destination, + &payload_reader, + &style_remap, + &hyperlink_remap, + ); + try destination.verifyIntegrity(testing.allocator); + + try testing.expectEqual( + @as(u21, 'A'), + destination.getRowAndCell(0, 0).cell.codepoint(), + ); + try testing.expectEqual( + TerminalCell.Wide.narrow, + destination.getRowAndCell(0, 0).cell.wide, + ); + if (columns > 1) { + const second = destination.getRowAndCell(1, 0).cell; + try testing.expectEqual(@as(u21, 'B'), second.codepoint()); + try testing.expectEqual(TerminalCell.Wide.narrow, second.wide); + } + } +} diff --git a/src/terminal/snapshot/history.zig b/src/terminal/snapshot/history.zig new file mode 100644 index 000000000..dc5fcb65d --- /dev/null +++ b/src/terminal/snapshot/history.zig @@ -0,0 +1,698 @@ +//! HISTORY record payload encoding. +//! +//! One HISTORY record describes the complete history for a previously encoded +//! screen. It is followed immediately by the number of complete PAGE records +//! declared by `page_count`. Those pages contain the history older than the +//! first page in the corresponding SCREEN sequence and are ordered from newest +//! to oldest. +//! +//! Every encoded SCREEN has one corresponding HISTORY record. A screen with no +//! older pages uses a zero page count and has no following PAGE records. +//! +//! The first SCREEN page may begin above the active area. Those incidental +//! history rows are already present at READY and are not repeated after +//! HISTORY. The corresponding SCREEN header declares the complete logical +//! history extent, including both that resident overlap and the following PAGE +//! records. +//! +//! All integers are unsigned and little-endian. +//! +//! ## Binary Format +//! +//! A HISTORY record is followed immediately by its declared PAGE records: +//! +//! ```text +//! +----------------------+ +//! | HISTORY record | +//! +----------------------+ +//! | PAGE record 0 | newest +//! +----------------------+ +//! | ... | +//! +----------------------+ +//! | PAGE record (n - 1) | oldest +//! +----------------------+ +//! +//! n = page_count +//! ``` +//! +//! Newest-to-oldest order lets a reader start showing most-recent +//! history as soon as possible which is more useful to a user. +//! +//! The PAGE sequence may be empty when all history is already included in the +//! first SCREEN page or when the screen has no history. +//! +//! ### Header +//! +//! The HISTORY payload consists only of this fixed header: +//! +//! ```text +//! 0 +--------------------------------+ +//! | Screen key (u16) | +//! 2 +--------------------------------+ +//! | Following page count (u32) | +//! 6 +--------------------------------+ +//! ``` +//! +//! A decoder uses `page_count`, rather than another record tag, to find the end +//! of the page sequence. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const test_fixture = @import("fixture.zig"); +const io = @import("io.zig"); +const page = @import("page.zig"); +const record = @import("record.zig"); +const screen = @import("screen.zig"); +const TerminalPage = @import("../page.zig").Page; +const TerminalPageList = @import("../PageList.zig"); +const TerminalScreen = @import("../Screen.zig"); +const TerminalScreenKey = @import("../ScreenSet.zig").Key; + +/// The complete fixed payload of one HISTORY record. +pub const Header = struct { + /// Number of encoded bytes in the fixed payload, calculated by its encoder. + pub const len = computeLen(); + + comptime { + // This size is part of the wire format. If it changes, the snapshot + // version must also change. + std.debug.assert(len == 6); + } + + /// Identifies the previously encoded screen that owns this history. + key: TerminalScreenKey, + + /// Number of complete PAGE records immediately following this record. + page_count: u32, + + /// Encode the fixed HISTORY payload. + pub fn encode( + self: Header, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try io.writeInt(writer, u16, @intCast(@intFromEnum(self.key))); + try io.writeInt(writer, u32, self.page_count); + } + + pub const DecodeError = std.Io.Reader.Error || error{InvalidKey}; + + /// Decode and validate the fixed HISTORY payload. + pub fn decode(reader: *std.Io.Reader) Header.DecodeError!Header { + const raw = try io.readInt(reader, u16); + const Tag = @typeInfo(TerminalScreenKey).@"enum".tag_type; + const value = std.math.cast(Tag, raw) orelse + return error.InvalidKey; + const key = std.enums.fromInt( + TerminalScreenKey, + value, + ) orelse return error.InvalidKey; + return .{ + .key = key, + .page_count = try io.readInt(reader, u32), + }; + } + + fn computeLen() usize { + comptime { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + const value: Header = .{ + .key = .primary, + .page_count = 0, + }; + value.encode(&writer) catch unreachable; + return writer.end; + } + } +}; + +/// Errors possible while encoding one HISTORY and its complete PAGE sequence. +pub const EncodeError = Allocator.Error || + page.EncodeError || + record.Writer.FinishError || + error{ + /// The complete historical prefix has more pages than the header fits. + PageCountOverflow, + }; + +/// Encode one screen's HISTORY and its complete historical pages. +/// +/// Pages are encoded newest-to-oldest. Compressed source pages are inspected +/// without changing their storage state. Completed records may already be +/// emitted if a later page fails. +pub fn encode( + terminal_screen: *const TerminalScreen, + key: TerminalScreenKey, + destination: *record.Writer, +) EncodeError!void { + // SCREEN begins at the page containing the active area's first row. Its + // leading rows are already resident; every previous complete page belongs + // to this HISTORY sequence. + const first = terminal_screen.pages.getTopLeft(.active).node.prev; + const page_count: usize = count: { + var page_count: usize = 0; + var node = first; + while (node) |current| : (node = current.prev) { + page_count += 1; + } + break :count page_count; + }; + + const header: Header = .{ + .key = key, + .page_count = std.math.cast( + u32, + page_count, + ) orelse return error.PageCountOverflow, + }; + + // HISTORY declares exactly how many PAGE records follow. + { + const payload = destination.begin(.history); + errdefer destination.cancel(); + try header.encode(payload); + try destination.finish(); + } + + // Walk backward so each page can be prepended by the decoder immediately. + // PreservedPage borrows resident pages and clones compressed pages without + // changing the source node's representation. + var node = first; + while (node) |current| : (node = current.prev) { + var preserved = try current.pagePreservingState(terminal_screen.alloc); + defer preserved.deinit(); + try page.encode(preserved.page(), destination); + } +} + +/// Errors possible while restoring one HISTORY and its PAGE sequence. +pub const DecodeError = Decoder.InitError || + Decoder.RestoreError || + error{ + /// The HISTORY key does not match the caller-selected screen. + UnexpectedScreenKey, + }; + +/// A decoded HISTORY manifest ready to restore its following PAGE records. +/// +/// Keeping manifest decoding separate lets the full snapshot wrapper route a +/// HISTORY sequence by its encoded key before any pages mutate a Screen. +pub const Decoder = struct { + source: *std.Io.Reader, + header: Header, + + pub const InitError = Header.DecodeError || + record.Reader.InitError || + record.Reader.FinishError || + error{ + /// The next record is valid but is not a HISTORY. + UnexpectedRecordTag, + }; + + pub const RestoreError = Allocator.Error || + page.DecodeError || + TerminalPageList.PageAllocation.FinalizeError || + error{ + /// The Screen already contains complete pages before its active page. + ExistingHistory, + }; + + /// Decode and finish the self-contained HISTORY manifest. + pub fn init(self: *Decoder, source: *std.Io.Reader) InitError!void { + var record_reader: record.Reader = undefined; + try record_reader.init(source); + if (record_reader.header.tag != .history) { + return error.UnexpectedRecordTag; + } + const header = try Header.decode(record_reader.payloadReader()); + try record_reader.finish(); + self.* = .{ .source = source, .header = header }; + } + + /// Restore the declared PAGE records into the selected native Screen. + pub fn decode( + self: *Decoder, + alloc: Allocator, + terminal_screen: *TerminalScreen, + ) RestoreError!void { + // A freshly restored SCREEN may carry overlap inside its first page, + // but cannot already contain a complete historical page before that + // boundary. + const active_top = terminal_screen.pages.getTopLeft(.active); + if (terminal_screen.pages.getTopLeft(.screen).node != active_top.node) { + return error.ExistingHistory; + } + + // Native row totals remain derived from the actual PAGE dimensions. + for (0..self.header.page_count) |_| { + // PAGE exposes its exact capacity before decoding the payload, + // allowing the destination PageList to allocate the final backing + // memory once. + var decoder: page.Decoder = undefined; + try decoder.init(self.source); + var allocation = try terminal_screen.pages.allocatePage( + decoder.capacity(), + ); + defer allocation.deinit(); + try decoder.decode(allocation.page(), alloc); + + const contains_prompt = hasSemanticPrompt(allocation.page()); + try allocation.finalize(.prepend); + if (contains_prompt) terminal_screen.semantic_prompt.seen = true; + } + + terminal_screen.pages.assertIntegrity(); + terminal_screen.assertIntegrity(); + } +}; + +/// Restore one HISTORY and its declared PAGE records into a native Screen. +/// +/// Each PAGE is decoded directly into a detached PageList-pooled allocation and +/// prepended only after its record and native integrity are validated. If a +/// later PAGE fails, earlier successful pages remain as a contiguous recent +/// history prefix. +pub fn decode( + source: *std.Io.Reader, + alloc: Allocator, + expected_key: TerminalScreenKey, + terminal_screen: *TerminalScreen, +) DecodeError!void { + var decoder: Decoder = undefined; + try decoder.init(source); + if (decoder.header.key != expected_key) return error.UnexpectedScreenKey; + try decoder.decode(alloc, terminal_screen); +} + +fn hasSemanticPrompt(terminal_page: *const TerminalPage) bool { + const rows = terminal_page.rows.ptr(terminal_page.memory)[0..terminal_page.size.rows]; + for (rows) |row| { + if (row.semantic_prompt != .none) return true; + + const cells = row.cells.ptr( + terminal_page.memory, + )[0..terminal_page.size.cols]; + for (cells) |cell| { + if (cell.semantic_content == .prompt) return true; + } + } + return false; +} + +const test_header_fixture = test_fixture.parse(@embedFile("testdata/history-header-v1.hex")); + +test "HISTORY header golden encoding and decoding" { + const expected: Header = .{ + .key = .alternate, + .page_count = 0x01020304, + }; + var encoded: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try expected.encode(&writer); + try test_fixture.expectEqual( + .bytes, + "src/terminal/snapshot/testdata/history-header-v1.hex", + "snapshot_fixture-history-header-v1.hex", + &test_header_fixture, + writer.buffered(), + ); + try std.testing.expectEqual(Header.len, test_header_fixture.len); + + var reader: std.Io.Reader = .fixed(&test_header_fixture); + try std.testing.expectEqualDeep(expected, try Header.decode(&reader)); + for (0..Header.len) |fixture_len| { + var truncated: std.Io.Reader = .fixed( + test_header_fixture[0..fixture_len], + ); + try std.testing.expectError( + error.EndOfStream, + Header.decode(&truncated), + ); + } + + var invalid_key = test_header_fixture; + invalid_key[0] = 2; + var invalid_key_reader: std.Io.Reader = .fixed(&invalid_key); + try std.testing.expectError( + error.InvalidKey, + Header.decode(&invalid_key_reader), + ); +} + +test "HISTORY encodes newest first and restores complete history" { + // Choose an active height which spans two native pages, then grow until + // exactly two additional complete pages precede the active boundary. + var probe = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ .cols = 80, .rows = 1, .max_scrollback_bytes = 0 }, + ); + const page_rows = probe.pages.getTopLeft(.screen).node.capacity().rows; + probe.deinit(); + const screen_rows = page_rows + 1; + + var source_screen = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ + .cols = 80, + .rows = screen_rows, + .max_scrollback_bytes = null, + }, + ); + defer source_screen.deinit(); + source_screen.cursorAbsolute(0, screen_rows - 1); + while (source_screen.pages.totalPages() < 4) { + try source_screen.testWriteString("\n"); + } + + const active_top = source_screen.pages.getTopLeft(.active); + const newest_history = active_top.node.prev.?; + const oldest_history = newest_history.prev.?; + try std.testing.expectEqual( + source_screen.pages.getTopLeft(.screen).node, + oldest_history, + ); + try std.testing.expectEqual(null, oldest_history.prev); + + // Mark the two complete historical pages so the wire sequence and final + // native order are visible. The oldest prompt also verifies derived state + // is updated only when that page is restored. + oldest_history.page().getRowAndCell(0, 0).cell.* = .init('A'); + oldest_history.page().getRowAndCell( + 0, + 0, + ).cell.semantic_content = .prompt; + newest_history.page().getRowAndCell(0, 0).cell.* = .init('B'); + + // Encode SCREEN before history compression, then compress eligible history + // and require HISTORY encoding to preserve each source storage state. + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + try screen.encode(&source_screen, .primary, &stream); + const history_offset = destination.written().len; + + _ = source_screen.pages.compress(.full); + const oldest_storage = oldest_history.storage(); + const newest_storage = newest_history.storage(); + try encode(&source_screen, .primary, &stream); + try std.testing.expectEqual(oldest_storage, oldest_history.storage()); + try std.testing.expectEqual(newest_storage, newest_history.storage()); + + // Inspect the manifest and PAGE records independently. Newest history is + // sent first even though native PageList order is oldest-to-newest. + var history_source: std.Io.Reader = .fixed( + destination.written()[history_offset..], + ); + var history_record: record.Reader = undefined; + try history_record.init(&history_source); + try std.testing.expectEqual(record.Tag.history, history_record.header.tag); + const header = try Header.decode(history_record.payloadReader()); + try history_record.finish(); + try std.testing.expectEqual(TerminalScreenKey.primary, header.key); + try std.testing.expectEqual(@as(u32, 2), header.page_count); + + var decoded_newest = try page.decode( + &history_source, + std.testing.allocator, + ); + defer decoded_newest.deinit(); + try std.testing.expectEqual( + @as(u21, 'B'), + decoded_newest.getRowAndCell(0, 0).cell.codepoint(), + ); + var decoded_oldest = try page.decode( + &history_source, + std.testing.allocator, + ); + defer decoded_oldest.deinit(); + try std.testing.expectEqual( + @as(u21, 'A'), + decoded_oldest.getRowAndCell(0, 0).cell.codepoint(), + ); + try std.testing.expectError(error.EndOfStream, history_source.takeByte()); + + // Restore SCREEN first, then prepend HISTORY directly into that PageList. + var restore_source: std.Io.Reader = .fixed(destination.written()); + var decoded_screen = try screen.decode( + &restore_source, + std.testing.io, + std.testing.allocator, + .{ + .cols = 80, + .rows = screen_rows, + .max_scrollback_bytes = null, + }, + ); + defer decoded_screen.deinit(); + try std.testing.expectEqual( + TerminalScreenKey.primary, + decoded_screen.key, + ); + try std.testing.expectEqual( + @as(u64, @intCast(source_screen.pages.total_rows - screen_rows)), + decoded_screen.history_rows, + ); + const restored = &decoded_screen.screen; + try std.testing.expect(!restored.semantic_prompt.seen); + try decode( + &restore_source, + std.testing.allocator, + .primary, + restored, + ); + + try std.testing.expectEqual( + source_screen.pages.totalPages(), + restored.pages.totalPages(), + ); + const restored_oldest = restored.pages.getTopLeft(.screen).node; + try std.testing.expectEqual( + @as(u21, 'A'), + restored_oldest.page().getRowAndCell(0, 0).cell.codepoint(), + ); + try std.testing.expectEqual( + @as(u21, 'B'), + restored_oldest.next.? + .page().getRowAndCell(0, 0).cell.codepoint(), + ); + try std.testing.expect(restored.semantic_prompt.seen); + try std.testing.expectError(error.EndOfStream, restore_source.takeByte()); + + // Reuse a writable copy of the sequence for failure-path fixtures. + const encoded = try std.testing.allocator.dupe( + u8, + destination.written(), + ); + defer std.testing.allocator.free(encoded); + const first_page_offset = history_offset + record.Header.len + Header.len; + const first_payload_len = std.mem.readInt( + u32, + encoded[first_page_offset + 2 ..][0..4], + .little, + ); + const second_page_offset = + first_page_offset + record.Header.len + first_payload_len; + + // Truncate the first PAGE only after its header has exposed a capacity. + // Its detached allocation is discarded and the live SCREEN list remains + // completely unchanged. + var truncated_source: std.Io.Reader = .fixed( + encoded[0 .. second_page_offset - 1], + ); + var decoded_truncated = try screen.decode( + &truncated_source, + std.testing.io, + std.testing.allocator, + .{ + .cols = 80, + .rows = screen_rows, + .max_scrollback_bytes = null, + }, + ); + defer decoded_truncated.deinit(); + const truncated = &decoded_truncated.screen; + const truncated_screen_first = truncated.pages.getTopLeft(.screen).node; + const truncated_screen_page_count = truncated.pages.totalPages(); + try std.testing.expectError( + error.EndOfStream, + decode( + &truncated_source, + std.testing.allocator, + .primary, + truncated, + ), + ); + try std.testing.expectEqual( + truncated_screen_page_count, + truncated.pages.totalPages(), + ); + try std.testing.expectEqual( + truncated_screen_first, + truncated.pages.getTopLeft(.screen).node, + ); + truncated.pages.assertIntegrity(); + truncated.assertIntegrity(); + + // Corrupt only the older PAGE tag. A failure in a later PAGE keeps only + // the successfully prepended newer pages, contiguous with SCREEN. + std.mem.writeInt( + u16, + encoded[second_page_offset..][0..2], + @intFromEnum(record.Tag.screen), + .little, + ); + + var partial_source: std.Io.Reader = .fixed(encoded); + var decoded_partial = try screen.decode( + &partial_source, + std.testing.io, + std.testing.allocator, + .{ + .cols = 80, + .rows = screen_rows, + .max_scrollback_bytes = null, + }, + ); + defer decoded_partial.deinit(); + const partial = &decoded_partial.screen; + const screen_page_count = partial.pages.totalPages(); + try std.testing.expectError( + error.UnexpectedRecordTag, + decode( + &partial_source, + std.testing.allocator, + .primary, + partial, + ), + ); + try std.testing.expectEqual( + screen_page_count + 1, + partial.pages.totalPages(), + ); + try std.testing.expectEqual( + @as(u21, 'B'), + partial.pages.getTopLeft(.screen).node + .page().getRowAndCell(0, 0).cell.codepoint(), + ); + try std.testing.expect(!partial.semantic_prompt.seen); + partial.pages.assertIntegrity(); + partial.assertIntegrity(); +} + +test "HISTORY encodes and restores an empty sequence" { + var terminal_screen = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ + .cols = 2, + .rows = 2, + .max_scrollback_bytes = null, + }, + ); + defer terminal_screen.deinit(); + + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + try encode(&terminal_screen, .primary, &stream); + + var inspect_source: std.Io.Reader = .fixed(destination.written()); + var history_record: record.Reader = undefined; + try history_record.init(&inspect_source); + const header = try Header.decode(history_record.payloadReader()); + try history_record.finish(); + try std.testing.expectEqual(@as(u32, 0), header.page_count); + try std.testing.expectError(error.EndOfStream, inspect_source.takeByte()); + + var decode_source: std.Io.Reader = .fixed(destination.written()); + const initial_first = terminal_screen.pages.getTopLeft(.screen).node; + try decode( + &decode_source, + std.testing.allocator, + .primary, + &terminal_screen, + ); + try std.testing.expectEqual( + initial_first, + terminal_screen.pages.getTopLeft(.screen).node, + ); + try std.testing.expectError(error.EndOfStream, decode_source.takeByte()); +} + +test "HISTORY rejects invalid routing and incomplete sequences" { + var terminal_screen = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ + .cols = 2, + .rows = 2, + .max_scrollback_bytes = null, + }, + ); + defer terminal_screen.deinit(); + + // Routing and sequence length still determine which native screen is + // mutated and where the following record begins, so they remain strict. + const Case = struct { + header: Header, + expected: anyerror, + }; + const cases = [_]Case{ + .{ + .header = .{ + .key = .alternate, + .page_count = 0, + }, + .expected = error.UnexpectedScreenKey, + }, + .{ + .header = .{ + .key = .primary, + .page_count = 1, + }, + .expected = error.EndOfStream, + }, + }; + for (cases) |case| { + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + const payload = stream.begin(.history); + errdefer stream.cancel(); + try case.header.encode(payload); + try stream.finish(); + + var source: std.Io.Reader = .fixed(destination.written()); + try std.testing.expectError( + case.expected, + decode( + &source, + std.testing.allocator, + .primary, + &terminal_screen, + ), + ); + terminal_screen.pages.assertIntegrity(); + terminal_screen.assertIntegrity(); + } +} diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig new file mode 100644 index 000000000..b10d0dc8d --- /dev/null +++ b/src/terminal/snapshot/hyperlink.zig @@ -0,0 +1,493 @@ +//! Snapshot hyperlink entry encoding. +//! +//! Each entry contains a URI and either an implicit numeric ID or an explicit +//! byte-string ID. Implicit IDs are generated by the terminal when the source +//! hyperlink has no explicit ID. A record can use these entries to build a +//! hyperlink table and assign indexes according to that record's format. +//! Indexing and ordering are properties of the containing record rather than +//! this codec. +//! +//! URIs and explicit IDs are non-empty arbitrary byte strings. Their lengths +//! are retained on the wire; they are not NUL-terminated and need not contain +//! UTF-8. Native page storage requires every allocated hyperlink string to +//! contain at least one byte. Encoding and standalone decoding reject empty +//! strings; PAGE decoding consumes but ignores entries it cannot represent. +//! +//! All integers are unsigned and little-endian. +//! +//! ## Implicit ID +//! +//! | Offset | Size | Field | +//! | -----: | --------: | :----------------- | +//! | 0 | 1 | Kind, `1` | +//! | 1 | 4 | Implicit ID (`u32`) | +//! | 5 | 4 | URI length (`u32`) | +//! | 9 | `uri_len` | URI bytes | +//! +//! ## Explicit ID +//! +//! | Offset | Size | Field | +//! | -----------: | --------: | :------------------------- | +//! | 0 | 1 | Kind, `2` | +//! | 1 | 4 | Explicit ID length (`u32`) | +//! | 5 | `id_len` | Explicit ID bytes | +//! | `5 + id_len` | 4 | URI length (`u32`) | +//! | `9 + id_len` | `uri_len` | URI bytes | +//! +//! Both variants have nine bytes of fixed overhead. The remaining bytes are +//! the URI plus the explicit ID, when present. +//! +//! The standalone decoder returns an allocator-owned hyperlink. PAGE decoding +//! reads strings directly into the destination page's allocator instead. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const test_fixture = @import("fixture.zig"); +const io = @import("io.zig"); +const terminal_hyperlink = @import("../hyperlink.zig"); +const terminal_page = @import("../page.zig"); +const terminal_size = @import("../size.zig"); + +const Kind = enum(u8) { + implicit = 1, + explicit = 2, +}; + +/// Errors possible while encoding one hyperlink entry. +pub const EncodeError = std.Io.Writer.Error || error{ + /// A native hyperlink URI must contain at least one byte. + InvalidUri, + + /// A native explicit hyperlink ID must contain at least one byte. + InvalidExplicitId, + + /// A string does not fit the version 1 length field. + StringTooLong, +}; + +/// Errors possible while decoding one allocator-owned hyperlink entry. +pub const DecodeError = std.Io.Reader.Error || Allocator.Error || error{ + /// The hyperlink kind is not defined by snapshot version 1. + InvalidKind, + + /// A native hyperlink URI must contain at least one byte. + InvalidUri, + + /// A native explicit hyperlink ID must contain at least one byte. + InvalidExplicitId, +}; + +/// Errors possible while decoding directly into a native page. +pub const DecodePageError = std.Io.Reader.Error || error{ + /// The hyperlink kind is not defined by snapshot version 1. + InvalidKind, +}; + +/// Encode one hyperlink entry. +pub fn encode( + value: terminal_hyperlink.Hyperlink, + writer: *std.Io.Writer, +) EncodeError!void { + // Validate the complete value before writing any part of this variable- + // length entry. This keeps callers able to roll back at entry boundaries. + if (value.uri.len == 0) return error.InvalidUri; + if (value.uri.len > std.math.maxInt(u32)) return error.StringTooLong; + if (value.id == .explicit) { + const id = value.id.explicit; + if (id.len == 0) return error.InvalidExplicitId; + if (id.len > std.math.maxInt(u32)) return error.StringTooLong; + } + + switch (value.id) { + .implicit => |id| { + try writer.writeByte(@intFromEnum(Kind.implicit)); + try io.writeInt(writer, u32, id); + try io.writeInt(writer, u32, @intCast(value.uri.len)); + try writer.writeAll(value.uri); + }, + .explicit => |id| { + try writer.writeByte(@intFromEnum(Kind.explicit)); + try io.writeInt(writer, u32, @intCast(id.len)); + try writer.writeAll(id); + try io.writeInt(writer, u32, @intCast(value.uri.len)); + try writer.writeAll(value.uri); + }, + } +} + +/// Decode one allocator-owned hyperlink entry. +/// +/// The caller owns the returned value and must call `Hyperlink.deinit`. +pub fn decode( + reader: *std.Io.Reader, + alloc: Allocator, +) DecodeError!terminal_hyperlink.Hyperlink { + const kind_raw = try reader.takeByte(); + const kind = std.enums.fromInt(Kind, kind_raw) orelse + return error.InvalidKind; + + return switch (kind) { + .implicit => implicit: { + const id = try io.readInt(reader, u32); + const uri_len: usize = @intCast(try io.readInt(reader, u32)); + if (uri_len == 0) return error.InvalidUri; + + const uri = try alloc.alloc(u8, uri_len); + errdefer alloc.free(uri); + try reader.readSliceAll(uri); + + break :implicit .{ + .id = .{ .implicit = id }, + .uri = uri, + }; + }, + + .explicit => explicit: { + const id_len: usize = @intCast(try io.readInt(reader, u32)); + if (id_len == 0) return error.InvalidExplicitId; + + const id = try alloc.alloc(u8, id_len); + errdefer alloc.free(id); + try reader.readSliceAll(id); + + const uri_len: usize = @intCast(try io.readInt(reader, u32)); + if (uri_len == 0) return error.InvalidUri; + + const uri = try alloc.alloc(u8, uri_len); + errdefer alloc.free(uri); + try reader.readSliceAll(uri); + + break :explicit .{ + .id = .{ .explicit = id }, + .uri = uri, + }; + }, + }; +} + +/// Decode one hyperlink directly into page-owned storage. +/// +/// Explicit ID and URI bytes are read into the page string allocator and the +/// completed entry is inserted into the page hyperlink set. The returned ID is +/// the native ID assigned by the destination page. Zero is returned when the +/// encoded value cannot be represented within the destination page, including +/// empty strings and insufficient advertised capacity. +pub fn decodePage( + page: *terminal_page.Page, + reader: *std.Io.Reader, +) DecodePageError!terminal_hyperlink.Id { + const kind_raw = try reader.takeByte(); + const kind = std.enums.fromInt(Kind, kind_raw) orelse + return error.InvalidKind; + + const entry: terminal_hyperlink.PageEntry = switch (kind) { + .implicit => implicit: { + const id = try io.readInt(reader, u32); + const uri_len: usize = @intCast(try io.readInt(reader, u32)); + if (uri_len == 0) return 0; + + const uri = decodePageString( + page, + reader, + uri_len, + ) catch |err| switch (err) { + error.StringsOutOfMemory => { + try reader.discardAll(uri_len); + return 0; + }, + else => |read_err| return read_err, + }; + + break :implicit .{ + .id = .{ .implicit = id }, + .uri = uri, + }; + }, + + .explicit => explicit: { + const id_len: usize = @intCast(try io.readInt(reader, u32)); + if (id_len == 0) { + const uri_len: usize = @intCast( + try io.readInt(reader, u32), + ); + try reader.discardAll(uri_len); + return 0; + } + + const id = decodePageString( + page, + reader, + id_len, + ) catch |err| switch (err) { + error.StringsOutOfMemory => { + try reader.discardAll(id_len); + const uri_len: usize = @intCast( + try io.readInt(reader, u32), + ); + try reader.discardAll(uri_len); + return 0; + }, + else => |read_err| return read_err, + }; + errdefer page.string_alloc.free( + page.memory, + id.slice(page.memory), + ); + + const uri_len: usize = @intCast(try io.readInt(reader, u32)); + if (uri_len == 0) { + page.string_alloc.free( + page.memory, + id.slice(page.memory), + ); + return 0; + } + + const uri = decodePageString( + page, + reader, + uri_len, + ) catch |err| switch (err) { + error.StringsOutOfMemory => { + try reader.discardAll(uri_len); + page.string_alloc.free( + page.memory, + id.slice(page.memory), + ); + return 0; + }, + else => |read_err| return read_err, + }; + + break :explicit .{ + .id = .{ .explicit = id }, + .uri = uri, + }; + }, + }; + return page.hyperlink_set.addContext( + page.memory, + entry, + .{ .page = page }, + ) catch { + // The entry was fully consumed, so capacity failure only loses this + // optional hyperlink. Free its strings and let cells restore unlinked. + entry.free(page); + return 0; + }; +} + +fn decodePageString( + page: *terminal_page.Page, + reader: *std.Io.Reader, + len: usize, +) (std.Io.Reader.Error || error{StringsOutOfMemory})!terminal_size.Offset(u8).Slice { + std.debug.assert(len > 0); + + // Allocate space for the string and read directly into it. + const value = page.string_alloc.alloc( + u8, + page.memory, + len, + ) catch return error.StringsOutOfMemory; + errdefer page.string_alloc.free(page.memory, value); + try reader.readSliceAll(value); + + return .{ + .len = value.len, + .offset = terminal_size.getOffset( + u8, + page.memory, + &value[0], + ), + }; +} + +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 = .{ + .id = .{ .implicit = 0x01020304 }, + .uri = "uri", + }; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(value, &writer); + + try test_fixture.expectEqual( + .bytes, + "src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex", + "snapshot_fixture-hyperlink-implicit-v1.hex", + &test_implicit_fixture, + writer.buffered(), + ); + + var reader: std.Io.Reader = .fixed(&test_implicit_fixture); + var decoded = try decode(&reader, std.testing.allocator); + defer decoded.deinit(std.testing.allocator); + try std.testing.expectEqualDeep(value, decoded); +} + +test "golden explicit encoding" { + const value: terminal_hyperlink.Hyperlink = .{ + .id = .{ .explicit = "id" }, + .uri = "uri", + }; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(value, &writer); + + try test_fixture.expectEqual( + .bytes, + "src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex", + "snapshot_fixture-hyperlink-explicit-v1.hex", + &test_explicit_fixture, + writer.buffered(), + ); + + var reader: std.Io.Reader = .fixed(&test_explicit_fixture); + var decoded = try decode(&reader, std.testing.allocator); + defer decoded.deinit(std.testing.allocator); + try std.testing.expectEqualDeep(value, decoded); +} + +test "encode rejects invalid strings before writing" { + const cases = [_]struct { + value: terminal_hyperlink.Hyperlink, + expected: anyerror, + }{ + .{ + .value = .{ .id = .{ .implicit = 1 }, .uri = "" }, + .expected = error.InvalidUri, + }, + .{ + .value = .{ .id = .{ .explicit = "" }, .uri = "uri" }, + .expected = error.InvalidExplicitId, + }, + .{ + .value = .{ .id = .{ .explicit = "id" }, .uri = "" }, + .expected = error.InvalidUri, + }, + }; + + for (cases) |case| { + var encoded: [32]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try std.testing.expectError( + case.expected, + encode(case.value, &writer), + ); + try std.testing.expectEqual(@as(usize, 0), writer.end); + } +} + +test "decode rejects empty strings" { + const cases = [_]struct { + fixture: []const u8, + expected: anyerror, + }{ + .{ + .fixture = "\x01\x04\x03\x02\x01\x00\x00\x00\x00", + .expected = error.InvalidUri, + }, + .{ + .fixture = "\x02\x00\x00\x00\x00", + .expected = error.InvalidExplicitId, + }, + .{ + .fixture = "\x02\x02\x00\x00\x00id\x00\x00\x00\x00", + .expected = error.InvalidUri, + }, + }; + + for (cases) |case| { + var reader: std.Io.Reader = .fixed(case.fixture); + try std.testing.expectError( + case.expected, + decode(&reader, std.testing.allocator), + ); + } +} + +test "decodePage ignores empty strings" { + const fixtures = [_][]const u8{ + "\x01\x04\x03\x02\x01\x00\x00\x00\x00", + "\x02\x00\x00\x00\x00\x03\x00\x00\x00uri", + "\x02\x02\x00\x00\x00id\x00\x00\x00\x00", + }; + + for (fixtures) |fixture| { + var page = try terminal_page.Page.init(.{ + .cols = 1, + .rows = 1, + .hyperlink_bytes = 512, + .string_bytes = 16, + }); + defer page.deinit(); + + var reader: std.Io.Reader = .fixed(fixture); + try std.testing.expectEqual( + @as(terminal_hyperlink.Id, 0), + try decodePage(&page, &reader), + ); + try std.testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); + try std.testing.expectError(error.EndOfStream, reader.takeByte()); + } +} + +test "decode rejects invalid kinds" { + for ([_]u8{ 0, 3, std.math.maxInt(u8) }) |kind| { + var fixture: [1]u8 = .{kind}; + var reader: std.Io.Reader = .fixed(&fixture); + try std.testing.expectError( + error.InvalidKind, + decode(&reader, std.testing.allocator), + ); + } +} + +test "decode allocation failure" { + { + var reader: std.Io.Reader = .fixed(&test_implicit_fixture); + var failing = std.testing.FailingAllocator.init( + std.testing.allocator, + .{ .fail_index = 0 }, + ); + try std.testing.expectError( + error.OutOfMemory, + decode(&reader, failing.allocator()), + ); + } + + for (0..2) |fail_index| { + var failing = std.testing.FailingAllocator.init( + std.testing.allocator, + .{ .fail_index = fail_index }, + ); + var reader: std.Io.Reader = .fixed(&test_explicit_fixture); + try std.testing.expectError( + error.OutOfMemory, + decode(&reader, failing.allocator()), + ); + } +} + +test "reject every truncation" { + const fixtures: [2][]const u8 = .{ + &test_implicit_fixture, + &test_explicit_fixture, + }; + + for (fixtures) |fixture| { + for (0..fixture.len) |fixture_len| { + var reader: std.Io.Reader = .fixed(fixture[0..fixture_len]); + try std.testing.expectError( + error.EndOfStream, + decode(&reader, std.testing.allocator), + ); + } + } +} diff --git a/src/terminal/snapshot/io.zig b/src/terminal/snapshot/io.zig new file mode 100644 index 000000000..d47f96ccb --- /dev/null +++ b/src/terminal/snapshot/io.zig @@ -0,0 +1,55 @@ +//! Wire-format I/O helpers. +//! +//! These are sometimes very thin layers over standard `std.Io.Writer` +//! but its to help ensure consistency in our behavior and avoid some +//! pitfalls. + +const std = @import("std"); +const testing = std.testing; + +/// writeInt ensures we write in little-endian since that is our +/// standard form. +pub fn writeInt( + writer: *std.Io.Writer, + comptime T: type, + value: T, +) std.Io.Writer.Error!void { + try writer.writeInt(T, value, .little); +} + +/// Read a little-endian integer. +/// +/// This avoids `takeInt` because at the time of writing this the underlying +/// implementation uses `takeArray` which asserts that the reader buffer +/// is as large as T. Our approach works with caller-selected buffers that +/// may be smaller. +pub fn readInt( + reader: *std.Io.Reader, + comptime T: type, +) std.Io.Reader.Error!T { + var buf: [@sizeOf(T)]u8 = undefined; + try reader.readSliceAll(&buf); + return std.mem.readInt(T, &buf, .little); +} + +test "integer round trip with a one-byte reader buffer" { + var encoded: [6]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try writeInt(&writer, u16, 0x1234); + try writeInt(&writer, u32, 0x56789abc); + + // Keep the reader buffer smaller than either integer to verify readInt + // does not inherit std.Io.Reader.takeInt's buffer-size requirement. + var source: std.Io.Reader = .fixed(&encoded); + var buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &buf); + + try testing.expectEqual( + @as(u16, 0x1234), + try readInt(&limited.interface, u16), + ); + try testing.expectEqual( + @as(u32, 0x56789abc), + try readInt(&limited.interface, u32), + ); +} diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig new file mode 100644 index 000000000..7e546a5e6 --- /dev/null +++ b/src/terminal/snapshot/main.zig @@ -0,0 +1,145 @@ +//! Terminal snapshot binary representation and codecs. +//! +//! This is NOT a full transport-ready format to implement generic replay +//! software such as multiplexers, recorders (e.g. asciinema), etc. The goal +//! of this package is to provide a documented, binary-compatible representation +//! for a terminal state. +//! +//! We call this a "snapshot." The snapshot is purposely laid out in a way +//! that prioritizes making a terminal functional as quickly as possible. +//! To do that, it sends the active terminal state followed by a READY record, +//! then complete history. +//! +//! READY denotes that enough of the terminal state is down that it can +//! be fully rendered at that point. This is also the point where live +//! terminals can also start accepting pty bytes, typically. But the current +//! snapshot format lacks some of the information necessary to synchronize +//! pty byte state with an authoritative server. +//! +//! After READY, we send history pages (scrollback). +//! +//! ## Snapshot Format +//! +//! This documents snapshot format 1. Version 1 is the work-in-progress +//! format that we intended to continue to break until we can promise +//! binary compatibility. +//! +//! A snapshot is one envelope followed by a sequence of records. The envelope +//! occurs once at byte zero. Every record is independently framed as a fixed +//! header followed by the number of payload bytes declared by that header. +//! +//! ```text +//! +------------------+ +//! | Envelope | +//! +------------------+ +//! | Record 1 header | +//! +------------------+ +//! | Record 1 payload | +//! +------------------+ +//! | Record 2 header | +//! +------------------+ +//! | Record 2 payload | +//! +------------------+ +//! | ... | +//! +------------------+ +//! ``` +//! +//! Record groups have a strict order: +//! +//! ```text +//! +----------------------------------------+ +//! | TERMINAL | +//! +----------------------------------------+ +//! | SCREEN * terminal.screen_count | +//! | PAGE * each screen.page_count | +//! +----------------------------------------+ +//! | READY | +//! +----------------------------------------+ +//! | HISTORY * terminal.screen_count | +//! | PAGE * each history.page_count | +//! +----------------------------------------+ +//! | FINISH | +//! +----------------------------------------+ +//! ``` +//! +//! The SCREEN and HISTORY sequence groups may each appear in any key order. +//! Each key must occur exactly once in each group and must identify one of the +//! screens declared by TERMINAL. SCREEN contains the complete pages needed to +//! restore each active area. HISTORY contains the older complete pages for its +//! screen in newest-to-oldest order so they can be prepended as they arrive. +//! Every SCREEN has one corresponding HISTORY, even when its history page count +//! is zero. FINISH terminates the snapshot. Bytes after FINISH belong to the +//! containing transport and are not consumed by snapshot decoding. +//! +//! READY and FINISH contain BLAKE3-256 digests of all preceding snapshot bytes. +//! READY therefore validates the renderable active-state prefix. FINISH covers +//! READY and all history as well, validating the complete snapshot and its +//! record ordering. Each SCREEN declares its complete logical history extent, +//! allowing a client to size its scrollbar at READY even though older PAGE +//! records arrive afterward. +//! +//! ## Encoding +//! +//! Encode a complete snapshot into any writer: +//! +//! ```zig +//! var output: std.Io.Writer.Allocating = .init(alloc); +//! defer output.deinit(); +//! +//! try snapshot.encode(alloc, &output.writer, &terminal); +//! +//! const bytes = output.written(); +//! ``` +//! +//! Encoding begins at the writer's current position, so unrelated bytes may +//! precede the snapshot. The encoder buffers only the current record payload +//! to calculate its length and CRC32C; completed records stream immediately +//! and BLAKE3 checkpoint coverage is updated incrementally. Buffering is an +//! encoder implementation detail, not a requirement of the wire format. +//! +//! A failure may leave prior complete records, or a partial record if the +//! destination itself fails. Such a prefix has no valid FINISH checkpoint and +//! cannot be restored as a complete snapshot. +//! +//! Each record type usually exposes an `encode` function that encodes +//! a complete record, such as `screen.encode`. +//! +//! ## Decoding +//! +//! `snapshot.decode` consumes exactly one snapshot through FINISH and leaves +//! any following bytes unread. This permits multiple snapshots or live protocol +//! data to share a stream without waiting for the peer to close it. Transports +//! that deliver live PTY data before history finishes must multiplex that data +//! outside this ordered snapshot record sequence. +//! +//! ```zig +//! var terminal = try snapshot.decode(alloc, io, &reader); +//! defer terminal.deinit(alloc); +//! ``` +//! +//! Use `snapshot.decodeExact` for a bounded file or buffer that must contain +//! only one snapshot. It preserves the stricter end-of-file check, which may +//! block when used with a live stream. + +pub const checkpoint = @import("checkpoint.zig"); +pub const envelope = @import("envelope.zig"); +pub const grid = @import("grid.zig"); +pub const history = @import("history.zig"); +pub const hyperlink = @import("hyperlink.zig"); +pub const page = @import("page.zig"); +pub const record = @import("record.zig"); +pub const screen = @import("screen.zig"); +pub const style = @import("style.zig"); +pub const terminal = @import("terminal.zig"); + +const codec = @import("snapshot.zig"); +pub const EncodeError = codec.EncodeError; +pub const DecodeError = codec.DecodeError; +pub const DecodeExactError = codec.DecodeExactError; +pub const encode = codec.encode; +pub const decode = codec.decode; +pub const decodeExact = codec.decodeExact; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig new file mode 100644 index 000000000..e71bc53c4 --- /dev/null +++ b/src/terminal/snapshot/page.zig @@ -0,0 +1,1462 @@ +//! PAGE record payload encoding. +//! +//! One PAGE record represents a set of rows/columns in the terminal. +//! In libghostty, this happens to map internally to a very specific +//! data structure called a "page" (hence the name), but consumers of +//! the format don't need to reproduce that. +//! +//! The important thing is that one page is fully self-contained: it has +//! a dimension (cols x rows), a set of styles, hyperlinks, cells, etc. +//! and depends on no external state to decode that with some exceptions +//! like assets such as images. +//! +//! A page could have a dimension that doesn't match the terminal +//! dimensions, e.g. for lazy resize/reflow. Callers must be prepared for +//! that. +//! +//! ## Binary Format +//! +//! Every PAGE payload begins with a fixed header, followed by a +//! payload of styles, hyperlinks, and rows and columns. +//! +//! All integers are unsigned and little-endian. +//! +//! ### Header +//! +//! | Offset | Size | Field | +//! | -----: | ---: | :--------------------------------- | +//! | 0 | 2 | Logical columns (`u16`) | +//! | 2 | 2 | Logical rows (`u16`) | +//! | 4 | 2 | Non-default style count (`u16`) | +//! | 6 | 2 | Unique hyperlink count (`u16`) | +//! | 8 | 2 | Style capacity hint (`u16`) | +//! | 10 | 2 | Hyperlink capacity bytes (`u16`) | +//! | 12 | 4 | Grapheme capacity bytes (`u32`) | +//! | 16 | 4 | String capacity bytes (`u32`) | +//! +//! The first two fields (columns and rows) denote the dimensionality +//! of the page. The payload is guaranteed to have this dimensionality; +//! every row has exactly the columns specified. +//! +//! Next, the style count and hyperlink count denote the number of +//! styles and hyperlinks respectively that are sent with the page. +//! The default style and absence of a hyperlink are implicit at native +//! ID zero and are not included in these counts. Each encoded table entry +//! carries its nonzero native page ID. +//! +//! The final four fields are allocation hints copied from the source page. +//! These represent upper limits on what this page might contain. A decoder +//! can optionally choose to use this for preallocation or it can ignore +//! and decode and allocate dynamically. +//! +//! ### Payload +//! +//! ```text +//! +---------------------------+ +//! | Header | +//! | 20 bytes | +//! +---------------------------+ +//! | Style table | +//! | style_count entries | +//! +---------------------------+ +//! | Hyperlink table | +//! | hyperlink_count entries | +//! +---------------------------+ +//! | Grid | +//! | one record per row | +//! +---------------------------+ +//! +//! Style entry = encoded ID + style record +//! Hyperlink entry = encoded ID + hyperlink record +//! Grid = row 0 ... row (rows - 1) +//! Row = row header + cell 0 ... cell (columns - 1) +//! Cell = cell header + grapheme suffix codepoints +//! ``` +//! +//! Following the header, the payload contains exactly `style_count` style +//! records and `hyperlink_count` hyperlink records, followed by exactly +//! `rows` row records. There is no padding between records. +//! +//! Each style begins with an ID (`u16`) followed by the typical style +//! binary representation (in style.zig). The ID is what cells will use +//! to reference the style. Decoders must maintain some kind of mapping +//! in order to associate these properly. +//! +//! Each hyperlink also begins with an ID (`u16`) with the same semantics +//! as the style ID. The hyperlink and style IDs are separate namespaces, +//! so they may collide. Following the ID, the hyperlink binary format +//! is encoded directly. +//! +//! Both style and hyperlink IDs are not guaranteed to be in any specific +//! order and may contain gaps (e.g. ID 1 and 3 but not 2). +//! +//! Rows and cells use the grid encoding documented in `grid.zig`. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const test_fixture = @import("fixture.zig"); +const grid = @import("grid.zig"); +const hyperlink = @import("hyperlink.zig"); +const io = @import("io.zig"); +const record = @import("record.zig"); +const style = @import("style.zig"); +const terminal_hyperlink = @import("../hyperlink.zig"); +const terminal_page = @import("../page.zig"); +const terminal_style = @import("../style.zig"); + +// Frequent constants we use +const TerminalHyperlink = terminal_hyperlink.Hyperlink; +const TerminalHyperlinkId = terminal_hyperlink.Id; +const TerminalHyperlinkPageEntry = terminal_hyperlink.PageEntry; +const TerminalCell = terminal_page.Cell; +const TerminalPage = terminal_page.Page; +const TerminalPageCapacity = terminal_page.Capacity; +const TerminalRow = terminal_page.Row; +const TerminalStyle = terminal_style.Style; +const TerminalStyleId = terminal_style.Id; + +const PayloadEncodeError = hyperlink.EncodeError || grid.EncodeError; + +const PayloadDecodeError = std.Io.Reader.Error || + Header.CapacityError || + error{ + /// The hyperlink kind is not defined by snapshot version 1. + InvalidKind, + + /// Native page backing memory could not be allocated. + OutOfMemory, + + /// The caller-provided page does not have the advertised capacity. + InvalidDestinationCapacity, + }; + +/// Errors possible while encoding a complete PAGE record. +pub const EncodeError = PayloadEncodeError || record.Writer.FinishError; + +/// Encode one complete PAGE record from a native page. +/// +/// The payload is built in the stream's reusable record buffer. If payload +/// encoding fails, no bytes from this record are emitted. +pub fn encode( + page: *const TerminalPage, + destination: *record.Writer, +) EncodeError!void { + const payload = destination.begin(.page); + errdefer destination.cancel(); + try encodePayload(page, payload); + try destination.finish(); +} + +/// Errors possible while decoding and validating a complete PAGE record. +pub const DecodeError = PayloadDecodeError || + record.Reader.InitError || + record.Reader.FinishError || + TerminalPage.IntegrityError || + error{ + /// The next record is valid but is not a PAGE. + UnexpectedRecordTag, + }; + +/// Decode and validate one complete PAGE record into a fresh native page. +/// +/// The record tag, payload boundary, CRC32C, payload contents, and final page +/// integrity are all validated before the page is returned. `alloc` is used +/// only for temporary decode remaps and integrity-check storage. +pub fn decode( + reader: *std.Io.Reader, + alloc: Allocator, +) DecodeError!TerminalPage { + var decoder: Decoder = undefined; + try decoder.init(reader); + + var page = TerminalPage.init(decoder.capacity()) catch + return error.OutOfMemory; + errdefer page.deinit(); + try decoder.decode(&page, alloc); + return page; +} + +/// PAGE record decoder where the caller is responsible for owning +/// the Page memory. This is particularly useful paired with +/// PageList.Builder so you can build up a proper PageList with +/// valid memory ownership. +pub const Decoder = struct { + record_reader: record.Reader, + header: Header, + + /// Begin decoding one PAGE record and expose its required capacity. + /// After this, call `capacity` to get the capacity to allocate + /// the page properly, then `decode` into it. + pub fn init(self: *Decoder, reader: *std.Io.Reader) DecodeError!void { + try self.record_reader.init(reader); + if (self.record_reader.header.tag != .page) { + return error.UnexpectedRecordTag; + } + + self.header = try Header.decode(self.record_reader.payloadReader()); + _ = try self.header.pageCapacity(); + } + + /// Return the exact native capacity advertised by the PAGE header. + pub fn capacity(self: *const Decoder) TerminalPageCapacity { + return self.header.pageCapacity() catch unreachable; + } + + /// Decode the remaining payload into caller-owned native page storage. + /// + /// The destination must be freshly initialized with `capacity`. `alloc` + /// is used only for temporary ID remaps and integrity-check storage. + pub fn decode( + self: *Decoder, + destination: *TerminalPage, + alloc: Allocator, + ) DecodeError!void { + if (!std.meta.eql(destination.capacity, self.capacity())) { + return error.InvalidDestinationCapacity; + } + + destination.size = .{ + .cols = self.header.columns, + .rows = self.header.rows, + }; + try decodePayloadBody( + self.record_reader.payloadReader(), + alloc, + destination, + self.header, + ); + try self.record_reader.finish(); + try destination.verifyIntegrity(alloc); + } +}; + +/// Encode a PAGE payload directly from a native page. +fn encodePayload( + page: *const TerminalPage, + writer: *std.Io.Writer, +) PayloadEncodeError!void { + // Write header + try Header.init(page).encode(writer); + + // Sparse styles + var style_it = page.styles.iterator(page.memory); + while (style_it.next()) |entry| { + try io.writeInt(writer, TerminalStyleId, entry.id); + try style.encode(entry.value_ptr.*, writer); + } + + // Sparse hyperlinks + var hyperlink_it = page.hyperlink_set.iterator(page.memory); + while (hyperlink_it.next()) |entry| { + try io.writeInt(writer, TerminalHyperlinkId, entry.id); + try hyperlink.encode(pageHyperlink(page, entry.value_ptr), writer); + } + + // Rows and cells + try grid.encode(page, writer); +} + +/// Decode a PAGE payload directly into a fresh native page. +/// +/// `alloc` is used only for the temporary native-ID remap tables. Styles, +/// hyperlinks, strings, graphemes, rows, and cells are stored in the page. +fn decodePayload( + reader: *std.Io.Reader, + alloc: Allocator, +) PayloadDecodeError!TerminalPage { + const header = try Header.decode(reader); + const capacity = try header.pageCapacity(); + var page = TerminalPage.init(capacity) catch + return error.OutOfMemory; + errdefer page.deinit(); + try decodePayloadBody(reader, alloc, &page, header); + return page; +} + +/// Decode PAGE tables and grid after the header into initialized native page +/// storage with the exact advertised capacity and dimensions. +fn decodePayloadBody( + reader: *std.Io.Reader, + alloc: Allocator, + page: *TerminalPage, + header: Header, +) PayloadDecodeError!void { + page.pauseIntegrityChecks(true); + defer page.pauseIntegrityChecks(false); + + var style_remap = grid.StyleRemap.init(alloc); + defer style_remap.deinit(); + style_remap.ensureTotalCapacity(header.style_count) catch + return error.OutOfMemory; + + var hyperlink_remap = grid.HyperlinkRemap.init(alloc); + defer hyperlink_remap.deinit(); + hyperlink_remap.ensureTotalCapacity(header.hyperlink_count) catch + return error.OutOfMemory; + + // Styles + for (0..header.style_count) |_| { + const native_id = try io.readInt(reader, TerminalStyleId); + const value: ?TerminalStyle = style.decodeOrDiscard(reader) catch null; + + // Zero is reserved for the implicit default. For a duplicate encoded + // ID, the first entry wins and this complete entry is simply ignored. + if (native_id == 0 or style_remap.contains(native_id)) continue; + + // Invalid/default styles map to the native default. Repeated concrete + // values share the existing native entry, while capacity failure also + // degrades only this style. + const decoded_id: TerminalStyleId = if (value) |valid| decoded: { + if (valid.default()) break :decoded 0; + if (page.styles.lookup(page.memory, valid)) |existing| { + break :decoded existing; + } + break :decoded page.styles.add( + page.memory, + valid, + ) catch 0; + } else 0; + style_remap.putAssumeCapacityNoClobber( + native_id, + decoded_id, + ); + } + + // Hyperlinks + for (0..header.hyperlink_count) |_| { + const native_id = try io.readInt(reader, TerminalHyperlinkId); + const decoded_id = try hyperlink.decodePage(page, reader); + + // As with styles, zero is reserved and the first duplicate encoded ID + // wins. Release any native entry reference created for an ignored ID. + if (native_id == 0 or hyperlink_remap.contains(native_id)) { + if (decoded_id != 0) { + page.hyperlink_set.release(page.memory, decoded_id); + } + continue; + } + + // Zero records an ignored table entry. Grid decoding treats every + // reference to it as no hyperlink. + hyperlink_remap.putAssumeCapacityNoClobber( + native_id, + decoded_id, + ); + } + + // Rows and cells + try grid.decode( + page, + reader, + &style_remap, + &hyperlink_remap, + ); +} + +/// The fixed logical dimensions, table counts, and allocation hints at the +/// start of PAGE. +pub const Header = struct { + /// Number of bytes written by `encode`, calculated using the encoder itself + /// so this remains synchronized with the field-by-field wire format. + pub const len = computeLen(); + + comptime { + // This size is part of the wire format. If it changes, the snapshot + // version and golden fixtures must also change. + std.debug.assert(len == 20); + } + + /// Number of logical cells in every encoded row. + columns: u16, + + /// Number of logical rows encoded after the tables. + rows: u16, + + /// Number of non-default style entries following the header. + style_count: u16, + + /// Number of unique hyperlink entries following the style table. + hyperlink_count: u16, + + /// Suggested capacity for the native non-default style set. + style_capacity: u16, + + /// Suggested native hyperlink storage capacity in bytes. + hyperlink_capacity_bytes: u16, + + /// Suggested native grapheme storage capacity in bytes. + grapheme_capacity_bytes: u32, + + /// Suggested native string storage capacity in bytes. + string_capacity_bytes: u32, + + /// Encode the fixed PAGE payload header. + pub fn encode( + self: Header, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try io.writeInt(writer, u16, self.columns); + try io.writeInt(writer, u16, self.rows); + try io.writeInt(writer, u16, self.style_count); + try io.writeInt(writer, u16, self.hyperlink_count); + try io.writeInt(writer, u16, self.style_capacity); + try io.writeInt(writer, u16, self.hyperlink_capacity_bytes); + try io.writeInt(writer, u32, self.grapheme_capacity_bytes); + try io.writeInt(writer, u32, self.string_capacity_bytes); + } + + /// Decode the fixed PAGE payload header. + /// + /// This reads field values only. The complete PAGE decoder is responsible + /// for applying configured limits before using any capacity hint. + pub fn decode(reader: *std.Io.Reader) std.Io.Reader.Error!Header { + return .{ + .columns = try io.readInt(reader, u16), + .rows = try io.readInt(reader, u16), + .style_count = try io.readInt(reader, u16), + .hyperlink_count = try io.readInt(reader, u16), + .style_capacity = try io.readInt(reader, u16), + .hyperlink_capacity_bytes = try io.readInt(reader, u16), + .grapheme_capacity_bytes = try io.readInt(reader, u32), + .string_capacity_bytes = try io.readInt(reader, u32), + }; + } + + /// Initialize a header from the current contents of a native page. + /// + /// This copies the page's existing allocation capacities. It does not + /// scan cells, allocate, or encode any bytes. + fn init(page: *const TerminalPage) Header { + return .{ + .columns = page.size.cols, + .rows = page.size.rows, + .style_count = @intCast(page.styles.count()), + .hyperlink_count = @intCast(page.hyperlink_set.count()), + .style_capacity = page.capacity.styles, + .hyperlink_capacity_bytes = page.capacity.hyperlink_bytes, + .grapheme_capacity_bytes = page.capacity.grapheme_bytes, + .string_capacity_bytes = page.capacity.string_bytes, + }; + } + + pub const CapacityError = error{ + InvalidDimensions, + }; + + /// Validate native allocation requirements and produce the page capacity. + fn pageCapacity(self: Header) CapacityError!TerminalPageCapacity { + if (self.columns == 0 or self.rows == 0) { + return error.InvalidDimensions; + } + + return .{ + .cols = self.columns, + .rows = self.rows, + .styles = self.style_capacity, + .hyperlink_bytes = self.hyperlink_capacity_bytes, + .grapheme_bytes = self.grapheme_capacity_bytes, + .string_bytes = self.string_capacity_bytes, + }; + } + + fn computeLen() usize { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + const header: Header = .{ + .columns = 0, + .rows = 0, + .style_count = 0, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + header.encode(&writer) catch unreachable; + return writer.end; + } +}; + +fn pageHyperlink( + page: *const TerminalPage, + entry: *const TerminalHyperlinkPageEntry, +) TerminalHyperlink { + return .{ + .id = switch (entry.id) { + .implicit => |value| .{ .implicit = value }, + .explicit => |value| .{ .explicit = value.slice(page.memory) }, + }, + .uri = entry.uri.slice(page.memory), + }; +} + +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 = .{ + .columns = 0x0102, + .rows = 0x0304, + .style_count = 0x0506, + .hyperlink_count = 0x0708, + .style_capacity = 0x090a, + .hyperlink_capacity_bytes = 0x0b0c, + .grapheme_capacity_bytes = 0x0d0e0f10, + .string_capacity_bytes = 0x11121314, + }; + var buf: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try header.encode(&writer); + try test_fixture.expectEqual( + .bytes, + "src/terminal/snapshot/testdata/page-header-v1.hex", + "snapshot_fixture-page-header-v1.hex", + &test_header_fixture, + writer.buffered(), + ); + + var source: std.Io.Reader = .fixed(&test_header_fixture); + var read_buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &read_buf); + try std.testing.expectEqual(header, try Header.decode(&limited.interface)); +} + +test "reject every truncation" { + for (0..Header.len) |len| { + var reader: std.Io.Reader = .fixed(test_header_fixture[0..len]); + try std.testing.expectError(error.EndOfStream, Header.decode(&reader)); + } +} + +test "framed PAGE encode and decode a sparse native page" { + const capacity: TerminalPageCapacity = .{ + .cols = 3, + .rows = 2, + .styles = 8, + .hyperlink_bytes = 512, + .grapheme_bytes = 128, + .string_bytes = 256, + }; + var page = try TerminalPage.init(capacity); + defer page.deinit(); + + // Create holes in both source tables so the encoded IDs exercise sparse + // table remapping rather than coincidentally matching decoded IDs. + const style_a = try page.styles.add(page.memory, .{ + .flags = .{ .bold = true }, + }); + const dead_style = try page.styles.add(page.memory, .{ + .flags = .{ .italic = true }, + }); + const style_b = try page.styles.add(page.memory, .{ + .bg_color = .{ .palette = 42 }, + }); + page.styles.release(page.memory, dead_style); + + const first = page.getRowAndCell(0, 0); + first.cell.style_id = style_a; + first.row.styled = true; + + const second = page.getRowAndCell(1, 0); + second.cell.style_id = style_b; + second.row.styled = true; + + const third = page.getRowAndCell(2, 0); + page.styles.use(page.memory, style_a); + third.cell.style_id = style_a; + third.row.styled = true; + + const hyperlink_a = try page.insertHyperlink(.{ + .id = .{ .explicit = "a" }, + .uri = "alpha", + }); + const dead_hyperlink = try page.insertHyperlink(.{ + .id = .{ .explicit = "dead-id" }, + .uri = "dead-uri", + }); + const hyperlink_b = try page.insertHyperlink(.{ + .id = .{ .implicit = 0x01020304 }, + .uri = "beta", + }); + page.hyperlink_set.release(page.memory, dead_hyperlink); + + page.hyperlink_set.use(page.memory, hyperlink_a); + try page.setHyperlink(first.row, first.cell, hyperlink_a); + try page.setHyperlink(second.row, second.cell, hyperlink_b); + try page.setHyperlink(third.row, third.cell, hyperlink_a); + + // Cover graphemes, wide-cell pairs, colors, protection, and semantic + // metadata in one compact two-row page. + const grapheme = page.getRowAndCell(0, 1); + grapheme.cell.* = .init('x'); + try page.setGraphemes( + grapheme.row, + grapheme.cell, + &.{ 0x0301, 0x0302 }, + ); + + first.cell.content = .{ .codepoint = .{ .data = 'A' } }; + first.cell.wide = .wide; + first.cell.protected = true; + first.cell.semantic_content = .prompt; + first.row.semantic_prompt = .prompt; + + second.cell.wide = .spacer_tail; + second.cell.semantic_content = .input; + + third.cell.content_tag = .bg_color_palette; + third.cell.content = .{ .color_palette = .{ .data = 7 } }; + + const rgb = page.getRowAndCell(1, 1); + rgb.cell.content_tag = .bg_color_rgb; + rgb.cell.content = .{ .color_rgb = .{ + .r = 0xaa, + .g = 0xbb, + .b = 0xcc, + } }; + rgb.cell.protected = true; + + const spacer_head = page.getRowAndCell(2, 1); + spacer_head.cell.wide = .spacer_head; + spacer_head.row.wrap = true; + spacer_head.row.wrap_continuation = true; + spacer_head.row.semantic_prompt = .prompt_continuation; + + const header: Header = .{ + .columns = 3, + .rows = 2, + .style_count = 2, + .hyperlink_count = 2, + .style_capacity = 8, + .hyperlink_capacity_bytes = 512, + .grapheme_capacity_bytes = 128, + .string_capacity_bytes = 256, + }; + try std.testing.expectEqual(header, Header.init(&page)); + + // Lock the payload bytes and independently verify the counting writer sees + // the same encoded length. + var counter: std.Io.Writer.Discarding = .init(&.{}); + try encodePayload(&page, &counter.writer); + + var encoded: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try encodePayload(&page, &writer); + + try test_fixture.expectEqual( + .page, + "src/terminal/snapshot/testdata/page-v1.hex", + "snapshot_fixture-page-v1.hex", + &test_page_fixture, + writer.buffered(), + ); + try std.testing.expectEqual( + @as(u64, test_page_fixture.len), + counter.count, + ); + + // Decode the checked-in reference rather than the just-generated bytes. + // A one-byte backing buffer exercises streaming reads across every field. + var source: std.Io.Reader = .fixed(&test_page_fixture); + var read_buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &read_buf); + var decoded = try decodePayload( + &limited.interface, + std.testing.allocator, + ); + defer decoded.deinit(); + + try std.testing.expectEqual(header, Header.init(&decoded)); + try decoded.verifyIntegrity(std.testing.allocator); + + // Decoding compacts the sparse source IDs while preserving table values + // and all cell references through the remap tables. + var style_it = decoded.styles.iterator(decoded.memory); + const decoded_style_a = style_it.next().?; + try std.testing.expectEqual(@as(TerminalStyleId, 1), decoded_style_a.id); + try std.testing.expect((TerminalStyle{ + .flags = .{ .bold = true }, + }).eql(decoded_style_a.value_ptr.*)); + const decoded_style_b = style_it.next().?; + try std.testing.expectEqual(@as(TerminalStyleId, 2), decoded_style_b.id); + try std.testing.expect((TerminalStyle{ + .bg_color = .{ .palette = 42 }, + }).eql(decoded_style_b.value_ptr.*)); + try std.testing.expectEqual(null, style_it.next()); + + var hyperlink_it = decoded.hyperlink_set.iterator(decoded.memory); + const decoded_link_a = hyperlink_it.next().?; + try std.testing.expectEqual(@as(TerminalHyperlinkId, 1), decoded_link_a.id); + const decoded_hyperlink_a = pageHyperlink(&decoded, decoded_link_a.value_ptr); + try std.testing.expectEqualStrings("a", decoded_hyperlink_a.id.explicit); + try std.testing.expectEqualStrings("alpha", decoded_hyperlink_a.uri); + const decoded_link_b = hyperlink_it.next().?; + try std.testing.expectEqual(@as(TerminalHyperlinkId, 2), decoded_link_b.id); + const decoded_hyperlink_b = pageHyperlink(&decoded, decoded_link_b.value_ptr); + try std.testing.expectEqual(@as(u32, 0x01020304), decoded_hyperlink_b.id.implicit); + try std.testing.expectEqualStrings("beta", decoded_hyperlink_b.uri); + try std.testing.expectEqual(null, hyperlink_it.next()); + + const decoded_first = decoded.getRowAndCell(0, 0); + try std.testing.expectEqual(@as(TerminalStyleId, 1), decoded_first.cell.style_id); + try std.testing.expectEqual( + @as(TerminalHyperlinkId, 1), + decoded.lookupHyperlink(decoded_first.cell).?, + ); + + const decoded_second = decoded.getRowAndCell(1, 0); + try std.testing.expectEqual(@as(TerminalStyleId, 2), decoded_second.cell.style_id); + try std.testing.expectEqual( + @as(TerminalHyperlinkId, 2), + decoded.lookupHyperlink(decoded_second.cell).?, + ); + + // The first re-encode reflects compacted IDs; subsequent round trips must + // stabilize byte-for-byte. + var reencoded: [512]u8 = undefined; + var rewriter: std.Io.Writer = .fixed(&reencoded); + try encodePayload(&decoded, &rewriter); + try std.testing.expect(!std.mem.eql( + u8, + writer.buffered(), + rewriter.buffered(), + )); + + var reencoded_reader: std.Io.Reader = .fixed(rewriter.buffered()); + var decoded_again = try decodePayload( + &reencoded_reader, + std.testing.allocator, + ); + defer decoded_again.deinit(); + try decoded_again.verifyIntegrity(std.testing.allocator); + + var reencoded_again: [512]u8 = undefined; + var rewriter_again: std.Io.Writer = .fixed(&reencoded_again); + try encodePayload(&decoded_again, &rewriter_again); + try std.testing.expectEqualStrings( + rewriter.buffered(), + rewriter_again.buffered(), + ); +} + +test "framed PAGE golden empty record" { + const capacity: TerminalPageCapacity = .{ + .cols = 1, + .rows = 1, + .styles = 0, + .hyperlink_bytes = 0, + .grapheme_bytes = 0, + .string_bytes = 0, + }; + var page = try TerminalPage.init(capacity); + defer page.deinit(); + + var destination: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer destination.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + try encode(&page, &stream); + try test_fixture.expectEqual( + .bytes, + "src/terminal/snapshot/testdata/page-empty-record-v1.hex", + "snapshot_fixture-page-empty-record-v1.hex", + &test_empty_framed_page_fixture, + destination.written(), + ); + + var source: std.Io.Reader = .fixed(&test_empty_framed_page_fixture); + var decoded = try decode(&source, std.testing.allocator); + defer decoded.deinit(); + try std.testing.expectEqual( + Header.init(&page), + Header.init(&decoded), + ); +} + +test "framed PAGE rejects incomplete wide cells transactionally" { + const testing = std.testing; + + // One column puts the wide cell at the row end. Two columns put an + // ordinary narrow cell after it. Neither case supplies a spacer tail. + for ([_]u16{ 1, 2 }) |columns| { + var page = try TerminalPage.init(.{ + .cols = columns, + .rows = 1, + }); + defer page.deinit(); + const first = page.getRowAndCell(0, 0).cell; + first.* = .init('A'); + first.wide = .wide; + if (columns > 1) { + page.getRowAndCell(1, 0).cell.* = .init('B'); + } + + var destination: std.Io.Writer.Allocating = .init(testing.allocator); + defer destination.deinit(); + try destination.writer.writeAll("prefix"); + var stream: record.Writer = .init( + testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + try testing.expectError( + error.InvalidWideCell, + encode(&page, &stream), + ); + try testing.expectEqualStrings("prefix", destination.written()); + } +} + +test "framed PAGE rejects a different record tag" { + var wrong_tag = test_empty_framed_page_fixture; + std.mem.writeInt( + u16, + wrong_tag[0..2], + @intFromEnum(record.Tag.screen), + .little, + ); + var reader: std.Io.Reader = .fixed(&wrong_tag); + try std.testing.expectError( + error.UnexpectedRecordTag, + decode(&reader, std.testing.allocator), + ); +} + +test "framed PAGE preserves following bytes" { + var source: std.Io.Reader = .fixed( + test_empty_framed_page_fixture ++ "next", + ); + var decoded = try decode(&source, std.testing.allocator); + defer decoded.deinit(); + try std.testing.expectEqualStrings("next", try source.take(4)); +} + +test "decode sparse page rejects every truncation" { + for (0..test_page_fixture.len) |len| { + var reader: std.Io.Reader = .fixed(test_page_fixture[0..len]); + try std.testing.expectError( + error.EndOfStream, + decodePayload(&reader, std.testing.allocator), + ); + } +} + +test "decode accepts unordered sparse style IDs and ignores zero" { + const header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 2, + .hyperlink_count = 0, + .style_capacity = 8, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var descending: [ + Header.len + + 2 * (2 + style.len) + + 1 + + 16 + ]u8 = undefined; + var descending_writer: std.Io.Writer = .fixed(&descending); + try header.encode(&descending_writer); + try io.writeInt(&descending_writer, TerminalStyleId, 3); + try style.encode(.{ .flags = .{ .bold = true } }, &descending_writer); + try io.writeInt(&descending_writer, TerminalStyleId, 2); + try style.encode(.{ .flags = .{ .italic = true } }, &descending_writer); + try descending_writer.writeByte(0); + try descending_writer.splatByteAll(0, 16); + + var descending_reader: std.Io.Reader = .fixed( + descending_writer.buffered(), + ); + var decoded_descending = try decodePayload( + &descending_reader, + std.testing.allocator, + ); + defer decoded_descending.deinit(); + try std.testing.expectEqual(@as(usize, 2), decoded_descending.styles.count()); + + const one_header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 1, + .hyperlink_count = 0, + .style_capacity = 8, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var zero: [Header.len + 2 + style.len + 17]u8 = undefined; + var zero_writer: std.Io.Writer = .fixed(&zero); + try one_header.encode(&zero_writer); + try io.writeInt(&zero_writer, TerminalStyleId, 0); + try style.encode(.{ .flags = .{ .bold = true } }, &zero_writer); + + var zero_grid = try TerminalPage.init(.{ .cols = 1, .rows = 1 }); + defer zero_grid.deinit(); + try grid.encode(&zero_grid, &zero_writer); + + var zero_reader: std.Io.Reader = .fixed(zero_writer.buffered()); + var decoded_zero = try decodePayload( + &zero_reader, + std.testing.allocator, + ); + defer decoded_zero.deinit(); + try std.testing.expectEqual(@as(usize, 0), decoded_zero.styles.count()); +} + +test "decode accepts unordered sparse hyperlink IDs" { + const header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 0, + .hyperlink_count = 2, + .style_capacity = 0, + .hyperlink_capacity_bytes = 512, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 6, + }; + + const first: TerminalHyperlink = .{ + .id = .{ .implicit = 1 }, + .uri = "one", + }; + const second: TerminalHyperlink = .{ + .id = .{ .implicit = 2 }, + .uri = "two", + }; + + var encoded: [ + Header.len + + 2 * 14 + + 1 + + 16 + ]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try header.encode(&writer); + try io.writeInt(&writer, TerminalHyperlinkId, 3); + try hyperlink.encode(first, &writer); + try io.writeInt(&writer, TerminalHyperlinkId, 2); + try hyperlink.encode(second, &writer); + try writer.writeByte(0); + try writer.splatByteAll(0, 16); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + var decoded = try decodePayload( + &reader, + std.testing.allocator, + ); + defer decoded.deinit(); + try std.testing.expectEqual( + @as(usize, 2), + decoded.hyperlink_set.count(), + ); +} + +test "decode defaults missing sparse cell references" { + // An unknown style ID falls back to the default style. + const style_header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 0, + .hyperlink_count = 0, + .style_capacity = 8, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var style_encoded: [Header.len + 1 + 16]u8 = undefined; + var style_writer: std.Io.Writer = .fixed(&style_encoded); + try style_header.encode(&style_writer); + try style_writer.writeByte(0); + try style_writer.writeAll(&.{ 0, 0, 0, 0 }); + try io.writeInt(&style_writer, TerminalStyleId, 1); + try io.writeInt(&style_writer, TerminalHyperlinkId, 0); + try io.writeInt(&style_writer, u32, 0); + try io.writeInt(&style_writer, u32, 0); + + var style_reader: std.Io.Reader = .fixed(style_writer.buffered()); + var style_page = try decodePayload( + &style_reader, + std.testing.allocator, + ); + defer style_page.deinit(); + try std.testing.expectEqual( + @as(TerminalStyleId, 0), + style_page.getRowAndCell(0, 0).cell.style_id, + ); + + // An unknown hyperlink ID likewise falls back to no hyperlink. + const hyperlink_header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 0, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 512, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var hyperlink_encoded: [Header.len + 1 + 16]u8 = undefined; + var hyperlink_writer: std.Io.Writer = .fixed(&hyperlink_encoded); + try hyperlink_header.encode(&hyperlink_writer); + try hyperlink_writer.writeByte(0); + try hyperlink_writer.writeAll(&.{ 0, 0, 0, 0 }); + try io.writeInt(&hyperlink_writer, TerminalStyleId, 0); + try io.writeInt(&hyperlink_writer, TerminalHyperlinkId, 1); + try io.writeInt(&hyperlink_writer, u32, 0); + try io.writeInt(&hyperlink_writer, u32, 0); + + var hyperlink_reader: std.Io.Reader = .fixed( + hyperlink_writer.buffered(), + ); + var hyperlink_page = try decodePayload( + &hyperlink_reader, + std.testing.allocator, + ); + defer hyperlink_page.deinit(); + const hyperlink_cell = hyperlink_page.getRowAndCell(0, 0).cell; + try std.testing.expect(!hyperlink_cell.hyperlink); + try std.testing.expectEqual( + null, + hyperlink_page.lookupHyperlink(hyperlink_cell), + ); +} + +test "decode normalizes invalid grid semantics" { + const header: Header = .{ + .columns = 3, + .rows = 1, + .style_count = 0, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var encoded: [Header.len + 1 + 3 * 16 + 12]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try header.encode(&writer); + + // Preserve wrap while degrading the unknown semantic-prompt value to none + // and ignoring all reserved row bits. + try writer.writeByte(0xFD); + + // An unknown content and width kind, invalid semantic content, reserved + // bytes, and suffix data all degrade to a blank narrow output cell. + try writer.writeAll(&.{ 3, 4, 0xFF, 0xFF }); + try io.writeInt(&writer, TerminalStyleId, 0); + try io.writeInt(&writer, TerminalHyperlinkId, 0); + try io.writeInt(&writer, u32, 0xD800); + try io.writeInt(&writer, u32, 1); + try io.writeInt(&writer, u32, 0x110000); + + // A recognized text cell replaces an invalid Unicode scalar with U+FFFD. + // Its valid suffix cannot fit the advertised zero capacity, so decoding + // preserves the base character and drops the optional suffix. + try writer.writeAll(&.{ 0, 0, 0, 0 }); + try io.writeInt(&writer, TerminalStyleId, 0); + try io.writeInt(&writer, TerminalHyperlinkId, 0); + try io.writeInt(&writer, u32, 0xD800); + try io.writeInt(&writer, u32, 1); + try io.writeInt(&writer, u32, 'x'); + + // Reserved palette bits and a nonsensical suffix do not obscure the valid + // low palette byte; the suffix is consumed and ignored. + try writer.writeAll(&.{ 1, 0, 0, 0xFF }); + try io.writeInt(&writer, TerminalStyleId, 0); + try io.writeInt(&writer, TerminalHyperlinkId, 0); + try io.writeInt(&writer, u32, 0xFFFFFF07); + try io.writeInt(&writer, u32, 1); + try io.writeInt(&writer, u32, 'x'); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + var page = try decodePayload(&reader, std.testing.allocator); + defer page.deinit(); + try page.verifyIntegrity(std.testing.allocator); + + const first = page.getRowAndCell(0, 0); + try std.testing.expect(first.row.wrap); + try std.testing.expectEqual( + terminal_page.Row.SemanticPrompt.none, + first.row.semantic_prompt, + ); + try std.testing.expectEqual(@as(u21, 0), first.cell.codepoint()); + try std.testing.expectEqual( + terminal_page.Cell.Wide.narrow, + first.cell.wide, + ); + try std.testing.expectEqual( + terminal_page.Cell.SemanticContent.output, + first.cell.semantic_content, + ); + try std.testing.expect(!first.cell.protected); + try std.testing.expect(!first.cell.hasGrapheme()); + + const second = page.getRowAndCell(1, 0).cell; + try std.testing.expectEqual(@as(u21, 0xFFFD), second.codepoint()); + try std.testing.expect(!second.hasGrapheme()); + + const third = page.getRowAndCell(2, 0).cell; + try std.testing.expectEqual( + terminal_page.Cell.ContentTag.bg_color_palette, + third.content_tag, + ); + try std.testing.expectEqual( + @as(u8, 7), + third.content.color_palette.data, + ); + try std.testing.expect(!third.hasGrapheme()); +} + +test "decode validates dimensions" { + const cases = [_]Header{ + .{ + .columns = 0, + .rows = 24, + .style_count = 0, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }, + .{ + .columns = 80, + .rows = 0, + .style_count = 0, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }, + }; + + for (cases) |header| { + var encoded: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try header.encode(&writer); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try std.testing.expectError( + error.InvalidDimensions, + decodePayload(&reader, std.testing.allocator), + ); + } +} + +test "decode normalizes duplicate default and invalid style entries" { + const header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 2, + .hyperlink_count = 0, + .style_capacity = 16, + .hyperlink_capacity_bytes = 512, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 256, + }; + const duplicate_style: TerminalStyle = .{ + .flags = .{ .bold = true }, + }; + + // Generate a valid grid whose cell references a sparse style ID. The PAGE + // table below can then give that ID duplicate, default, or invalid contents + // without hand-authoring the cell wire format. + var grid_page = try TerminalPage.init(.{ + .cols = 1, + .rows = 1, + .styles = 8, + }); + defer grid_page.deinit(); + const released_style_a = try grid_page.styles.add( + grid_page.memory, + .{ .flags = .{ .italic = true } }, + ); + const released_style_b = try grid_page.styles.add( + grid_page.memory, + .{ .bg_color = .{ .palette = 1 } }, + ); + const encoded_style_id = try grid_page.styles.add( + grid_page.memory, + duplicate_style, + ); + grid_page.styles.release(grid_page.memory, released_style_a); + grid_page.styles.release(grid_page.memory, released_style_b); + const grid_cell = grid_page.getRowAndCell(0, 0); + grid_cell.cell.* = .init('A'); + grid_cell.cell.style_id = encoded_style_id; + grid_cell.row.styled = true; + + var grid_bytes: [64]u8 = undefined; + var grid_writer: std.Io.Writer = .fixed(&grid_bytes); + try grid.encode(&grid_page, &grid_writer); + + var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer encoded.deinit(); + try header.encode(&encoded.writer); + try io.writeInt(&encoded.writer, TerminalStyleId, 1); + try style.encode(duplicate_style, &encoded.writer); + try io.writeInt(&encoded.writer, TerminalStyleId, encoded_style_id); + try style.encode(duplicate_style, &encoded.writer); + try encoded.writer.writeAll(grid_writer.buffered()); + + var reader: std.Io.Reader = .fixed(encoded.written()); + var duplicate_decoded = try decodePayload( + &reader, + std.testing.allocator, + ); + defer duplicate_decoded.deinit(); + try std.testing.expectEqual(@as(usize, 1), duplicate_decoded.styles.count()); + const duplicate_cell = duplicate_decoded.getRowAndCell(0, 0).cell; + try std.testing.expect(duplicate_cell.style_id != 0); + try std.testing.expect( + duplicate_decoded.styles.get( + duplicate_decoded.memory, + duplicate_cell.style_id, + ).flags.bold, + ); + + const default_header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 1, + .hyperlink_count = 0, + .style_capacity = 16, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + var default_encoded: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer default_encoded.deinit(); + try default_header.encode(&default_encoded.writer); + try io.writeInt( + &default_encoded.writer, + TerminalStyleId, + encoded_style_id, + ); + try style.encode(.{}, &default_encoded.writer); + try default_encoded.writer.writeAll(grid_writer.buffered()); + + var default_reader: std.Io.Reader = .fixed(default_encoded.written()); + var default_decoded = try decodePayload( + &default_reader, + std.testing.allocator, + ); + defer default_decoded.deinit(); + try std.testing.expectEqual( + @as(TerminalStyleId, 0), + default_decoded.getRowAndCell(0, 0).cell.style_id, + ); + + // The strict style codec rejects this kind, but PAGE owns the fixed entry + // boundary and can safely map the encoded ID to the default style. A zero + // capacity hint also remains advisory. + var invalid_header = default_header; + invalid_header.style_capacity = 0; + var invalid_encoded: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer invalid_encoded.deinit(); + try invalid_header.encode(&invalid_encoded.writer); + try io.writeInt( + &invalid_encoded.writer, + TerminalStyleId, + encoded_style_id, + ); + try invalid_encoded.writer.writeByte(3); + try invalid_encoded.writer.splatByteAll(0, style.len - 1); + try invalid_encoded.writer.writeAll(grid_writer.buffered()); + + var invalid_reader: std.Io.Reader = .fixed(invalid_encoded.written()); + var invalid_decoded = try decodePayload( + &invalid_reader, + std.testing.allocator, + ); + defer invalid_decoded.deinit(); + try std.testing.expectEqual( + @as(TerminalStyleId, 0), + invalid_decoded.getRowAndCell(0, 0).cell.style_id, + ); +} + +test "decode reuses duplicate hyperlinks" { + const header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 0, + .hyperlink_count = 2, + .style_capacity = 0, + .hyperlink_capacity_bytes = 512, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 16, + }; + const duplicate: TerminalHyperlink = .{ + .id = .{ .explicit = "id" }, + .uri = "uri", + }; + + // As with styles above, use a sparse native ID to make grid.encode produce + // the cell reference whose table value this test deliberately duplicates. + var grid_page = try TerminalPage.init(.{ + .cols = 1, + .rows = 1, + .hyperlink_bytes = 512, + .string_bytes = 64, + }); + defer grid_page.deinit(); + const released_hyperlink_a = try grid_page.insertHyperlink(.{ + .id = .{ .implicit = 1 }, + .uri = "one", + }); + const released_hyperlink_b = try grid_page.insertHyperlink(.{ + .id = .{ .implicit = 2 }, + .uri = "two", + }); + const encoded_hyperlink_id = try grid_page.insertHyperlink(.{ + .id = .{ .implicit = 3 }, + .uri = "three", + }); + grid_page.hyperlink_set.release( + grid_page.memory, + released_hyperlink_a, + ); + grid_page.hyperlink_set.release( + grid_page.memory, + released_hyperlink_b, + ); + const grid_cell = grid_page.getRowAndCell(0, 0); + grid_cell.cell.* = .init('A'); + try grid_page.setHyperlink( + grid_cell.row, + grid_cell.cell, + encoded_hyperlink_id, + ); + + var grid_bytes: [64]u8 = undefined; + var grid_writer: std.Io.Writer = .fixed(&grid_bytes); + try grid.encode(&grid_page, &grid_writer); + + var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer encoded.deinit(); + try header.encode(&encoded.writer); + try io.writeInt(&encoded.writer, TerminalHyperlinkId, 1); + try hyperlink.encode(duplicate, &encoded.writer); + try io.writeInt( + &encoded.writer, + TerminalHyperlinkId, + encoded_hyperlink_id, + ); + try hyperlink.encode(duplicate, &encoded.writer); + try encoded.writer.writeAll(grid_writer.buffered()); + + var reader: std.Io.Reader = .fixed(encoded.written()); + var decoded = try decodePayload( + &reader, + std.testing.allocator, + ); + defer decoded.deinit(); + try std.testing.expectEqual(@as(usize, 1), decoded.hyperlink_set.count()); + const cell = decoded.getRowAndCell(0, 0).cell; + try std.testing.expect(cell.hyperlink); + const id = decoded.lookupHyperlink(cell).?; + const entry = decoded.hyperlink_set.get(decoded.memory, id); + try std.testing.expectEqualStrings( + "uri", + entry.uri.slice(decoded.memory), + ); +} + +test "decode ignores empty hyperlink strings" { + const header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 0, + .hyperlink_count = 1, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 16, + }; + const fixtures = [_][]const u8{ + // Empty implicit URI. + "\x01\x01\x00\x00\x00\x00\x00\x00\x00", + // Empty explicit ID. + "\x02\x00\x00\x00\x00\x03\x00\x00\x00uri", + // Empty explicit URI. + "\x02\x02\x00\x00\x00id\x00\x00\x00\x00", + // Valid contents that cannot fit the zero-capacity native set. + "\x01\x01\x00\x00\x00\x03\x00\x00\x00uri", + }; + + // Only the invalid hyperlink entry itself is hand-authored above. Generate + // its cell reference through the native grid encoder. + var grid_page = try TerminalPage.init(.{ + .cols = 1, + .rows = 1, + .hyperlink_bytes = 256, + .string_bytes = 16, + }); + defer grid_page.deinit(); + const encoded_hyperlink_id = try grid_page.insertHyperlink(.{ + .id = .{ .implicit = 1 }, + .uri = "uri", + }); + const grid_cell = grid_page.getRowAndCell(0, 0); + grid_cell.cell.* = .init('A'); + try grid_page.setHyperlink( + grid_cell.row, + grid_cell.cell, + encoded_hyperlink_id, + ); + var grid_bytes: [64]u8 = undefined; + var grid_writer: std.Io.Writer = .fixed(&grid_bytes); + try grid.encode(&grid_page, &grid_writer); + + for (fixtures) |fixture| { + var encoded: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer encoded.deinit(); + try header.encode(&encoded.writer); + try io.writeInt( + &encoded.writer, + TerminalHyperlinkId, + encoded_hyperlink_id, + ); + try encoded.writer.writeAll(fixture); + + // The cell refers to the ignored table entry. Its absent native + // remapping must degrade to no hyperlink while preserving the cell. + try encoded.writer.writeAll(grid_writer.buffered()); + + var reader: std.Io.Reader = .fixed(encoded.written()); + var decoded = try decodePayload( + &reader, + std.testing.allocator, + ); + defer decoded.deinit(); + + try std.testing.expectEqual( + @as(usize, 0), + decoded.hyperlink_set.count(), + ); + const cell = decoded.getRowAndCell(0, 0).cell; + try std.testing.expectEqual(@as(u21, 'A'), cell.codepoint()); + try std.testing.expect(!cell.hyperlink); + try std.testing.expectEqual(null, decoded.lookupHyperlink(cell)); + } +} diff --git a/src/terminal/snapshot/record.zig b/src/terminal/snapshot/record.zig new file mode 100644 index 000000000..0126c2bcd --- /dev/null +++ b/src/terminal/snapshot/record.zig @@ -0,0 +1,599 @@ +//! Snapshot record framing. +//! +//! Records begin immediately after the single snapshot envelope and continue +//! back-to-back until FINISH. Each record has one fixed-size header followed +//! by exactly `payload_len` bytes. The payload length limits the record reader +//! so malformed payloads cannot consume bytes from the following record. +//! +//! The CRC covers the first six header bytes (tag and payload length) +//! followed by the payload. The CRC field itself is not included for obvious +//! reasons. All integers are unsigned and little-endian. +//! +//! | Offset | Size | Field | +//! | -----: | ------------: | :--------------------- | +//! | 0 | 2 | Tag (`u16`) | +//! | 2 | 4 | Payload length (`u32`) | +//! | 6 | 4 | CRC32C (`u32`) | +//! | 10 | `payload_len` | Payload | +//! +//! Supported tags are in `Tag`. + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const test_fixture = @import("fixture.zig"); +const io = @import("io.zig"); + +const Blake3 = std.crypto.hash.Blake3; + +/// The running digest shared by snapshot stream codecs and checkpoints. +pub const PrefixDigest = [Blake3.digest_length]u8; + +/// CRC32C as specified by the snapshot format. Zig names this standard +/// parameter set after its iSCSI use. +pub const Crc32c = std.hash.crc.Crc32Iscsi; + +/// Identifies the layout and meaning of a record payload. +/// The current snapshot version rejects every value not listed here. +pub const Tag = enum(u16) { + /// Terminal-wide live state and configuration. + terminal = 1, + + /// One live screen and its page sequence. + screen = 2, + + /// One complete logical terminal page. + page = 3, + + /// One screen's complete history manifest and page sequence. + history = 4, + + /// Digest marking the validated terminal-state prefix. + ready = 5, + + /// Digest validating the complete snapshot blob. + finish = 6, +}; + +/// The fixed framing that precedes every record payload. +pub const Header = struct { + /// Number of bytes written by `encode`, calculated using the encoder itself + /// so this remains synchronized with the field-by-field wire format. + pub const len = computeLen(); + + comptime { + // This size is part of the wire format. If it changes, the snapshot + // version and golden fixtures must also change. + assert(len == 10); + } + + /// Determines how the payload is decoded. + tag: Tag, + + /// Number of payload bytes immediately following this header. + payload_len: u32, + + /// CRC32C over the encoded tag, payload length, and payload. + crc32c: u32, + + /// Errors possible while decoding a record header. + pub const DecodeError = std.Io.Reader.Error || error{InvalidTag}; + + /// Encode the fixed record header. + pub fn encode( + self: Header, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try encodeChecksumPrefix(self.tag, self.payload_len, writer); + try io.writeInt(writer, u32, self.crc32c); + } + + /// Decode a fixed record header and reject unknown tags. + /// Payload length and CRC validation occur while decoding the payload. + pub fn decode(reader: *std.Io.Reader) DecodeError!Header { + const tag_raw = try io.readInt(reader, u16); + const tag = std.enums.fromInt(Tag, tag_raw) orelse { + return error.InvalidTag; + }; + + return .{ + .tag = tag, + .payload_len = try io.readInt(reader, u32), + .crc32c = try io.readInt(reader, u32), + }; + } + + /// Computes the required header length at comptime. + fn computeLen() usize { + comptime { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + const header: Header = .{ + .tag = .terminal, + .payload_len = 0, + .crc32c = 0, + }; + header.encode(&writer) catch unreachable; + return writer.end; + } + } +}; + +/// A writer that calculates a record checksum without retaining payload bytes. +/// The checksum is initialized with the encoded tag and payload length. +pub const Checksum = struct { + hashing: std.Io.Writer.Hashing(Crc32c), + + /// Begin a checksum with the encoded tag and payload length already added. + pub fn init(tag: Tag, payload_len: u32) Checksum { + var result: Checksum = .{ + .hashing = .initHasher(.init(), &.{}), + }; + encodeChecksumPrefix( + tag, + payload_len, + &result.hashing.writer, + ) catch unreachable; + return result; + } + + /// Return the writer through which the complete payload must be encoded. + pub fn writer(self: *Checksum) *std.Io.Writer { + return &self.hashing.writer; + } + + /// Finish and return the checksum stored in the record header. + pub fn final(self: *Checksum) u32 { + self.hashing.writer.flush() catch unreachable; + return self.hashing.hasher.final(); + } +}; + +/// Streams complete records while retaining only one payload at a time. +/// +/// All emitted bytes pass through one unbuffered BLAKE3 writer. The scratch +/// allocation is retained between records so a stream's peak memory is the +/// largest record payload rather than the complete snapshot. +pub const Writer = struct { + hashing: std.Io.Writer.Hashed(Blake3), + scratch: std.Io.Writer.Allocating, + active_tag: ?Tag, + + pub fn init( + alloc: Allocator, + destination: *std.Io.Writer, + ) Writer { + return .{ + .hashing = destination.hashed(Blake3.init(.{}), &.{}), + .scratch = .init(alloc), + .active_tag = null, + }; + } + + pub fn deinit(self: *Writer) void { + assert(self.active_tag == null); + assert(self.scratch.writer.end == 0); + self.scratch.deinit(); + self.* = undefined; + } + + /// Return the digest-updating writer for unframed snapshot bytes. + pub fn writer(self: *Writer) *std.Io.Writer { + assert(self.active_tag == null); + return &self.hashing.writer; + } + + /// Begin one record and return its reusable payload writer. + pub fn begin(self: *Writer, tag: Tag) *std.Io.Writer { + assert(self.active_tag == null); + assert(self.scratch.writer.end == 0); + self.active_tag = tag; + return &self.scratch.writer; + } + + pub const FinishError = std.Io.Writer.Error || error{ + /// The payload cannot be represented by the record's `u32` length. + PayloadTooLarge, + }; + + /// Finish and emit the active record's header followed by its payload. + /// + /// Payload validation failures occur before this function and emit no part + /// of the record. A destination failure here may have emitted a prefix. + pub fn finish(self: *Writer) FinishError!void { + assert(self.active_tag != null); + const tag = self.active_tag.?; + defer self.cancel(); + + const payload = self.scratch.written(); + const payload_len = std.math.cast( + u32, + payload.len, + ) orelse return error.PayloadTooLarge; + + // The CRC prefix contains the payload length, so checksum the retained + // payload only after its final length is known. + var checksum: Checksum = .init(tag, payload_len); + checksum.writer().writeAll(payload) catch unreachable; + + const header: Header = .{ + .tag = tag, + .payload_len = payload_len, + .crc32c = checksum.final(), + }; + var header_bytes: [Header.len]u8 = undefined; + var header_writer: std.Io.Writer = .fixed(&header_bytes); + header.encode(&header_writer) catch unreachable; + + try self.hashing.writer.writeAll(&header_bytes); + try self.hashing.writer.writeAll(payload); + } + + /// Discard the active record without emitting any bytes. + /// + /// This is idempotent so an outer error cleanup may call it after `finish` + /// has already cleared state following a destination failure. + pub fn cancel(self: *Writer) void { + self.scratch.shrinkRetainingCapacity(0); + self.active_tag = null; + } + + /// Finalize the prefix written so far without consuming the hasher. + pub fn prefixDigest(self: *const Writer) PrefixDigest { + // Checkpoints require an exact byte boundary. Writer owns this + // adapter and always constructs it without a buffer. + assert(self.active_tag == null); + assert(self.scratch.writer.end == 0); + assert(self.hashing.writer.buffer.len == 0); + assert(self.hashing.writer.buffered().len == 0); + + var result: PrefixDigest = undefined; + self.hashing.hasher.final(&result); + return result; + } +}; + +/// Hashes snapshot bytes as they are consumed without reading ahead. +pub const StreamReader = struct { + hashing: std.Io.Reader.Hashed(Blake3), + + pub fn init(input: *std.Io.Reader) StreamReader { + return .{ + .hashing = input.hashed(Blake3.init(.{}), &.{}), + }; + } + + /// Return the digest-updating reader used before and through READY. + pub fn reader(self: *StreamReader) *std.Io.Reader { + return &self.hashing.reader; + } + + /// Return the source used to consume FINISH without hashing it. + pub fn source(self: *StreamReader) *std.Io.Reader { + return self.hashing.in; + } + + /// Finalize the prefix consumed so far without consuming the hasher. + pub fn prefixDigest(self: *const StreamReader) PrefixDigest { + // A nonempty adapter buffer could contain bytes beyond a checkpoint. + // Construction is private to this type and fixes its capacity at zero. + assert(self.hashing.reader.buffer.len == 0); + assert(self.hashing.reader.bufferedLen() == 0); + + var result: PrefixDigest = undefined; + self.hashing.hasher.final(&result); + return result; + } +}; + +/// Reads one complete record. +/// +/// `init` decodes the header, `payloadReader` returns a reader limited to the +/// declared payload, and `finish` verifies exact exhaustion and the CRC32C. +pub const Reader = struct { + header: Header, + + // The limited reader normally streams directly into the hashing reader's + // buffer. One byte is enough for operations that require it to buffer, + // such as peek and discard. + limited_buffer: [1]u8, + + // PAGE decoding performs many small reads. 256 bytes batches several cells + // while CRC32C is calculated without making Reader large on the stack. + hashing_buffer: [256]u8, + + limited: std.Io.Reader.Limited, + hashing: std.Io.Reader.Hashed(Crc32c), + + pub const InitError = Header.DecodeError; + + /// Errors detected after a payload decoder returns. + pub const FinishError = error{ + /// The payload bytes do not match the CRC in the record header. + InvalidChecksum, + + /// The decoder did not consume exactly `payload_len` bytes. + PayloadNotExhausted, + }; + + /// Decode a record header and initialize its payload reader. + /// + /// `self` must remain at a stable address until `finish` returns. Decode + /// the payload only through the reader returned by `payloadReader`. + pub fn init( + self: *Reader, + source: *std.Io.Reader, + ) InitError!void { + self.* = undefined; + self.header = try Header.decode(source); + self.limited = .init( + source, + .limited(self.header.payload_len), + &self.limited_buffer, + ); + + const checksum: Checksum = .init( + self.header.tag, + self.header.payload_len, + ); + self.hashing = .init( + &self.limited.interface, + checksum.hashing.hasher, + &self.hashing_buffer, + ); + } + + /// Return the length-limited, checksum-updating payload reader. + pub fn payloadReader(self: *Reader) *std.Io.Reader { + return &self.hashing.reader; + } + + /// Require exact payload exhaustion and validate its CRC32C. + pub fn finish(self: *Reader) FinishError!void { + if (self.hashing.reader.bufferedLen() != 0 or + self.limited.remaining != .nothing) + { + return error.PayloadNotExhausted; + } + + if (self.hashing.hasher.final() != self.header.crc32c) { + return error.InvalidChecksum; + } + } +}; + +/// Encode the portion of a record header covered by CRC32C. +fn encodeChecksumPrefix( + tag: Tag, + payload_len: u32, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { + try io.writeInt(writer, u16, @intFromEnum(tag)); + try io.writeInt(writer, u32, payload_len); +} + +const test_page_header_fixture = test_fixture.parse( + @embedFile("testdata/record-page-header-v1.hex"), +); + +test "golden PAGE record header and checksum" { + const page_header = + "\x50\x00\x18\x00\x00\x00\x00\x00" ++ + "\x00\x00\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x00\x00\x00\x00\x00\x00"; + + var checksum: Checksum = .init(.page, page_header.len); + try checksum.writer().writeAll(page_header); + try std.testing.expectEqual(@as(u32, 0x7178441b), checksum.final()); + + const header: Header = .{ + .tag = .page, + .payload_len = page_header.len, + .crc32c = 0x7178441b, + }; + var buf: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try header.encode(&writer); + + try test_fixture.expectEqual( + .bytes, + "src/terminal/snapshot/testdata/record-page-header-v1.hex", + "snapshot_fixture-record-page-header-v1.hex", + &test_page_header_fixture, + writer.buffered(), + ); + + var reader: std.Io.Reader = .fixed(&test_page_header_fixture); + try std.testing.expectEqual(header, try Header.decode(&reader)); +} + +test "reject invalid tags" { + for ([_]u16{ 0, 7, std.math.maxInt(u16) }) |tag| { + var fixture = [_]u8{0} ** Header.len; + std.mem.writeInt(u16, fixture[0..2], tag, .little); + var reader: std.Io.Reader = .fixed(&fixture); + try std.testing.expectError(error.InvalidTag, Header.decode(&reader)); + } +} + +test "reject every header truncation" { + for (0..Header.len) |len| { + var reader: std.Io.Reader = .fixed( + test_page_header_fixture[0..len], + ); + try std.testing.expectError(error.EndOfStream, Header.decode(&reader)); + } +} + +test "record reader verifies exhaustion and checksum" { + const payload = "snapshot payload"; + var checksum: Checksum = .init(.page, payload.len); + try checksum.writer().writeAll(payload); + const header: Header = .{ + .tag = .page, + .payload_len = payload.len, + .crc32c = checksum.final(), + }; + + var encoded: [Header.len + payload.len + 4]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try header.encode(&writer); + try writer.writeAll(payload ++ "next"); + + var source: std.Io.Reader = .fixed(writer.buffered()); + var record_reader: Reader = undefined; + try record_reader.init(&source); + var decoded: [payload.len]u8 = undefined; + try record_reader.payloadReader().readSliceAll(&decoded); + try record_reader.finish(); + try std.testing.expectEqualStrings(payload, &decoded); + try std.testing.expectEqualStrings("next", try source.take(4)); +} + +test "record reader rejects remaining bytes and invalid checksum" { + const payload = "payload"; + const header: Header = .{ + .tag = .page, + .payload_len = payload.len, + .crc32c = 0, + }; + + var encoded: [Header.len + payload.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try header.encode(&writer); + try writer.writeAll(payload); + + { + var source: std.Io.Reader = .fixed(writer.buffered()); + var record_reader: Reader = undefined; + try record_reader.init(&source); + _ = try record_reader.payloadReader().takeByte(); + try std.testing.expectError( + error.PayloadNotExhausted, + record_reader.finish(), + ); + } + + { + var source: std.Io.Reader = .fixed(writer.buffered()); + var record_reader: Reader = undefined; + try record_reader.init(&source); + try record_reader.payloadReader().discardAll(payload.len); + try std.testing.expectError( + error.InvalidChecksum, + record_reader.finish(), + ); + } +} + +test "payload limit does not consume the next record" { + const payload = "ab"; + var checksum: Checksum = .init(.page, payload.len); + try checksum.writer().writeAll(payload); + const header: Header = .{ + .tag = .page, + .payload_len = payload.len, + .crc32c = checksum.final(), + }; + + var encoded: [Header.len + payload.len + 4]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try header.encode(&writer); + try writer.writeAll(payload ++ "next"); + + var source: std.Io.Reader = .fixed(writer.buffered()); + var record_reader: Reader = undefined; + try record_reader.init(&source); + var too_long: [3]u8 = undefined; + try std.testing.expectError( + error.EndOfStream, + record_reader.payloadReader().readSliceAll(&too_long), + ); + try record_reader.finish(); + try std.testing.expectEqualStrings("next", try source.take(4)); +} + +test "record writer buffers a payload and appends framing" { + var destination: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer destination.deinit(); + try destination.writer.writeAll("prefix"); + var stream: Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + + const payload = stream.begin(.page); + errdefer stream.cancel(); + try payload.writeAll("payload"); + try stream.finish(); + + const encoded = destination.written(); + try std.testing.expectEqualStrings("prefix", encoded[0..6]); + + var source: std.Io.Reader = .fixed(encoded[6..]); + var record_reader: Reader = undefined; + try record_reader.init(&source); + try std.testing.expectEqual(Tag.page, record_reader.header.tag); + try std.testing.expectEqual( + @as(u32, 7), + record_reader.header.payload_len, + ); + try std.testing.expectEqualStrings( + "payload", + try record_reader.payloadReader().take(7), + ); + try record_reader.finish(); +} + +test "record writer cancel preserves preceding bytes" { + var destination: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer destination.deinit(); + try destination.writer.writeAll("prefix"); + var stream: Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + + const partial = stream.begin(.page); + errdefer stream.cancel(); + try partial.writeAll("partial"); + const scratch_capacity = stream.scratch.writer.buffer.len; + stream.cancel(); + + try std.testing.expectEqualStrings("prefix", destination.written()); + try std.testing.expectEqual( + scratch_capacity, + stream.scratch.writer.buffer.len, + ); + + // Cancel retains the allocation but leaves it ready for the next record. + const next = stream.begin(.page); + try next.writeAll("next"); + try stream.finish(); + + var source: std.Io.Reader = .fixed(destination.written()[6..]); + var record_reader: Reader = undefined; + try record_reader.init(&source); + try std.testing.expectEqualStrings( + "next", + try record_reader.payloadReader().take(4), + ); + try record_reader.finish(); +} + +test "record writer clears active state after destination failure" { + // The header fits but the payload does not, leaving a permitted partial + // record in the destination while the reusable writer becomes idle again. + var destination_bytes: [Header.len]u8 = undefined; + var destination: std.Io.Writer = .fixed(&destination_bytes); + var stream: Writer = .init(std.testing.allocator, &destination); + defer stream.deinit(); + + const payload = stream.begin(.page); + try payload.writeByte(0); + try std.testing.expectError(error.WriteFailed, stream.finish()); + try std.testing.expectEqual(@as(?Tag, null), stream.active_tag); + try std.testing.expectEqual(@as(usize, 0), stream.scratch.writer.end); +} diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig new file mode 100644 index 000000000..875d2bb0e --- /dev/null +++ b/src/terminal/snapshot/screen.zig @@ -0,0 +1,2299 @@ +//! SCREEN record payload encoding. +//! +//! One SCREEN record represents the live state for one terminal screen. The +//! record is followed immediately by the number of complete PAGE records +//! declared by `page_count`. They are the minimal suffix of native pages needed +//! to cover the active area and are ordered from oldest to newest. A decoder +//! uses the declared count, rather than another record tag, to find the end of +//! the screen's page sequence. +//! +//! The final screen-height rows are the active area. When its boundary falls +//! inside the first encoded page, that page also contains an incidental history +//! prefix. `history_rows` declares the complete logical history extent so a +//! client can size its scrollbar at READY without waiting for HISTORY. A client +//! may expose or ignore the resident overlap carried by SCREEN. The extent is +//! advisory; native row totals remain derived from decoded PAGE records. +//! +//! Screen dimensions are terminal-wide state and are not repeated here. Page +//! capacities, native page IDs and pointers, history availability, selection, +//! viewport, dirty state, and derived semantic-prompt state are also omitted. +//! +//! All integers are unsigned and little-endian. +//! +//! ## Binary Format +//! +//! A SCREEN record is followed immediately by its declared PAGE records: +//! +//! ```text +//! +----------------------+ +//! | SCREEN record | +//! +----------------------+ +//! | PAGE record 0 | +//! +----------------------+ +//! | ... | +//! +----------------------+ +//! | PAGE record (n - 1) | +//! +----------------------+ +//! +//! n = page_count +//! ``` +//! +//! The HISTORY record for this screen declares and sends the older complete +//! pages after the terminal becomes ready. The incidental prefix is already +//! present in SCREEN and is not sent again. +//! +//! The SCREEN payload begins with a fixed header. When the header says there is +//! no saved cursor, the payload is: +//! +//! ```text +//! 0 +----------------------+ +//! | Header | +//! 53 +----------------------+ +//! | Cursor hyperlink | +//! | variable | +//! end +----------------------+ +//! ``` +//! +//! When a saved cursor is present, it is inserted before the cursor hyperlink: +//! +//! ```text +//! 0 +----------------------+ +//! | Header | +//! 53 +----------------------+ +//! | Saved cursor | +//! 76 +----------------------+ +//! | Cursor hyperlink | +//! | variable | +//! end +----------------------+ +//! ``` +//! +//! The cursor hyperlink begins with a one-byte kind. Zero means no hyperlink +//! and has no following bytes. Kinds one and two use the implicit and explicit +//! hyperlink encodings documented in `hyperlink.zig`. +//! +//! ### Header +//! +//! ```text +//! 0 +----------------------------------+ +//! | Screen key (u16) | +//! 2 +----------------------------------+ +//! | Page count (u16) | +//! 4 +----------------------------------+ +//! | Logical history rows (u64) | +//! 12 +----------------------------------+ +//! | Cursor x (u16) | +//! 14 +----------------------------------+ +//! | Cursor y (u16) | +//! 16 +----------------------------------+ +//! | Cursor visual style (u8) | +//! 17 +----------------------------------+ +//! | Cursor flags (u8) | +//! 18 +----------------------------------+ +//! | Concrete cursor pen (Style) | +//! 34 +----------------------------------+ +//! | Hyperlink implicit counter (u32) | +//! 38 +----------------------------------+ +//! | Current charset (CharsetState) | +//! 40 +----------------------------------+ +//! | Protected mode (u8) | +//! 41 +----------------------------------+ +//! | Kitty keyboard stack index (u8) | +//! 42 +----------------------------------+ +//! | Kitty keyboard stack flags | +//! | 8 * u8 | +//! 50 +----------------------------------+ +//! | Semantic-click kind (u8) | +//! 51 +----------------------------------+ +//! | Semantic-click value (u8) | +//! 52 +----------------------------------+ +//! | Saved-cursor-present (u8) | +//! 53 +----------------------------------+ +//! ``` +//! +//! The concrete cursor pen uses the fixed style encoding from `style.zig`. +//! +//! ### Cursor flags +//! +//! ```text +//! bit 0 +--------------------------------+ +//! | Pending wrap | +//! bit 1 +--------------------------------+ +//! | Protected | +//! bit 2 +--------------------------------+ +//! | Semantic content | +//! | 2 bits | +//! bit 4 +--------------------------------+ +//! | Semantic-content-clear-EOL | +//! bit 5 +--------------------------------+ +//! | Reserved, zero | +//! | 3 bits | +//! bit 8 +--------------------------------+ +//! ``` +//! +//! ### Charset state +//! +//! The current and saved cursor charset states share this packed layout: +//! +//! ```text +//! bit 0 +-------------------------------+ +//! | G0 charset | +//! | 2 bits | +//! bit 2 +-------------------------------+ +//! | G1 charset | +//! | 2 bits | +//! bit 4 +-------------------------------+ +//! | G2 charset | +//! | 2 bits | +//! bit 6 +-------------------------------+ +//! | G3 charset | +//! | 2 bits | +//! bit 8 +-------------------------------+ +//! | GL slot | +//! | 2 bits | +//! bit 10 +-------------------------------+ +//! | GR slot | +//! | 2 bits | +//! bit 12 +-------------------------------+ +//! | Single shift | +//! | 3 bits | +//! bit 15 +-------------------------------+ +//! | Reserved, zero | +//! bit 16 +-------------------------------+ +//! ``` +//! +//! The Kitty keyboard stack contains its current index followed by all eight +//! flag entries, including disabled entries. +//! +//! ### Saved cursor +//! +//! ```text +//! 0 +-----------------------------+ +//! | Saved cursor x (u16) | +//! 2 +-----------------------------+ +//! | Saved cursor y (u16) | +//! 4 +-----------------------------+ +//! | Saved cursor pen (Style) | +//! 20 +-----------------------------+ +//! | Saved cursor flags (u8) | +//! 21 +-----------------------------+ +//! | Saved charset state | +//! 23 +-----------------------------+ +//! ``` +//! +//! ### Saved cursor flags +//! +//! ```text +//! bit 0 +---------------------------+ +//! | Protected | +//! bit 1 +---------------------------+ +//! | Pending wrap | +//! bit 2 +---------------------------+ +//! | Origin | +//! bit 3 +---------------------------+ +//! | Reserved, zero | +//! | 5 bits | +//! bit 8 +---------------------------+ +//! ``` +//! +//! Native enum values used below are stable terminal registries. Their wire +//! widths are fixed independently by this payload. Changing those values or +//! any packed bit assignment requires a snapshot version bump. + +const std = @import("std"); +const build_options = @import("terminal_options"); +const Allocator = std.mem.Allocator; +const hyperlink = @import("hyperlink.zig"); +const test_fixture = @import("fixture.zig"); +const io = @import("io.zig"); +const page = @import("page.zig"); +const record = @import("record.zig"); +const style = @import("style.zig"); +const terminal_ansi = @import("../ansi.zig"); +const terminal_charsets = @import("../charsets.zig"); +const terminal_hyperlink = @import("../hyperlink.zig"); +const terminal_kitty = @import("../kitty.zig"); +const terminal_osc = @import("../osc.zig"); +const terminal_page = @import("../page.zig"); +const TerminalPageList = @import("../PageList.zig"); +const TerminalScreen = @import("../Screen.zig"); +const TerminalScreenKey = @import("../ScreenSet.zig").Key; +const terminal_style = @import("../style.zig"); + +const TerminalHyperlink = terminal_hyperlink.Hyperlink; +const TerminalStyle = terminal_style.Style; + +/// Errors possible while encoding fixed SCREEN payload fields. +const PayloadEncodeError = hyperlink.EncodeError || error{ + InvalidCursorFlags, + InvalidCharsetState, + InvalidKittyKeyboardIndex, + InvalidKittyKeyboardFlags, + InvalidSavedCursorFlags, +}; + +/// Errors possible while encoding a SCREEN and its complete PAGE sequence. +pub const EncodeError = PayloadEncodeError || page.EncodeError || error{ + /// The active area spans more pages than the SCREEN header can declare. + PageCountOverflow, +}; + +/// Encode one SCREEN and its minimal suffix of complete native pages. +/// +/// The suffix begins with the page containing the active area's first row and +/// ends with the newest page. Completed records may already be emitted if a +/// later record fails; the missing READY checkpoint makes that prefix invalid. +pub fn encode( + screen: *const TerminalScreen, + key: TerminalScreenKey, + destination: *record.Writer, +) EncodeError!void { + // The active top may fall inside this page, leaving an incidental history + // prefix. Every earlier complete page is history and is omitted. + const first = screen.pages.getTopLeft(.active).node; + var page_count: usize = 0; + var node: ?@TypeOf(first) = first; + while (node) |current| : (node = current.next) page_count += 1; + const encoded_page_count = std.math.cast( + u16, + page_count, + ) orelse return error.PageCountOverflow; + + // SCREEN declares exactly how many immediately following PAGE records + // belong to it. + { + const payload = destination.begin(.screen); + errdefer destination.cancel(); + try encodePayload( + screen, + key, + encoded_page_count, + payload, + ); + try destination.finish(); + } + + // PageList never compresses the active-boundary page or any later page. + // Encoding this resident suffix therefore does not restore cold history or + // otherwise mutate the source screen. + node = first; + while (node) |current| : (node = current.next) { + std.debug.assert(current.pageIfResident() != null); + try page.encode(current.pageAssumeResident(), destination); + } +} + +/// Errors possible while restoring a SCREEN and its declared PAGE sequence. +pub const DecodeError = PayloadDecodeError || + page.DecodeError || + record.Reader.InitError || + record.Reader.FinishError || + TerminalPageList.Builder.FinishError || + TerminalPageList.IncreaseCapacityError || + error{ + /// The next record is valid but is not a SCREEN. + UnexpectedRecordTag, + + /// A SCREEN must declare at least one PAGE. + InvalidPageCount, + }; + +/// One decoded SCREEN sequence and the native screen identified by its header. +pub const Decoded = struct { + key: TerminalScreenKey, + /// Expected history extent at READY, including resident SCREEN overlap. + history_rows: u64, + screen: TerminalScreen, + + /// Release the decoded native screen before ownership is transferred. + pub fn deinit(self: *Decoded) void { + self.screen.deinit(); + self.* = undefined; + } +}; + +/// Restore one SCREEN and its declared PAGE records into native terminal state. +/// +/// The caller supplies terminal-wide dimensions and policy. The record sequence +/// is decoded transactionally: on failure, all page, pin, hyperlink, and +/// temporary allocations are released and no partially restored Screen escapes. +/// The returned key selects the ScreenSet slot that owns the decoded screen. +pub fn decode( + source: *std.Io.Reader, + io_: std.Io, + alloc: Allocator, + options: TerminalScreen.Options, +) DecodeError!Decoded { + // Decode and finish the self-contained SCREEN record before consuming any + // of the PAGE records which follow it. + var record_reader: record.Reader = undefined; + try record_reader.init(source); + if (record_reader.header.tag != .screen) { + return error.UnexpectedRecordTag; + } + + // The optional saved cursor and cursor hyperlink are both part of the + // SCREEN payload. Keep ownership of the decoded hyperlink locally until it + // has been copied into the native Screen. + const payload_reader = record_reader.payloadReader(); + const header = try Header.decode(payload_reader); + const saved_cursor = if (header.saved_cursor_present) + try SavedCursor.decode(payload_reader) + else + null; + var cursor_hyperlink = try decodeCursorHyperlink( + payload_reader, + alloc, + ); + defer if (cursor_hyperlink) |*value| value.deinit(alloc); + try record_reader.finish(); + + const key = header.key; + if (header.page_count == 0) return error.InvalidPageCount; + + // Dimensions are terminal-wide state supplied by the enclosing snapshot. + // Validate them, and all cursor invariants which depend on them, before + // allocating the declared pages. + if (options.cols == 0 or options.rows == 0) { + return error.InvalidDimensions; + } + + // Build the native page list transactionally. Alternate screens never + // retain scrollback, while primary screens inherit the caller's limits. + var result: TerminalScreen = result: { + var builder = try TerminalPageList.Builder.init(alloc, .{ + .cols = options.cols, + .rows = options.rows, + .max_size = if (key == .alternate) + 0 + else + options.max_scrollback_bytes, + .max_lines = if (key == .alternate) + 0 + else + options.max_scrollback_lines, + }); + defer builder.deinit(); + + // Allocate each PAGE at its declared capacity and decode directly into + // builder-owned storage, avoiding an intermediate page representation. + for (0..header.page_count) |_| { + var decoder: page.Decoder = undefined; + try decoder.init(source); + const native_page = try builder.allocatePage(decoder.capacity()); + try decoder.decode(native_page, alloc); + } + + // Finishing validates that the decoded suffix covers the active area + // and establishes the PageList's active viewport. + var pages = try builder.finish(); + errdefer pages.deinit(); + + // Convert the payload-relative cursor position into the tracked native + // pin and page-local row/cell pointers required by TerminalScreen. + const cursor: TerminalScreen.Cursor = cursor: { + const y = @min(header.cursor_y, options.rows - 1); + + // The active area can temporarily contain mixed-width pages after + // a reflow. Locate the row at column zero, then clamp the encoded + // x coordinate to the width of that physical page. + const row_pin = pages.pin(.{ .active = .{ .y = y } }) orelse unreachable; + const x = @min(header.cursor_x, row_pin.node.cols() - 1); + const pin = try pages.trackPin(.{ + .node = row_pin.node, + .x = x, + .y = row_pin.y, + }); + const rac = pin.rowAndCell(); + + break :cursor .{ + .x = x, + .y = y, + .cursor_style = header.cursor_style, + .pending_wrap = header.cursor_flags.pending_wrap and + x == options.cols - 1, + .protected = header.cursor_flags.protected, + .style = header.cursor_pen, + .hyperlink_implicit_id = header.hyperlink_implicit_id, + .semantic_content = header.cursor_flags.semantic_content, + .semantic_content_clear_eol = header.cursor_flags + .semantic_content_clear_eol, + .page_pin = pin, + .page_row = rac.row, + .page_cell = rac.cell, + }; + }; + + // Assemble the remaining native state from stable snapshot registries. + // Derived state and page-local table references are repaired below. + break :result .{ + .io = io_, + .alloc = alloc, + .pages = pages, + .no_scrollback = key == .alternate or + options.max_scrollback_bytes == 0, + .cursor = cursor, + .saved_cursor = if (saved_cursor) |value| + value.terminal() + else + null, + .charset = header.charset, + .protected_mode = header.protected_mode, + .kitty_keyboard = header.kitty_keyboard.terminal(), + .semantic_prompt = .{ + .seen = false, + .click = header.semantic_click, + }, + }; + }; + errdefer result.deinit(); + + // Reinsert the concrete cursor style into its page-local style table. This + // existing Screen path grows or splits the page when the decoded table is + // already full. + try result.manualStyleUpdate(); + + // Reinsert the cursor hyperlink into its page-local hyperlink table. For an + // implicit link, temporarily seed the counter with the encoded link ID, + // then restore the independent next-ID counter from the header. + if (cursor_hyperlink) |value| { + switch (value.id) { + .explicit => |id| try result.startHyperlink(value.uri, id), + .implicit => |id| { + result.cursor.hyperlink_implicit_id = id; + try result.startHyperlink(value.uri, null); + result.cursor.hyperlink_implicit_id = + header.hyperlink_implicit_id; + }, + } + } + + // `seen` is derived state. Only resident prompt metadata participates + // because omitted history is outside the restored Screen model. + result.semantic_prompt.seen = seen: { + if (result.cursor.semantic_content == .prompt) break :seen true; + + var node = result.pages.getTopLeft(.screen).node; + while (true) { + const native_page = node.pageAssumeResident(); + const rows = native_page.rows.ptr( + native_page.memory, + )[0..native_page.size.rows]; + for (rows) |row| { + if (row.semantic_prompt != .none) break :seen true; + + const cells = row.cells.ptr( + native_page.memory, + )[0..native_page.size.cols]; + for (cells) |cell| { + if (cell.semantic_content == .prompt) break :seen true; + } + } + + node = node.next orelse break :seen false; + } + + unreachable; + }; + + // Kitty image payloads are outside this record, but the restored Screen + // must still receive the caller's terminal-wide storage policy. + if (comptime build_options.kitty_graphics) { + result.kitty_images.setLimit( + io_, + alloc, + &result, + options.kitty_image_storage_limit, + ) catch unreachable; + result.kitty_images.image_limits = options.kitty_image_loading_limits; + } + + // All fallible reconstruction is complete; verify the native invariants + // before transferring ownership to the caller. + result.pages.assertIntegrity(); + result.assertIntegrity(); + return .{ + .key = key, + .history_rows = header.history_rows, + .screen = result, + }; +} + +/// Errors possible while decoding fixed SCREEN payload fields. +const PayloadDecodeError = std.Io.Reader.Error || error{InvalidKey}; + +/// Flags encoded after the cursor's visual shape. +pub const CursorFlags = packed struct(u8) { + pending_wrap: bool = false, + protected: bool = false, + semantic_content: terminal_page.Cell.SemanticContent = .output, + semantic_content_clear_eol: bool = false, + _padding: u3 = 0, + + fn init(cursor: *const TerminalScreen.Cursor) CursorFlags { + return .{ + .pending_wrap = cursor.pending_wrap, + .protected = cursor.protected, + .semantic_content = cursor.semantic_content, + .semantic_content_clear_eol = cursor + .semantic_content_clear_eol, + }; + } + + /// Decode the cursor flag registry, normalizing unknown semantic state. + pub fn decode(reader: *std.Io.Reader) std.Io.Reader.Error!CursorFlags { + const raw = try reader.takeByte(); + const bits: CursorFlags = @bitCast(raw); + const semantic_content_raw: u2 = @truncate(raw >> @bitOffsetOf(CursorFlags, "semantic_content")); + const semantic_content = std.enums.fromInt( + terminal_page.Cell.SemanticContent, + semantic_content_raw, + ) orelse .output; + + return .{ + .pending_wrap = bits.pending_wrap, + .protected = bits.protected, + .semantic_content = semantic_content, + .semantic_content_clear_eol = bits.semantic_content_clear_eol, + }; + } +}; + +/// The packed wire bits shared by current and saved cursor charset state. +/// +/// Native enum values are stable, but their backing type is `c_int` in C ABI +/// builds. The wire therefore stores only checked integer values at its fixed +/// bit widths. +const CharsetBits = packed struct(u16) { + g0: u2 = 0, + g1: u2 = 0, + g2: u2 = 0, + g3: u2 = 0, + gl: u2 = 0, + gr: u2 = 2, + single_shift: u3 = 0, + _padding: u1 = 0, +}; + +fn enumFromInt(comptime T: type, raw: anytype) ?T { + const Tag = @typeInfo(T).@"enum".tag_type; + const value = std.math.cast(Tag, raw) orelse return null; + return std.enums.fromInt(T, value); +} + +fn encodeCharsetState( + value: TerminalScreen.CharsetState, +) error{InvalidCharsetState}!u16 { + const g0 = std.math.cast( + u2, + @intFromEnum(value.charsets.get(.G0)), + ) orelse return error.InvalidCharsetState; + const g1 = std.math.cast( + u2, + @intFromEnum(value.charsets.get(.G1)), + ) orelse return error.InvalidCharsetState; + const g2 = std.math.cast( + u2, + @intFromEnum(value.charsets.get(.G2)), + ) orelse return error.InvalidCharsetState; + const g3 = std.math.cast( + u2, + @intFromEnum(value.charsets.get(.G3)), + ) orelse return error.InvalidCharsetState; + const gl = std.math.cast( + u2, + @intFromEnum(value.gl), + ) orelse return error.InvalidCharsetState; + const gr = std.math.cast( + u2, + @intFromEnum(value.gr), + ) orelse return error.InvalidCharsetState; + const single_shift: u3 = if (value.single_shift) |slot| single_shift: { + const raw = std.math.cast( + u2, + @intFromEnum(slot), + ) orelse return error.InvalidCharsetState; + break :single_shift @as(u3, raw) + 1; + } else 0; + + const bits: CharsetBits = .{ + .g0 = g0, + .g1 = g1, + .g2 = g2, + .g3 = g3, + .gl = gl, + .gr = gr, + .single_shift = single_shift, + }; + return @bitCast(bits); +} + +fn decodeCharsetState( + raw: u16, +) TerminalScreen.CharsetState { + const bits: CharsetBits = @bitCast(raw); + + var result: TerminalScreen.CharsetState = .{}; + result.gl = enumFromInt( + terminal_charsets.Slots, + bits.gl, + ) orelse result.gl; + result.gr = enumFromInt( + terminal_charsets.Slots, + bits.gr, + ) orelse result.gr; + result.single_shift = if (bits.single_shift == 0) + null + else if (bits.single_shift <= 4) + enumFromInt( + terminal_charsets.Slots, + bits.single_shift - 1, + ) + else + null; + result.charsets.set( + .G0, + enumFromInt( + terminal_charsets.Charset, + bits.g0, + ) orelse .utf8, + ); + result.charsets.set( + .G1, + enumFromInt( + terminal_charsets.Charset, + bits.g1, + ) orelse .utf8, + ); + result.charsets.set( + .G2, + enumFromInt( + terminal_charsets.Charset, + bits.g2, + ) orelse .utf8, + ); + result.charsets.set( + .G3, + enumFromInt( + terminal_charsets.Charset, + bits.g3, + ) orelse .utf8, + ); + return result; +} + +/// The complete fixed-size Kitty keyboard stack. +pub const KittyKeyboard = struct { + index: u8 = 0, + flags: [8]Flags = @splat(.{}), + + /// One byte in the fixed Kitty keyboard flag stack. + pub const Flags = packed struct(u8) { + disambiguate: bool = false, + report_events: bool = false, + report_alternates: bool = false, + report_all: bool = false, + report_associated: bool = false, + _padding: u3 = 0, + + /// Decode one Kitty keyboard flag byte, ignoring reserved bits. + pub fn decode(reader: *std.Io.Reader) std.Io.Reader.Error!Flags { + var result: Flags = @bitCast(try reader.takeByte()); + result._padding = 0; + return result; + } + }; + + fn init(value: terminal_kitty.KeyFlagStack) KittyKeyboard { + var result: KittyKeyboard = .{ .index = value.idx }; + for (value.flags, &result.flags) |native, *snapshot| { + snapshot.* = .{ + .disambiguate = native.disambiguate, + .report_events = native.report_events, + .report_alternates = native.report_alternates, + .report_all = native.report_all, + .report_associated = native.report_associated, + }; + } + return result; + } + + fn terminal(self: KittyKeyboard) terminal_kitty.KeyFlagStack { + var result: terminal_kitty.KeyFlagStack = .{ + .idx = @intCast(self.index), + }; + for (self.flags, &result.flags) |snapshot, *native| { + native.* = .{ + .disambiguate = snapshot.disambiguate, + .report_events = snapshot.report_events, + .report_alternates = snapshot.report_alternates, + .report_all = snapshot.report_all, + .report_associated = snapshot.report_associated, + }; + } + return result; + } + + /// Decode the complete fixed Kitty keyboard stack. + pub fn decode(reader: *std.Io.Reader) std.Io.Reader.Error!KittyKeyboard { + const index_raw = try reader.takeByte(); + const index = if (index_raw < 8) index_raw else 0; + + var flags: [8]Flags = undefined; + for (&flags) |*entry| entry.* = try Flags.decode(reader); + return .{ .index = index, .flags = flags }; + } +}; + +/// Decode semantic-click state, falling back to disabled for unknown values. +fn decodeSemanticClick( + reader: *std.Io.Reader, +) std.Io.Reader.Error!TerminalScreen.SemanticPrompt.SemanticClick { + const kind = enumFromInt( + TerminalScreen.SemanticPrompt.SemanticClickKind, + try reader.takeByte(), + ); + const value = try reader.takeByte(); + + return switch (kind orelse return .none) { + .none => .none, + .click_events => .{ .click_events = enumFromInt( + terminal_osc.semantic_prompt.ClickEvents, + value, + ) orelse return .none }, + .cl => .{ .cl = enumFromInt( + terminal_osc.semantic_prompt.Click, + value, + ) orelse return .none }, + }; +} + +/// The optional fixed-size saved cursor state. +pub const SavedCursor = struct { + /// Number of bytes written by `encode`, calculated using the encoder. + pub const len = computeLen(); + + comptime { + std.debug.assert(len == 23); + } + + x: u16, + y: u16, + pen: TerminalStyle, + flags: Flags, + charset: TerminalScreen.CharsetState, + + /// Flags encoded with an optional saved cursor. + pub const Flags = packed struct(u8) { + protected: bool = false, + pending_wrap: bool = false, + origin: bool = false, + _padding: u5 = 0, + + /// Decode saved cursor flags, ignoring reserved bits. + pub fn decode(reader: *std.Io.Reader) std.Io.Reader.Error!Flags { + var result: Flags = @bitCast(try reader.takeByte()); + result._padding = 0; + return result; + } + }; + + fn init(value: TerminalScreen.SavedCursor) SavedCursor { + return .{ + .x = value.x, + .y = value.y, + .pen = value.style, + .flags = .{ + .protected = value.protected, + .pending_wrap = value.pending_wrap, + .origin = value.origin, + }, + .charset = value.charset, + }; + } + + fn terminal(self: SavedCursor) TerminalScreen.SavedCursor { + return .{ + .x = self.x, + .y = self.y, + .style = self.pen, + .protected = self.flags.protected, + .pending_wrap = self.flags.pending_wrap, + .origin = self.flags.origin, + .charset = self.charset, + }; + } + + /// Encode one optional saved cursor value. + pub fn encode( + self: SavedCursor, + writer: *std.Io.Writer, + ) PayloadEncodeError!void { + if (self.flags._padding != 0) { + return error.InvalidSavedCursorFlags; + } + const charset = try encodeCharsetState(self.charset); + + try io.writeInt(writer, u16, self.x); + try io.writeInt(writer, u16, self.y); + try style.encode(self.pen, writer); + try writer.writeByte(@bitCast(self.flags)); + try io.writeInt(writer, u16, charset); + } + + /// Decode and validate one optional saved cursor value. + pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!SavedCursor { + return .{ + .x = try io.readInt(reader, u16), + .y = try io.readInt(reader, u16), + .pen = style.decodeOrDiscard(reader) catch .{}, + .flags = try Flags.decode(reader), + .charset = decodeCharsetState( + try io.readInt(reader, u16), + ), + }; + } + + fn computeLen() usize { + comptime { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + const value: SavedCursor = .{ + .x = 0, + .y = 0, + .pen = .{}, + .flags = .{}, + .charset = .{}, + }; + value.encode(&writer) catch unreachable; + return writer.end; + } + } +}; + +/// The fixed fields at the start of every SCREEN payload. +pub const Header = struct { + /// Number of bytes written by `encode`, calculated using the encoder. + pub const len = computeLen(); + + comptime { + std.debug.assert(len == 53); + } + + key: TerminalScreenKey, + /// Complete pages in the minimal suffix covering the active area. + page_count: u16, + /// Advisory logical rows before the active area, including resident + /// SCREEN overlap. + history_rows: u64, + cursor_x: u16, + cursor_y: u16, + cursor_style: TerminalScreen.CursorStyle, + cursor_flags: CursorFlags, + cursor_pen: TerminalStyle, + hyperlink_implicit_id: u32, + charset: TerminalScreen.CharsetState, + protected_mode: terminal_ansi.ProtectedMode, + kitty_keyboard: KittyKeyboard, + semantic_click: TerminalScreen.SemanticPrompt.SemanticClick, + saved_cursor_present: bool, + + /// Initialize the fixed header from one native screen. + fn init( + screen: *const TerminalScreen, + key: TerminalScreenKey, + page_count: u16, + ) Header { + return .{ + .key = key, + .page_count = page_count, + .history_rows = @intCast( + screen.pages.total_rows - screen.pages.rows, + ), + .cursor_x = screen.cursor.x, + .cursor_y = screen.cursor.y, + .cursor_style = screen.cursor.cursor_style, + .cursor_flags = .init(&screen.cursor), + .cursor_pen = screen.cursor.style, + .hyperlink_implicit_id = screen.cursor.hyperlink_implicit_id, + .charset = screen.charset, + .protected_mode = screen.protected_mode, + .kitty_keyboard = .init(screen.kitty_keyboard), + .semantic_click = screen.semantic_prompt.click, + .saved_cursor_present = screen.saved_cursor != null, + }; + } + + /// Encode the fixed SCREEN payload header field by field. + pub fn encode( + self: Header, + writer: *std.Io.Writer, + ) PayloadEncodeError!void { + // Validate packed cursor state before writing any bytes. + if (self.cursor_flags._padding != 0) { + return error.InvalidCursorFlags; + } + + // Validate and narrow native charset enum values before writing. + const charset = try encodeCharsetState(self.charset); + + // Validate the complete Kitty keyboard stack. + if (self.kitty_keyboard.index >= self.kitty_keyboard.flags.len) { + return error.InvalidKittyKeyboardIndex; + } + for (self.kitty_keyboard.flags) |flags| { + if (flags._padding != 0) { + return error.InvalidKittyKeyboardFlags; + } + } + + // Screen identity, expected history extent, and cursor position. + try io.writeInt(writer, u16, @intCast(@intFromEnum(self.key))); + try io.writeInt(writer, u16, self.page_count); + try io.writeInt(writer, u64, self.history_rows); + try io.writeInt(writer, u16, self.cursor_x); + try io.writeInt(writer, u16, self.cursor_y); + + // Current cursor rendering state. + try writer.writeByte(@intCast(@intFromEnum(self.cursor_style))); + try writer.writeByte(@bitCast(self.cursor_flags)); + try style.encode(self.cursor_pen, writer); + try io.writeInt(writer, u32, self.hyperlink_implicit_id); + + // Charset and selective-erase state. + try io.writeInt(writer, u16, charset); + try writer.writeByte(@intCast(@intFromEnum(self.protected_mode))); + + // Keyboard modes and semantic-click state. + try writer.writeByte(self.kitty_keyboard.index); + for (self.kitty_keyboard.flags) |flags| { + try writer.writeByte(@bitCast(flags)); + } + try writer.writeByte(@intCast(@intFromEnum( + std.meta.activeTag(self.semantic_click), + ))); + switch (self.semantic_click) { + .none => try writer.writeByte(0), + .click_events => |value| try writer.writeByte( + @intCast(@intFromEnum(value)), + ), + .cl => |value| try writer.writeByte( + @intCast(@intFromEnum(value)), + ), + } + + // Presence of the optional saved-cursor suffix. + try writer.writeByte(@intFromBool(self.saved_cursor_present)); + } + + /// Decode the fixed SCREEN payload header. + /// + /// The screen key remains structural because it routes the following PAGE + /// sequence. Unknown semantic fields use native defaults instead. + pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!Header { + // Screen identity, expected history extent, and cursor position. + const key = enumFromInt( + TerminalScreenKey, + try io.readInt(reader, u16), + ) orelse return error.InvalidKey; + const page_count = try io.readInt(reader, u16); + const history_rows = try io.readInt(reader, u64); + const cursor_x = try io.readInt(reader, u16); + const cursor_y = try io.readInt(reader, u16); + + // Current cursor rendering state. + const cursor_style = enumFromInt( + TerminalScreen.CursorStyle, + try reader.takeByte(), + ) orelse .block; + const cursor_flags = try CursorFlags.decode(reader); + const cursor_pen: TerminalStyle = style.decodeOrDiscard(reader) catch .{}; + const hyperlink_implicit_id = try io.readInt(reader, u32); + + // Charset and selective-erase state. + const charset = decodeCharsetState( + try io.readInt(reader, u16), + ); + const protected_mode = enumFromInt( + terminal_ansi.ProtectedMode, + try reader.takeByte(), + ) orelse .off; + + // Keyboard modes and semantic-click state. + const kitty_keyboard = try KittyKeyboard.decode(reader); + const semantic_click = try decodeSemanticClick(reader); + + // Presence of the optional saved-cursor suffix. + const saved_cursor_present = try reader.takeByte() != 0; + + return .{ + .key = key, + .page_count = page_count, + .history_rows = history_rows, + .cursor_x = cursor_x, + .cursor_y = cursor_y, + + .cursor_style = cursor_style, + .cursor_flags = cursor_flags, + .cursor_pen = cursor_pen, + .hyperlink_implicit_id = hyperlink_implicit_id, + + .charset = charset, + .protected_mode = protected_mode, + + .kitty_keyboard = kitty_keyboard, + .semantic_click = semantic_click, + + .saved_cursor_present = saved_cursor_present, + }; + } + + fn computeLen() usize { + comptime { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + const value: Header = .{ + .key = .primary, + .page_count = 0, + .history_rows = 0, + .cursor_x = 0, + .cursor_y = 0, + .cursor_style = .bar, + .cursor_flags = .{}, + .cursor_pen = .{}, + .hyperlink_implicit_id = 0, + .charset = .{}, + .protected_mode = .off, + .kitty_keyboard = .{}, + .semantic_click = .none, + .saved_cursor_present = false, + }; + value.encode(&writer) catch unreachable; + return writer.end; + } + } +}; + +/// Encode the SCREEN payload, excluding its following PAGE records. +fn encodePayload( + screen: *const TerminalScreen, + key: TerminalScreenKey, + page_count: u16, + writer: *std.Io.Writer, +) PayloadEncodeError!void { + try Header.init(screen, key, page_count).encode(writer); + + if (screen.saved_cursor) |saved_cursor| { + try SavedCursor.init(saved_cursor).encode(writer); + } + + const cursor_hyperlink: ?TerminalHyperlink = + if (screen.cursor.hyperlink) |entry| entry.* else null; + try encodeCursorHyperlink(cursor_hyperlink, writer); +} + +/// The variable cursor hyperlink at the end of every SCREEN payload. +pub const CursorHyperlink = ?TerminalHyperlink; + +/// Encode the optional cursor hyperlink at the end of a SCREEN payload. +pub fn encodeCursorHyperlink( + value: CursorHyperlink, + writer: *std.Io.Writer, +) hyperlink.EncodeError!void { + if (value) |entry| return hyperlink.encode(entry, writer); + try writer.writeByte(0); +} + +/// Decode one allocator-owned optional cursor hyperlink. +/// +/// The caller owns a non-null returned value and must call `Hyperlink.deinit`. +pub fn decodeCursorHyperlink( + reader: *std.Io.Reader, + alloc: Allocator, +) std.Io.Reader.Error!CursorHyperlink { + if (try reader.peekByte() == 0) { + _ = try reader.takeByte(); + return null; + } + + return hyperlink.decode(reader, alloc) catch |err| switch (err) { + error.ReadFailed => error.ReadFailed, + + // The cursor hyperlink is the final SCREEN payload field, so its + // record boundary lets us discard an invalid or unrepresentable value + // without losing the following PAGE sequence. + error.EndOfStream, + error.OutOfMemory, + error.InvalidKind, + error.InvalidUri, + error.InvalidExplicitId, + => { + _ = try reader.discardRemaining(); + return null; + }, + }; +} + +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 = .{ + .gl = .G1, + .gr = .G3, + .single_shift = .G2, + }; + result.charsets.set(.G0, .utf8); + result.charsets.set(.G1, .ascii); + result.charsets.set(.G2, .british); + result.charsets.set(.G3, .dec_special); + return result; +} + +fn testHeader() Header { + return .{ + .key = .alternate, + .page_count = 0x0203, + .history_rows = 0x0102030405060708, + .cursor_x = 0x0405, + .cursor_y = 0x0607, + .cursor_style = .block_hollow, + .cursor_flags = .{ + .pending_wrap = true, + .semantic_content = .prompt, + .semantic_content_clear_eol = true, + }, + .cursor_pen = .{ + .fg_color = .none, + .bg_color = .{ .palette = 0x7f }, + .underline_color = .{ .rgb = .{ + .r = 0x12, + .g = 0x34, + .b = 0x56, + } }, + .flags = .{ + .bold = true, + .italic = true, + .faint = true, + .blink = true, + .inverse = true, + .invisible = true, + .strikethrough = true, + .overline = true, + .underline = .curly, + }, + }, + .hyperlink_implicit_id = 0x0a0b0c0d, + .charset = testCharsetState(), + .protected_mode = .dec, + .kitty_keyboard = .{ + .index = 7, + .flags = .{ + .{ .disambiguate = true }, + .{ .report_events = true }, + .{ .report_alternates = true }, + .{ .report_all = true }, + .{ .report_associated = true }, + .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }, + .{}, + .{ + .disambiguate = true, + .report_associated = true, + }, + }, + }, + .semantic_click = .{ .cl = .smart_vertical }, + .saved_cursor_present = true, + }; +} + +fn testSavedCursor() SavedCursor { + return .{ + .x = 0x0102, + .y = 0x0304, + .pen = .{}, + .flags = .{ + .protected = true, + .pending_wrap = true, + .origin = true, + }, + .charset = testCharsetState(), + }; +} + +test "SCREEN header golden encoding and decoding" { + var encoded: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try testHeader().encode(&writer); + + try test_fixture.expectEqual( + .bytes, + "src/terminal/snapshot/testdata/screen-header-v1.hex", + "snapshot_fixture-screen-header-v1.hex", + &test_header_fixture, + writer.buffered(), + ); + try std.testing.expectEqual(Header.len, test_header_fixture.len); + + var source: std.Io.Reader = .fixed(&test_header_fixture); + var buffer: [1]u8 = undefined; + var limited = source.limited(.unlimited, &buffer); + + try std.testing.expectEqualDeep( + testHeader(), + try Header.decode(&limited.interface), + ); +} + +test "native enum values used by the SCREEN format" { + const keys = [_]TerminalScreenKey{ .primary, .alternate }; + for (keys, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); + } + + const cursor_styles = [_]TerminalScreen.CursorStyle{ + .bar, + .block, + .underline, + .block_hollow, + }; + for (cursor_styles, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); + } + + const semantic_contents = [_]terminal_page.Cell.SemanticContent{ + .output, + .input, + .prompt, + }; + for (semantic_contents, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); + } + + const charsets = [_]terminal_charsets.Charset{ + .utf8, + .ascii, + .british, + .dec_special, + }; + for (charsets, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); + } + + const slots = [_]terminal_charsets.Slots{ .G0, .G1, .G2, .G3 }; + for (slots, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); + } + + const protected_modes = [_]terminal_ansi.ProtectedMode{ + .off, + .iso, + .dec, + }; + for (protected_modes, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); + } + + const click_kinds = [_]TerminalScreen.SemanticPrompt.SemanticClickKind{ + .none, + .click_events, + .cl, + }; + for (click_kinds, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); + } + + const click_events = [_]terminal_osc.semantic_prompt.ClickEvents{ + .absolute, + .relative, + }; + for (click_events, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); + } + + const clicks = [_]terminal_osc.semantic_prompt.Click{ + .line, + .multiple, + .conservative_vertical, + .smart_vertical, + }; + for (clicks, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); + } +} + +test "cursor, Kitty, and saved cursor flag bit layouts" { + const CursorCase = struct { + value: CursorFlags, + expected: u8, + }; + const cursor_cases = [_]CursorCase{ + .{ .value = .{ .pending_wrap = true }, .expected = 1 << 0 }, + .{ .value = .{ .protected = true }, .expected = 1 << 1 }, + .{ + .value = .{ .semantic_content = .input }, + .expected = 1 << 2, + }, + .{ + .value = .{ .semantic_content = .prompt }, + .expected = 2 << 2, + }, + .{ + .value = .{ .semantic_content_clear_eol = true }, + .expected = 1 << 4, + }, + }; + for (cursor_cases) |case| { + try std.testing.expectEqual( + case.expected, + @as(u8, @bitCast(case.value)), + ); + } + + const KittyCase = struct { + value: KittyKeyboard.Flags, + expected: u8, + }; + const kitty_cases = [_]KittyCase{ + .{ .value = .{ .disambiguate = true }, .expected = 1 << 0 }, + .{ .value = .{ .report_events = true }, .expected = 1 << 1 }, + .{ + .value = .{ .report_alternates = true }, + .expected = 1 << 2, + }, + .{ .value = .{ .report_all = true }, .expected = 1 << 3 }, + .{ + .value = .{ .report_associated = true }, + .expected = 1 << 4, + }, + }; + for (kitty_cases) |case| { + try std.testing.expectEqual( + case.expected, + @as(u8, @bitCast(case.value)), + ); + } + + const SavedCase = struct { + value: SavedCursor.Flags, + expected: u8, + }; + const saved_cases = [_]SavedCase{ + .{ .value = .{ .protected = true }, .expected = 1 << 0 }, + .{ .value = .{ .pending_wrap = true }, .expected = 1 << 1 }, + .{ .value = .{ .origin = true }, .expected = 1 << 2 }, + }; + for (saved_cases) |case| { + try std.testing.expectEqual( + case.expected, + @as(u8, @bitCast(case.value)), + ); + } +} + +test "header encoding rejects invalid state" { + var encoded: [Header.len]u8 = undefined; + + var invalid_cursor_flags = testHeader(); + invalid_cursor_flags.cursor_flags._padding = 1; + var cursor_flags_writer: std.Io.Writer = .fixed(&encoded); + try std.testing.expectError( + error.InvalidCursorFlags, + invalid_cursor_flags.encode(&cursor_flags_writer), + ); + + var invalid_kitty_index = testHeader(); + invalid_kitty_index.kitty_keyboard.index = 8; + var kitty_index_writer: std.Io.Writer = .fixed(&encoded); + try std.testing.expectError( + error.InvalidKittyKeyboardIndex, + invalid_kitty_index.encode(&kitty_index_writer), + ); + + var invalid_kitty_flags = testHeader(); + invalid_kitty_flags.kitty_keyboard.flags[3]._padding = 1; + var kitty_flags_writer: std.Io.Writer = .fixed(&encoded); + try std.testing.expectError( + error.InvalidKittyKeyboardFlags, + invalid_kitty_flags.encode(&kitty_flags_writer), + ); +} + +test "header decoding rejects structural values" { + const valid = [_]u8{0} ** Header.len; + + var invalid_key = valid; + invalid_key[0] = 2; + var key_reader: std.Io.Reader = .fixed(&invalid_key); + try std.testing.expectError(error.InvalidKey, Header.decode(&key_reader)); +} + +test "header decoding normalizes unknown semantic values" { + var fixture = [_]u8{0} ** Header.len; + + fixture[16] = 4; // Unknown cursor style. + fixture[17] = 0xFF; // Known flags, unknown semantic value, reserved bits. + fixture[18] = 3; // Unknown cursor foreground color kind. + fixture[39] = 0xD0; // Invalid single shift plus a reserved bit. + fixture[40] = 3; // Unknown protected mode. + fixture[41] = 8; // Out-of-range Kitty keyboard index. + @memset(fixture[42..50], 0xFF); // Known flags plus reserved bits. + fixture[50] = 3; // Unknown semantic-click kind. + fixture[51] = 0xFF; + fixture[52] = 2; // Noncanonical true. + + var reader: std.Io.Reader = .fixed(&fixture); + const decoded = try Header.decode(&reader); + + try std.testing.expectEqual( + TerminalScreen.CursorStyle.block, + decoded.cursor_style, + ); + try std.testing.expect(decoded.cursor_pen.default()); + try std.testing.expect(decoded.cursor_flags.pending_wrap); + try std.testing.expect(decoded.cursor_flags.protected); + try std.testing.expectEqual( + terminal_page.Cell.SemanticContent.output, + decoded.cursor_flags.semantic_content, + ); + try std.testing.expect( + decoded.cursor_flags.semantic_content_clear_eol, + ); + try std.testing.expectEqual(@as(u3, 0), decoded.cursor_flags._padding); + + try std.testing.expectEqual(null, decoded.charset.single_shift); + try std.testing.expectEqual(terminal_charsets.Slots.G0, decoded.charset.gr); + try std.testing.expectEqual( + terminal_ansi.ProtectedMode.off, + decoded.protected_mode, + ); + try std.testing.expectEqual(@as(u8, 0), decoded.kitty_keyboard.index); + for (decoded.kitty_keyboard.flags) |flags| { + try std.testing.expect(flags.disambiguate); + try std.testing.expect(flags.report_events); + try std.testing.expect(flags.report_alternates); + try std.testing.expect(flags.report_all); + try std.testing.expect(flags.report_associated); + try std.testing.expectEqual(@as(u3, 0), flags._padding); + } + try std.testing.expectEqual( + TerminalScreen.SemanticPrompt.SemanticClick.none, + decoded.semantic_click, + ); + try std.testing.expect(decoded.saved_cursor_present); + + // Known semantic-click kinds with unknown or noncanonical values also + // degrade to disabled while consuming both fixed bytes. + const invalid_semantic_clicks = [_][2]u8{ + .{ 0, 1 }, + .{ 1, 2 }, + .{ 2, 4 }, + }; + for (invalid_semantic_clicks) |invalid| { + var semantic_fixture = [_]u8{0} ** Header.len; + semantic_fixture[50] = invalid[0]; + semantic_fixture[51] = invalid[1]; + var semantic_reader: std.Io.Reader = .fixed(&semantic_fixture); + const semantic_decoded = try Header.decode(&semantic_reader); + try std.testing.expectEqual( + TerminalScreen.SemanticPrompt.SemanticClick.none, + semantic_decoded.semantic_click, + ); + } +} + +test "header decoding rejects every truncation" { + for (0..Header.len) |fixture_len| { + var reader: std.Io.Reader = .fixed( + test_header_fixture[0..fixture_len], + ); + try std.testing.expectError(error.EndOfStream, Header.decode(&reader)); + } +} + +test "native SCREEN payload omits absent optional state" { + var screen = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, + ); + defer screen.deinit(); + + var encoded: [Header.len + 1]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try encodePayload(&screen, .primary, 1, &writer); + try std.testing.expectEqual(encoded.len, writer.end); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + const header = try Header.decode(&reader); + try std.testing.expectEqual(TerminalScreenKey.primary, header.key); + try std.testing.expectEqual(@as(u16, 1), header.page_count); + try std.testing.expectEqual(@as(u64, 0), header.history_rows); + try std.testing.expect(!header.saved_cursor_present); + try std.testing.expectEqual( + null, + try decodeCursorHyperlink(&reader, std.testing.allocator), + ); +} + +test "saved cursor golden encoding and decoding" { + var encoded: [SavedCursor.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try testSavedCursor().encode(&writer); + try test_fixture.expectEqual( + .bytes, + "src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex", + "snapshot_fixture-screen-saved-cursor-v1.hex", + &test_saved_cursor_fixture, + writer.buffered(), + ); + try std.testing.expectEqual( + SavedCursor.len, + test_saved_cursor_fixture.len, + ); + + var source: std.Io.Reader = .fixed(&test_saved_cursor_fixture); + var buffer: [1]u8 = undefined; + var limited = source.limited(.unlimited, &buffer); + try std.testing.expectEqualDeep( + testSavedCursor(), + try SavedCursor.decode(&limited.interface), + ); +} + +test "saved cursor encoding rejects and decoding normalizes invalid state" { + var encoded: [SavedCursor.len]u8 = undefined; + + var invalid_flags = testSavedCursor(); + invalid_flags.flags._padding = 1; + var flags_writer: std.Io.Writer = .fixed(&encoded); + try std.testing.expectError( + error.InvalidSavedCursorFlags, + invalid_flags.encode(&flags_writer), + ); + + var invalid = [_]u8{0} ** SavedCursor.len; + invalid[4] = 3; // Unknown saved-cursor foreground color kind. + invalid[20] = 0xF9; // Protected plus reserved bits. + invalid[22] = 0xD0; // Invalid single shift plus a reserved bit. + var reader: std.Io.Reader = .fixed(&invalid); + const decoded = try SavedCursor.decode(&reader); + + try std.testing.expect(decoded.pen.default()); + try std.testing.expect(decoded.flags.protected); + try std.testing.expect(!decoded.flags.pending_wrap); + try std.testing.expect(!decoded.flags.origin); + try std.testing.expectEqual(@as(u5, 0), decoded.flags._padding); + try std.testing.expectEqual(null, decoded.charset.single_shift); + try std.testing.expectEqual( + terminal_charsets.Slots.G0, + decoded.charset.gr, + ); +} + +test "saved cursor decoding rejects every truncation" { + for (0..SavedCursor.len) |fixture_len| { + var reader: std.Io.Reader = .fixed( + test_saved_cursor_fixture[0..fixture_len], + ); + try std.testing.expectError( + error.EndOfStream, + SavedCursor.decode(&reader), + ); + } +} + +test "cursor hyperlink null encoding and decoding" { + var none_encoded: [1]u8 = undefined; + var none_writer: std.Io.Writer = .fixed(&none_encoded); + try encodeCursorHyperlink(null, &none_writer); + try std.testing.expectEqualStrings("\x00", none_writer.buffered()); + + var none_reader: std.Io.Reader = .fixed(none_writer.buffered()); + try std.testing.expectEqual( + null, + try decodeCursorHyperlink(&none_reader, std.testing.allocator), + ); +} + +test "framed native SCREEN and PAGE sequence" { + var screen = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ .cols = 8, .rows = 8, .max_scrollback_bytes = 0 }, + ); + defer screen.deinit(); + + // Configure the live cursor state represented by the fixed header. + screen.cursorAbsolute(7, 6); + screen.cursor.cursor_style = .block_hollow; + screen.cursor.pending_wrap = true; + screen.cursor.protected = false; + screen.cursor.semantic_content = .prompt; + screen.cursor.semantic_content_clear_eol = true; + screen.cursor.style = testHeader().cursor_pen; + screen.cursor.hyperlink_implicit_id = 0x0a0b0c0d; + + // Configure the screen-wide modes and packed charset state. + var charset: TerminalScreen.CharsetState = .{ + .gl = .G1, + .gr = .G3, + .single_shift = .G2, + }; + charset.charsets.set(.G0, .utf8); + charset.charsets.set(.G1, .ascii); + charset.charsets.set(.G2, .british); + charset.charsets.set(.G3, .dec_special); + screen.charset = charset; + screen.protected_mode = .dec; + screen.kitty_keyboard = .{ + .idx = 7, + .flags = .{ + .{ .disambiguate = true }, + .{ .report_events = true }, + .{ .report_alternates = true }, + .{ .report_all = true }, + .{ .report_associated = true }, + .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }, + .{}, + .{ + .disambiguate = true, + .report_associated = true, + }, + }, + }; + screen.semantic_prompt = .{ + .seen = true, + .click = .{ .cl = .smart_vertical }, + }; + + // Configure the optional cursor state encoded after the fixed header. + screen.saved_cursor = .{ + .x = 0x0102, + .y = 0x0304, + .style = .{}, + .protected = true, + .pending_wrap = true, + .origin = true, + .charset = charset, + }; + try screen.startHyperlink("cursor-uri", null); + + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + try destination.writer.writeAll("prefix"); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + try encode(&screen, .alternate, &stream); + + try std.testing.expectEqualStrings( + "prefix", + destination.written()[0..6], + ); + + // The complete sequence restores directly into native Screen/PageList + // state, including page-local cursor references. + var restore_source: std.Io.Reader = .fixed(destination.written()[6..]); + var decoded = try decode( + &restore_source, + std.testing.io, + std.testing.allocator, + .{ .cols = 8, .rows = 8, .max_scrollback_bytes = 0 }, + ); + defer decoded.deinit(); + try std.testing.expectEqual(TerminalScreenKey.alternate, decoded.key); + const restored = &decoded.screen; + + try std.testing.expect(restored.no_scrollback); + try std.testing.expectEqual(@as(u16, 7), restored.cursor.x); + try std.testing.expectEqual(@as(u16, 6), restored.cursor.y); + try std.testing.expectEqual( + TerminalScreen.CursorStyle.block_hollow, + restored.cursor.cursor_style, + ); + try std.testing.expect(restored.cursor.pending_wrap); + try std.testing.expectEqual( + terminal_page.Cell.SemanticContent.prompt, + restored.cursor.semantic_content, + ); + try std.testing.expect( + restored.cursor.style.eql(testHeader().cursor_pen), + ); + try std.testing.expect(restored.cursor.style_id != terminal_style.default_id); + try std.testing.expectEqual( + @as(u32, 0x0a0b0c0e), + restored.cursor.hyperlink_implicit_id, + ); + const restored_hyperlink = restored.cursor.hyperlink.?.*; + try std.testing.expectEqualStrings( + "cursor-uri", + restored_hyperlink.uri, + ); + switch (restored_hyperlink.id) { + .implicit => |id| try std.testing.expectEqual( + @as(u32, 0x0a0b0c0d), + id, + ), + .explicit => try std.testing.expect(false), + } + + const restored_saved = restored.saved_cursor.?; + try std.testing.expectEqual(@as(u16, 0x0102), restored_saved.x); + try std.testing.expectEqual(@as(u16, 0x0304), restored_saved.y); + try std.testing.expect(restored_saved.protected); + try std.testing.expect(restored_saved.pending_wrap); + try std.testing.expect(restored_saved.origin); + try std.testing.expectEqual( + terminal_charsets.Charset.ascii, + restored.charset.charsets.get(.G1), + ); + try std.testing.expectEqual( + terminal_ansi.ProtectedMode.dec, + restored.protected_mode, + ); + try std.testing.expectEqual(@as(u8, 7), restored.kitty_keyboard.idx); + try std.testing.expect(restored.semantic_prompt.seen); + try std.testing.expectEqual( + TerminalScreen.SemanticPrompt.SemanticClick{ + .cl = .smart_vertical, + }, + restored.semantic_prompt.click, + ); + + // Restored page-local cursor IDs are immediately usable by normal writes. + try restored.testWriteString("Z"); + const written = restored.pages.getCell(.{ .active = .{ + .x = 0, + .y = 7, + } }).?; + try std.testing.expectEqual(@as(u21, 'Z'), written.cell.codepoint()); + try std.testing.expectEqual( + restored.cursor.style_id, + written.cell.style_id, + ); + try std.testing.expectEqual( + restored.cursor.hyperlink_id, + written.node.page().lookupHyperlink(written.cell).?, + ); + try std.testing.expectEqual( + terminal_page.Cell.SemanticContent.prompt, + written.cell.semantic_content, + ); + try std.testing.expectError(error.EndOfStream, restore_source.takeByte()); +} + +test "SCREEN encodes the minimal complete-page active suffix" { + var probe = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ .cols = 80, .rows = 1, .max_scrollback_bytes = 0 }, + ); + const page_rows = probe.pages.getTopLeft(.screen).node.capacity().rows; + probe.deinit(); + const screen_rows = page_rows + 1; + + var screen = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ + .cols = 80, + .rows = screen_rows, + .max_scrollback_bytes = null, + }, + ); + defer screen.deinit(); + + // Create one complete historical page followed by a mixed page whose + // first row is history and whose remaining rows begin the active area. + screen.cursorAbsolute(0, screen_rows - 1); + for (0..screen_rows) |_| try screen.testWriteString("\n"); + try std.testing.expectEqual(@as(usize, 3), screen.pages.totalPages()); + + const active_top = screen.pages.getTopLeft(.active); + const historical = screen.pages.getTopLeft(.screen).node; + const mixed = historical.next.?; + const newest = mixed.next.?; + try std.testing.expectEqual(mixed, active_top.node); + try std.testing.expectEqual(@as(u16, 1), active_top.y); + try std.testing.expectEqual(null, newest.next); + + // Mark each native page so the encoded sequence proves both omission and + // oldest-to-newest ordering. The mixed page's marker is in its incidental + // history prefix and must survive because complete pages are encoded. + historical.page().getRowAndCell(0, 0).cell.* = .init('A'); + mixed.page().getRowAndCell(0, 0).cell.* = .init('B'); + newest.page().getRowAndCell(0, 0).cell.* = .init('C'); + + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + try encode(&screen, .primary, &stream); + + var source: std.Io.Reader = .fixed(destination.written()); + var screen_record: record.Reader = undefined; + try screen_record.init(&source); + try std.testing.expectEqual(record.Tag.screen, screen_record.header.tag); + + const payload_reader = screen_record.payloadReader(); + const header = try Header.decode(payload_reader); + try std.testing.expectEqual(TerminalScreenKey.primary, header.key); + try std.testing.expectEqual(@as(u16, 2), header.page_count); + try std.testing.expectEqual( + @as(u64, @intCast(screen.pages.total_rows - screen.pages.rows)), + header.history_rows, + ); + try std.testing.expect(!header.saved_cursor_present); + try std.testing.expectEqual( + null, + try decodeCursorHyperlink(payload_reader, std.testing.allocator), + ); + try screen_record.finish(); + + var decoded_mixed = try page.decode(&source, std.testing.allocator); + defer decoded_mixed.deinit(); + try std.testing.expectEqual( + @as(u21, 'B'), + decoded_mixed.getRowAndCell(0, 0).cell.codepoint(), + ); + + var decoded_newest = try page.decode(&source, std.testing.allocator); + defer decoded_newest.deinit(); + try std.testing.expectEqual( + @as(u21, 'C'), + decoded_newest.getRowAndCell(0, 0).cell.codepoint(), + ); + try std.testing.expectError(error.EndOfStream, source.takeByte()); + + // Native restoration keeps the incidental history prefix and positions + // the active top within the first decoded page. + var restore_source: std.Io.Reader = .fixed(destination.written()); + var decoded = try decode( + &restore_source, + std.testing.io, + std.testing.allocator, + .{ + .cols = 80, + .rows = screen_rows, + .max_scrollback_bytes = null, + }, + ); + defer decoded.deinit(); + try std.testing.expectEqual(TerminalScreenKey.primary, decoded.key); + try std.testing.expectEqual(header.history_rows, decoded.history_rows); + const restored = &decoded.screen; + + try std.testing.expectEqual(@as(usize, 2), restored.pages.totalPages()); + const restored_top = restored.pages.getTopLeft(.active); + try std.testing.expectEqual(@as(u16, 1), restored_top.y); + try std.testing.expectEqual( + @as(usize, restored_top.y), + restored.pages.total_rows - restored.pages.rows, + ); + try std.testing.expect( + decoded.history_rows > @as( + u64, + @intCast(restored.pages.total_rows - restored.pages.rows), + ), + ); + try std.testing.expectEqual( + @as(u21, 'B'), + restored.pages.getTopLeft(.screen).node + .page().getRowAndCell(0, 0).cell.codepoint(), + ); + try std.testing.expectEqual( + @as(u21, 'C'), + restored.pages.getTopLeft(.screen).node.next.? + .page().getRowAndCell(0, 0).cell.codepoint(), + ); + + // The restored PageList resumes ordinary scrolling and row growth. + try restored.testWriteString("\n"); + restored.pages.assertIntegrity(); + restored.assertIntegrity(); + try std.testing.expectError(error.EndOfStream, restore_source.takeByte()); +} + +test "SCREEN restoration normalizes invalid cursor positions" { + var screen = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, + ); + defer screen.deinit(); + + const Case = struct { + x: u16, + y: u16, + pending_wrap: bool, + expected_x: u16, + expected_y: u16, + expected_pending_wrap: bool, + }; + const cases = [_]Case{ + // Out-of-range coordinates clamp to the bottom-right. Pending wrap is + // valid there and can be retained. + .{ + .x = std.math.maxInt(u16), + .y = std.math.maxInt(u16), + .pending_wrap = true, + .expected_x = 1, + .expected_y = 1, + .expected_pending_wrap = true, + }, + // Pending wrap away from the final column would trip native printing + // assertions, so retain the position and clear only that flag. + .{ + .x = 0, + .y = 0, + .pending_wrap = true, + .expected_x = 0, + .expected_y = 0, + .expected_pending_wrap = false, + }, + }; + + for (cases) |case| { + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + + var header = Header.init(&screen, .primary, 1); + header.cursor_x = case.x; + header.cursor_y = case.y; + header.cursor_flags.pending_wrap = case.pending_wrap; + header.saved_cursor_present = false; + + const screen_payload = stream.begin(.screen); + errdefer stream.cancel(); + try header.encode(screen_payload); + try screen_payload.writeByte(0); + try stream.finish(); + + const native_page = screen.pages.getTopLeft(.active).node + .pageAssumeResident(); + try page.encode(native_page, &stream); + + var source: std.Io.Reader = .fixed(destination.written()); + var decoded = try decode( + &source, + std.testing.io, + std.testing.allocator, + .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, + ); + defer decoded.deinit(); + + try std.testing.expectEqual(case.expected_x, decoded.screen.cursor.x); + try std.testing.expectEqual(case.expected_y, decoded.screen.cursor.y); + try std.testing.expectEqual( + case.expected_pending_wrap, + decoded.screen.cursor.pending_wrap, + ); + decoded.screen.assertIntegrity(); + } +} + +test "SCREEN restoration rejects invalid and incomplete sequences" { + var screen = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, + ); + defer screen.deinit(); + + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + try encode(&screen, .primary, &stream); + + // Every truncation must fail without leaking a partially restored + // PageList, cursor pin, or cursor-owned state. + for (0..destination.written().len) |fixture_len| { + var source: std.Io.Reader = .fixed( + destination.written()[0..fixture_len], + ); + var restored = decode( + &source, + std.testing.io, + std.testing.allocator, + .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, + ) catch continue; + restored.deinit(); + try std.testing.expect(false); + } + + // Native PageList construction is transactional on allocation failure. + { + var failing = std.testing.FailingAllocator.init( + std.testing.allocator, + .{ .fail_index = 0 }, + ); + var source: std.Io.Reader = .fixed(destination.written()); + try std.testing.expectError( + error.OutOfMemory, + decode( + &source, + std.testing.io, + failing.allocator(), + .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, + ), + ); + } + + // A syntactically valid SCREEN cannot declare an empty PAGE sequence. + var empty_sequence: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer empty_sequence.deinit(); + var empty_stream: record.Writer = .init( + std.testing.allocator, + &empty_sequence.writer, + ); + defer empty_stream.deinit(); + { + const payload = empty_stream.begin(.screen); + errdefer empty_stream.cancel(); + try Header.init(&screen, .primary, 0).encode( + payload, + ); + try payload.writeByte(0); + try empty_stream.finish(); + } + var empty_source: std.Io.Reader = .fixed(empty_sequence.written()); + try std.testing.expectError( + error.InvalidPageCount, + decode( + &empty_source, + std.testing.io, + std.testing.allocator, + .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, + ), + ); +} + +test "SCREEN sequence failure preserves preceding bytes" { + var screen = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, + ); + defer screen.deinit(); + + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + try destination.writer.writeAll("prefix"); + + var failing = std.testing.FailingAllocator.init( + std.testing.allocator, + .{ .fail_index = 0 }, + ); + var stream: record.Writer = .init( + failing.allocator(), + &destination.writer, + ); + defer stream.deinit(); + + try std.testing.expectError( + error.WriteFailed, + encode(&screen, .primary, &stream), + ); + try std.testing.expectEqualStrings("prefix", destination.written()); +} + +test "SCREEN decode ignores an invalid cursor hyperlink" { + var screen = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ .cols = 1, .rows = 1, .max_scrollback_bytes = 0 }, + ); + defer screen.deinit(); + + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + + var header = testHeader(); + header.key = .primary; + header.page_count = 1; + header.cursor_x = 0; + header.cursor_y = 0; + header.cursor_flags.pending_wrap = false; + header.saved_cursor_present = false; + + const screen_payload = stream.begin(.screen); + errdefer stream.cancel(); + try header.encode(screen_payload); + try screen_payload.writeByte(1); // Implicit hyperlink. + try io.writeInt(screen_payload, u32, 1); + try io.writeInt(screen_payload, u32, 0); // Empty URI. + try stream.finish(); + + try page.encode( + screen.pages.getTopLeft(.active).node.pageAssumeResident(), + &stream, + ); + + var source: std.Io.Reader = .fixed(destination.written()); + var decoded = try decode( + &source, + std.testing.io, + std.testing.allocator, + .{ .cols = 1, .rows = 1 }, + ); + defer decoded.deinit(); + try std.testing.expectEqual(null, decoded.screen.cursor.hyperlink); +} + +test "SCREEN decode ignores a PAGE with an empty hyperlink URI" { + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + + var header = testHeader(); + header.key = .primary; + header.page_count = 1; + header.cursor_x = 0; + header.cursor_y = 0; + header.cursor_flags.pending_wrap = false; + header.saved_cursor_present = false; + + const screen_payload = stream.begin(.screen); + errdefer stream.cancel(); + try header.encode(screen_payload); + try screen_payload.writeByte(0); + try stream.finish(); + + const page_header: page.Header = .{ + .columns = 1, + .rows = 1, + .style_count = 0, + .hyperlink_count = 1, + .style_capacity = 16, + .hyperlink_capacity_bytes = 512, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + const page_payload = stream.begin(.page); + errdefer stream.cancel(); + try page_header.encode(page_payload); + try io.writeInt( + page_payload, + terminal_hyperlink.Id, + 1, + ); + try page_payload.writeByte(1); // Implicit hyperlink. + try io.writeInt(page_payload, u32, 1); + try io.writeInt(page_payload, u32, 0); // Empty URI. + + // One narrow codepoint cell refers to the hyperlink table entry above. + // Since that entry is ignored, the cell must restore without a hyperlink. + try page_payload.writeByte(0); + try page_payload.writeAll(&.{ 0, 0, 0, 0 }); + try io.writeInt( + page_payload, + terminal_style.Id, + 0, + ); + try io.writeInt( + page_payload, + terminal_hyperlink.Id, + 1, + ); + try io.writeInt(page_payload, u32, 'A'); + try io.writeInt(page_payload, u32, 0); + try stream.finish(); + + var source: std.Io.Reader = .fixed(destination.written()); + var decoded = try decode( + &source, + std.testing.io, + std.testing.allocator, + .{ .cols = 1, .rows = 1 }, + ); + defer decoded.deinit(); + + try std.testing.expectEqual( + @as(u21, 'A'), + decoded.screen.cursor.page_cell.codepoint(), + ); + try std.testing.expect(!decoded.screen.cursor.page_cell.hyperlink); + + // Resizing copies the restored cells into a wider page. The ignored + // hyperlink must not leave a zero-length PageEntry to duplicate later. + try decoded.screen.resize(.{ .cols = 2, .rows = 1 }); + try std.testing.expect(!decoded.screen.cursor.page_cell.hyperlink); +} diff --git a/src/terminal/snapshot/snapshot.ksy b/src/terminal/snapshot/snapshot.ksy new file mode 100644 index 000000000..ad8f6c3a0 --- /dev/null +++ b/src/terminal/snapshot/snapshot.ksy @@ -0,0 +1,994 @@ +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. FINISH terminates the snapshot; bytes that follow belong to + the containing transport and are outside this schema. Each SCREEN declares + its complete logical history extent before READY. + + 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) +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: history_rows + type: u8 + - 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: 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/snapshot.zig b/src/terminal/snapshot/snapshot.zig new file mode 100644 index 000000000..ac76df44f --- /dev/null +++ b/src/terminal/snapshot/snapshot.zig @@ -0,0 +1,773 @@ +//! Complete terminal snapshot encoding and restoration. + +const std = @import("std"); +const build_options = @import("terminal_options"); +const Allocator = std.mem.Allocator; +const checkpoint = @import("checkpoint.zig"); +const envelope = @import("envelope.zig"); +const test_fixture = @import("fixture.zig"); +const history = @import("history.zig"); +const record = @import("record.zig"); +const screen = @import("screen.zig"); +const terminal = @import("terminal.zig"); +const Terminal = @import("../Terminal.zig"); +const terminal_kitty = @import("../kitty.zig"); +const TerminalPageList = @import("../PageList.zig"); +const TerminalScreen = @import("../Screen.zig"); +const TerminalScreenKey = @import("../ScreenSet.zig").Key; + +const test_complete_fixture = test_fixture.parse( + @embedFile("testdata/complete-v1.hex"), +); + +/// Errors possible while encoding one complete terminal snapshot. +pub const EncodeError = terminal.EncodeError || + screen.EncodeError || + history.EncodeError || + checkpoint.EncodeError; + +/// Encode one complete terminal snapshot. +/// +/// Encoding starts at the destination's current position. Only one record +/// payload is buffered at a time; completed records stream immediately. On +/// failure, the destination may contain a snapshot prefix without its required +/// checkpoints, and an output failure may have written part of a record. +pub fn encode( + alloc: Allocator, + destination: *std.Io.Writer, + t: *const Terminal, +) EncodeError!void { + var stream: record.Writer = .init(alloc, destination); + defer stream.deinit(); + + // 1. Envelope + try envelope.encode(stream.writer()); + + // 2. Terminal + try terminal.encode(t, &stream); + + // 3. Primary and alt screen + try screen.encode( + t.screens.get(.primary).?, + .primary, + &stream, + ); + if (t.screens.get(.alternate)) |alternate| try screen.encode( + alternate, + .alternate, + &stream, + ); + + // 4. Ready checkpoint. In the future we'll put our continuation + // state before this so pty bytes can also flow. + try checkpoint.encode(.ready, &stream); + + // 5. History + try history.encode( + t.screens.get(.primary).?, + .primary, + &stream, + ); + if (t.screens.get(.alternate)) |alternate| try history.encode( + alternate, + .alternate, + &stream, + ); + + // 6. Finish + try checkpoint.encode(.finish, &stream); +} + +/// Errors possible while restoring one complete terminal snapshot. +pub const DecodeError = envelope.DecodeError || + terminal.DecodeError || + screen.DecodeError || + history.DecodeError || + checkpoint.DecodeError || + error{ + /// A SCREEN names a key not declared by TERMINAL. + UnexpectedScreenKey, + + /// More than one SCREEN names the same key. + DuplicateScreen, + + /// A HISTORY names a key not declared by TERMINAL. + UnexpectedHistoryKey, + + /// More than one HISTORY names the same key. + DuplicateHistory, + }; + +/// Restore one complete snapshot into a native terminal. +/// +/// This consumes one snapshot through FINISH and leaves any following bytes in +/// the reader for the containing transport. Restoration is transactional: the +/// returned terminal is either complete and ready or not (error return). +/// Individual record codecs normalize optional semantic state, while framing, +/// checkpoints, declared sequence counts, and unique cross-record screen +/// routing remain strict. +pub fn decode( + alloc: Allocator, + io_: std.Io, + source: *std.Io.Reader, +) DecodeError!Terminal { + // StreamReader owns a zero-buffer hashing adapter, making checkpoint + // boundaries part of its API rather than a caller-maintained invariant. + var stream: record.StreamReader = .init(source); + const reader = stream.reader(); + + // Read the envelope, which is currently just a verification step. + try envelope.decode(reader); + + // TERMINAL establishes terminal-wide state and allocates empty screen + // slots with their final routing. SCREEN values replace those slots in + // place so ScreenSet pointers, including the active pointer, stay valid. + var result = try terminal.decode(reader, io_, alloc); + errdefer result.deinit(alloc); + + // TERMINAL initializes exactly the number of screen slots it declared. + // Decode that many SCREEN sequences and route each one by its encoded key. + const screen_count = result.screens.all.count(); + const options: TerminalScreen.Options = options: { + const primary = result.screens.get(.primary).?; + const explicit_bytes = primary.pages.limits.bytes.explicit; + const explicit_lines = primary.pages.limits.lines.explicit; + break :options .{ + .cols = result.cols, + .rows = result.rows, + .max_scrollback_bytes = if (explicit_bytes == std.math.maxInt(usize)) + null + else + explicit_bytes, + .max_scrollback_lines = if (explicit_lines == std.math.maxInt(usize)) + null + else + explicit_lines, + }; + }; + + for (0..screen_count) |_| { + var decoded = try screen.decode( + reader, + io_, + alloc, + options, + ); + errdefer decoded.deinit(); + + const slot = result.screens.get(decoded.key) orelse + return error.UnexpectedScreenKey; + + // The fresh ScreenSet starts every declared slot at generation zero. + // Replacing a slot advances its generation, so a nonzero value means + // an earlier SCREEN in this snapshot already supplied the same key. + if (result.screens.generation(decoded.key) != 0) return error.DuplicateScreen; + + slot.deinit(); + slot.* = decoded.screen; + decoded.screen = undefined; + + // We put an artificial generation in just so we can detect duplicates. + result.screens.generations.put( + decoded.key, + result.screens.generation(decoded.key) +% 1, + ); + } + + // READY covers the exact envelope-through-SCREEN prefix. Finalizing does + // not consume the hasher, so the same stream continues toward FINISH. + try checkpoint.decode(.ready, &stream); + + // HISTORY keys make this sequence order-independent just like SCREEN. + // Although a decoder may publish recent pages as they validate, any later + // full-snapshot failure deinitializes the whole result. + for (0..screen_count) |_| { + var decoder: history.Decoder = undefined; + try decoder.init(reader); + + const key = decoder.header.key; + const restored = result.screens.get(key) orelse + return error.UnexpectedHistoryKey; + + // SCREEN routing advanced every decoded slot from generation zero to + // one. A value other than one means this key already received HISTORY. + if (result.screens.generation(key) != 1) { + return error.DuplicateHistory; + } + + try decoder.decode(alloc, restored); + result.screens.generations.put(key, 2); + } + + // FINISH authenticates READY and all history. Decode it directly from the + // underlying reader so the digest does not include FINISH itself. + try checkpoint.decode(.finish, &stream); + + const keys = [_]TerminalScreenKey{ .primary, .alternate }; + if (comptime build_options.slow_runtime_safety) { + for (keys) |key| { + const restored = result.screens.get(key) orelse continue; + restored.pages.assertIntegrity(); + restored.assertIntegrity(); + } + } + + // Generations are only scratch state while routing the two keyed sequence + // groups. The completed terminal has not escaped yet, so reset them to the + // same initial state as any newly constructed ScreenSet. + for (keys) |key| result.screens.generations.put(key, 0); + return result; +} + +/// Errors possible while restoring a snapshot that must end at end-of-file. +pub const DecodeExactError = DecodeError || std.Io.Reader.Error || error{ + /// FINISH was followed by additional bytes. + TrailingData, +}; + +/// Restore one snapshot and require FINISH to be followed by end-of-file. +/// +/// This is intended for bounded snapshot files and buffers. On a live stream, +/// checking for end-of-file may block; use `decode` to stop at FINISH instead. +pub fn decodeExact( + alloc: Allocator, + io_: std.Io, + source: *std.Io.Reader, +) DecodeExactError!Terminal { + var result = try decode(alloc, io_, source); + errdefer result.deinit(alloc); + + _ = source.peekByte() catch |err| switch (err) { + error.EndOfStream => return result, + else => return err, + }; + return error.TrailingData; +} + +test "complete snapshot round trip with history and alternate screen" { + const testing = std.testing; + + var t = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 2, + .rows = 3, + .max_scrollback_bytes = null, + .max_scrollback_lines = null, + }); + defer t.deinit(testing.allocator); + + // Exercise terminal-wide state. + t.width_px = 800; + t.height_px = 600; + t.colors.palette.set(7, .{ .r = 1, .g = 2, .b = 3 }); + t.modes.values.bracketed_paste = true; + try t.setPwd("file:///tmp/snapshot"); + try t.setTitle("complete snapshot"); + + const primary = t.screens.get(.primary).?; + + // Use small exact capacities so this compound golden remains practical to + // review while still containing two complete history pages and one active + // page. Replacing the Screen in place preserves ScreenSet routing. + var replacement: TerminalScreen = replacement: { + var builder = try TerminalPageList.Builder.init( + testing.allocator, + .{ + .cols = t.cols, + .rows = t.rows, + .max_size = null, + .max_lines = null, + }, + ); + defer builder.deinit(); + + const oldest = try builder.allocatePage(.{ .cols = 2, .rows = 2 }); + oldest.size.rows = 2; + oldest.getRowAndCell(0, 0).cell.* = .init('A'); + + const recent = try builder.allocatePage(.{ .cols = 2, .rows = 2 }); + recent.size.rows = 2; + recent.getRowAndCell(0, 0).cell.* = .init('B'); + + const active = try builder.allocatePage(.{ .cols = 2, .rows = 3 }); + active.size.rows = 3; + active.getRowAndCell(0, 0).cell.* = .init('C'); + active.getRowAndCell(0, 1).cell.* = .init('D'); + active.getRowAndCell(0, 2).cell.* = .init('E'); + + var pages = try builder.finish(); + errdefer pages.deinit(); + + const cursor_pin = try pages.trackPin( + pages.pin(.{ .active = .{} }).?, + ); + const cursor_rac = cursor_pin.rowAndCell(); + break :replacement .{ + .io = testing.io, + .alloc = testing.allocator, + .pages = pages, + .cursor = .{ + .page_pin = cursor_pin, + .page_row = cursor_rac.row, + .page_cell = cursor_rac.cell, + }, + }; + }; + primary.deinit(); + primary.* = replacement; + replacement = undefined; + + try testing.expect(primary.pages.scrollbar().total > t.rows); + + // Compression is an internal source representation and must remain + // unchanged while the complete history is inspected for encoding. + _ = primary.pages.compress(.full); + const source_memory = primary.pages.memoryStats(); + + // The optional alternate screen participates in both phases and remains + // the active screen after restoration. + _ = try t.switchScreen(.alternate); + try t.printString("alternate"); + + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &t); + try testing.expectEqualDeep(source_memory, primary.pages.memoryStats()); + try test_fixture.expectEqual( + .snapshot, + "src/terminal/snapshot/testdata/complete-v1.hex", + "snapshot_fixture-complete-v1.hex", + &test_complete_fixture, + encoded.written(), + ); + + // A complete snapshot can stream through a non-allocating destination. + // Independently hash that output so both its length and complete byte + // sequence are checked without retaining a second snapshot copy. + var discard: std.Io.Writer.Discarding = .init(&.{}); + var hashing = discard.writer.hashed( + std.crypto.hash.Blake3.init(.{}), + &.{}, + ); + try encode(testing.allocator, &hashing.writer, &t); + try testing.expectEqual( + @as(u64, test_complete_fixture.len), + discard.fullCount(), + ); + var expected_digest: checkpoint.Digest = undefined; + std.crypto.hash.Blake3.hash( + &test_complete_fixture, + &expected_digest, + .{}, + ); + var actual_digest: checkpoint.Digest = undefined; + hashing.hasher.final(&actual_digest); + try testing.expectEqual(expected_digest, actual_digest); + + // Restore the checked-in reference rather than the just-generated bytes. + var encoded_source: std.Io.Reader = .fixed(&test_complete_fixture); + var source_buffer: [1]u8 = undefined; + var limited = encoded_source.limited(.unlimited, &source_buffer); + var restored = try decode( + testing.allocator, + testing.io, + &limited.interface, + ); + defer restored.deinit(testing.allocator); + + try testing.expectEqual(TerminalScreenKey.alternate, restored.screens.active_key); + try testing.expectEqual( + restored.screens.get(.alternate).?, + restored.screens.active, + ); + try testing.expectEqualStrings( + "file:///tmp/snapshot", + restored.getPwd().?, + ); + try testing.expectEqualStrings( + "complete snapshot", + restored.getTitle().?, + ); + try testing.expectEqual( + primary.pages.scrollbar().total, + restored.screens.get(.primary).?.pages.scrollbar().total, + ); + + // Re-encoding is a compact semantic equality check over all TERMINAL, + // SCREEN, PAGE, and HISTORY fields and both checkpoint boundaries. + var reencoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer reencoded.deinit(); + try encode(testing.allocator, &reencoded.writer, &restored); + try testing.expectEqualStrings( + &test_complete_fixture, + reencoded.written(), + ); + + // SCREEN and HISTORY keys make both sequence groups order independent. + var reversed: std.Io.Writer.Allocating = .init(testing.allocator); + defer reversed.deinit(); + var reversed_stream: record.Writer = .init( + testing.allocator, + &reversed.writer, + ); + defer reversed_stream.deinit(); + try envelope.encode(reversed_stream.writer()); + try terminal.encode(&t, &reversed_stream); + try screen.encode( + t.screens.get(.alternate).?, + .alternate, + &reversed_stream, + ); + try screen.encode(primary, .primary, &reversed_stream); + try checkpoint.encode(.ready, &reversed_stream); + try history.encode( + t.screens.get(.alternate).?, + .alternate, + &reversed_stream, + ); + try history.encode(primary, .primary, &reversed_stream); + try checkpoint.encode(.finish, &reversed_stream); + + var reversed_source: std.Io.Reader = .fixed(reversed.written()); + var reversed_restored = try decode( + testing.allocator, + testing.io, + &reversed_source, + ); + defer reversed_restored.deinit(testing.allocator); + try testing.expectEqual( + TerminalScreenKey.alternate, + reversed_restored.screens.active_key, + ); + try testing.expectEqual( + @as(usize, 0), + reversed_restored.screens.generation(.primary), + ); + try testing.expectEqual( + @as(usize, 0), + reversed_restored.screens.generation(.alternate), + ); +} + +test "complete snapshot preserves Kitty virtual placeholders" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const testing = std.testing; + var t = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 2, + .rows = 1, + }); + defer t.deinit(testing.allocator); + + // Register a real virtual placement, then write its grid representation: + // U+10EEEE followed by row and column diacritics. The image and placement + // registry is intentionally omitted, but the grid content must remain + // decodable. + try t.screens.active.kitty_images.addImage( + testing.io, + testing.allocator, + .{ .id = 1 }, + ); + try t.screens.active.kitty_images.addPlacement( + testing.io, + testing.allocator, + 1, + 0, + .{ + .location = .{ .virtual = {} }, + .columns = 1, + .rows = 1, + }, + ); + try t.setAttribute(.{ .@"256_fg" = 1 }); + try t.printString("\u{10EEEE}\u{0305}\u{0305}"); + const source_cell = t.screens.active.pages.getCell(.{ + .screen = .{}, + }).?; + try testing.expectEqual( + terminal_kitty.graphics.unicode.placeholder, + source_cell.cell.codepoint(), + ); + try testing.expect(source_cell.row.kitty_virtual_placeholder); + + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &t); + + var encoded_source: std.Io.Reader = .fixed(encoded.written()); + var restored = try decode( + testing.allocator, + testing.io, + &encoded_source, + ); + defer restored.deinit(testing.allocator); + + const restored_cell = restored.screens.active.pages.getCell(.{ + .screen = .{}, + }).?; + try testing.expectEqual( + terminal_kitty.graphics.unicode.placeholder, + restored_cell.cell.codepoint(), + ); + try testing.expect(restored_cell.cell.hasGrapheme()); + try testing.expect(restored_cell.row.kitty_virtual_placeholder); + try testing.expectEqual( + @as(usize, 0), + restored.screens.active.kitty_images.images.count(), + ); + try testing.expectEqual( + @as(usize, 0), + restored.screens.active.kitty_images.placements.count(), + ); +} + +test "complete snapshot encoding streams from the current writer position" { + const testing = std.testing; + + var t = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 2, + .rows = 1, + }); + defer t.deinit(testing.allocator); + + // Prefix hashing begins with this call's envelope, independent of bytes + // that were already present in the destination. + var nonempty: std.Io.Writer.Allocating = .init(testing.allocator); + defer nonempty.deinit(); + try nonempty.writer.writeAll("prefix"); + const snapshot_offset = nonempty.written().len; + try encode(testing.allocator, &nonempty.writer, &t); + try testing.expectEqualStrings( + "prefix", + nonempty.written()[0..snapshot_offset], + ); + var appended_source: std.Io.Reader = .fixed( + nonempty.written()[snapshot_offset..], + ); + var appended = try decode( + testing.allocator, + testing.io, + &appended_source, + ); + appended.deinit(testing.allocator); + + // Payload validation happens in the record-local scratch allocation. The + // already-streamed envelope remains, but no partial TERMINAL is emitted. + t.colors.palette.current[7] = .{ .r = 1, .g = 2, .b = 3 }; + var destination: std.Io.Writer.Allocating = .init(testing.allocator); + defer destination.deinit(); + try destination.writer.writeAll("prefix"); + try testing.expectError( + error.InvalidPalette, + encode(testing.allocator, &destination.writer, &t), + ); + var expected_envelope: [envelope.encoded_len]u8 = undefined; + var envelope_writer: std.Io.Writer = .fixed(&expected_envelope); + try envelope.encode(&envelope_writer); + try testing.expectEqualStrings( + &expected_envelope, + destination.written()["prefix".len..], + ); +} + +test "complete snapshot rejects ordering and invalid checkpoints" { + const testing = std.testing; + + var t = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 2, + .rows = 1, + }); + defer t.deinit(testing.allocator); + const primary = t.screens.get(.primary).?; + + // HISTORY is individually valid here, but the full decoder requires the + // primary SCREEN before READY. + var reordered: std.Io.Writer.Allocating = .init(testing.allocator); + defer reordered.deinit(); + var reordered_stream: record.Writer = .init( + testing.allocator, + &reordered.writer, + ); + defer reordered_stream.deinit(); + try envelope.encode(reordered_stream.writer()); + try terminal.encode(&t, &reordered_stream); + try history.encode(primary, .primary, &reordered_stream); + var reordered_source: std.Io.Reader = .fixed(reordered.written()); + try testing.expectError( + error.UnexpectedRecordTag, + decode(testing.allocator, testing.io, &reordered_source), + ); + + // Construct a correctly framed READY with an intentionally unrelated + // digest so the full driver, rather than record CRC validation, rejects it. + var invalid_ready: std.Io.Writer.Allocating = .init(testing.allocator); + defer invalid_ready.deinit(); + var invalid_ready_stream: record.Writer = .init( + testing.allocator, + &invalid_ready.writer, + ); + defer invalid_ready_stream.deinit(); + try envelope.encode(invalid_ready_stream.writer()); + try terminal.encode(&t, &invalid_ready_stream); + try screen.encode(primary, .primary, &invalid_ready_stream); + const ready_payload = invalid_ready_stream.begin(.ready); + errdefer invalid_ready_stream.cancel(); + try ready_payload.splatByteAll( + 0, + @sizeOf(checkpoint.Digest), + ); + try invalid_ready_stream.finish(); + var invalid_ready_source: std.Io.Reader = .fixed( + invalid_ready.written(), + ); + try testing.expectError( + error.InvalidDigest, + decode(testing.allocator, testing.io, &invalid_ready_source), + ); + + // A SCREEN key must name one of the slots declared by TERMINAL. + var undeclared: std.Io.Writer.Allocating = .init(testing.allocator); + defer undeclared.deinit(); + var undeclared_stream: record.Writer = .init( + testing.allocator, + &undeclared.writer, + ); + defer undeclared_stream.deinit(); + try envelope.encode(undeclared_stream.writer()); + try terminal.encode(&t, &undeclared_stream); + try screen.encode(primary, .alternate, &undeclared_stream); + var undeclared_source: std.Io.Reader = .fixed(undeclared.written()); + try testing.expectError( + error.UnexpectedScreenKey, + decode(testing.allocator, testing.io, &undeclared_source), + ); + + // HISTORY sequences are also routed by key, which must name a declared + // screen even when the sequence contains no PAGE records. + var undeclared_history: std.Io.Writer.Allocating = .init(testing.allocator); + defer undeclared_history.deinit(); + var undeclared_history_stream: record.Writer = .init( + testing.allocator, + &undeclared_history.writer, + ); + defer undeclared_history_stream.deinit(); + try envelope.encode(undeclared_history_stream.writer()); + try terminal.encode(&t, &undeclared_history_stream); + try screen.encode(primary, .primary, &undeclared_history_stream); + try checkpoint.encode(.ready, &undeclared_history_stream); + try history.encode(primary, .alternate, &undeclared_history_stream); + var undeclared_history_source: std.Io.Reader = .fixed( + undeclared_history.written(), + ); + try testing.expectError( + error.UnexpectedHistoryKey, + decode(testing.allocator, testing.io, &undeclared_history_source), + ); + + // The declared count cannot be satisfied by repeating the same key. + _ = try t.switchScreen(.alternate); + var duplicate: std.Io.Writer.Allocating = .init(testing.allocator); + defer duplicate.deinit(); + var duplicate_stream: record.Writer = .init( + testing.allocator, + &duplicate.writer, + ); + defer duplicate_stream.deinit(); + try envelope.encode(duplicate_stream.writer()); + try terminal.encode(&t, &duplicate_stream); + try screen.encode(primary, .primary, &duplicate_stream); + try screen.encode(primary, .primary, &duplicate_stream); + var duplicate_source: std.Io.Reader = .fixed(duplicate.written()); + try testing.expectError( + error.DuplicateScreen, + decode(testing.allocator, testing.io, &duplicate_source), + ); + + // The declared count cannot be satisfied by repeating one HISTORY key. + var duplicate_history: std.Io.Writer.Allocating = .init(testing.allocator); + defer duplicate_history.deinit(); + var duplicate_history_stream: record.Writer = .init( + testing.allocator, + &duplicate_history.writer, + ); + defer duplicate_history_stream.deinit(); + try envelope.encode(duplicate_history_stream.writer()); + try terminal.encode(&t, &duplicate_history_stream); + try screen.encode(primary, .primary, &duplicate_history_stream); + try screen.encode( + t.screens.get(.alternate).?, + .alternate, + &duplicate_history_stream, + ); + try checkpoint.encode(.ready, &duplicate_history_stream); + try history.encode(primary, .primary, &duplicate_history_stream); + try history.encode(primary, .primary, &duplicate_history_stream); + var duplicate_history_source: std.Io.Reader = .fixed( + duplicate_history.written(), + ); + try testing.expectError( + error.DuplicateHistory, + decode(testing.allocator, testing.io, &duplicate_history_source), + ); +} + +test "complete snapshot leaves continuation bytes unread" { + const testing = std.testing; + + var t = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 2, + .rows = 1, + }); + defer t.deinit(testing.allocator); + + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &t); + const snapshot_len = encoded.written().len; + try encoded.writer.writeAll("pty"); + + var source: std.Io.Reader = .fixed(encoded.written()); + var restored = try decode(testing.allocator, testing.io, &source); + defer restored.deinit(testing.allocator); + + var continuation: [3]u8 = undefined; + try source.readSliceAll(&continuation); + try testing.expectEqualStrings("pty", &continuation); + + var exact_source: std.Io.Reader = .fixed(encoded.written()); + try testing.expectError( + error.TrailingData, + decodeExact(testing.allocator, testing.io, &exact_source), + ); + + var bounded_source: std.Io.Reader = .fixed( + encoded.written()[0..snapshot_len], + ); + var bounded = try decodeExact( + testing.allocator, + testing.io, + &bounded_source, + ); + defer bounded.deinit(testing.allocator); +} + +test "complete snapshots decode sequentially from one reader" { + const testing = std.testing; + + var t = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 2, + .rows = 1, + }); + defer t.deinit(testing.allocator); + + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &t); + try encode(testing.allocator, &encoded.writer, &t); + + var source: std.Io.Reader = .fixed(encoded.written()); + var first = try decode(testing.allocator, testing.io, &source); + defer first.deinit(testing.allocator); + var second = try decode(testing.allocator, testing.io, &source); + defer second.deinit(testing.allocator); + try testing.expectError(error.EndOfStream, source.takeByte()); +} diff --git a/src/terminal/snapshot/style.zig b/src/terminal/snapshot/style.zig new file mode 100644 index 000000000..6df8ba03c --- /dev/null +++ b/src/terminal/snapshot/style.zig @@ -0,0 +1,392 @@ +//! Snapshot style entry encoding. +//! +//! Each entry describes one terminal style. A record can use these entries to +//! build a style table and assign indexes according to that record's format. +//! Indexing, ordering, etc. are properties of the containing record. +//! +//! Styles are encoded field by field from the terminal's native `Style` type. +//! The native packed representation is deliberately not part of the snapshot +//! format. This gives flexibility for changing one side or the other. +//! +//! All integers are unsigned and little-endian. +//! +//! | Offset | Size | Field | +//! | -----: | ---: | :------------------------ | +//! | 0 | 4 | Foreground color | +//! | 4 | 4 | Background color | +//! | 8 | 4 | Underline color | +//! | 12 | 2 | Style flags (`u16`) | +//! | 14 | 2 | Reserved, must be zero | +//! +//! The trailing reserved field is explicit wire padding that rounds each +//! style entry from 14 bytes to 16 bytes. This makes entry offsets and byte +//! counts straightforward. +//! +//! Each color begins with a one-byte kind followed by three data bytes: +//! +//! | Kind | Meaning | Data bytes | +//! | ---: | :------ | :--------------------------------- | +//! | 0 | None | All zero | +//! | 1 | Palette | Palette index, then two zero bytes | +//! | 2 | RGB | Red, green, blue | +//! +//! Style flag bits 0 through 7 are bold, italic, faint, blink, inverse, +//! invisible, strikethrough, and overline. Bits 8 through 10 contain the +//! underline kind. Bits 11 through 15 must be zero. +//! +//! | Underline | Meaning | +//! | --------: | :------ | +//! | 0 | None | +//! | 1 | Single | +//! | 2 | Double | +//! | 3 | Curly | +//! | 4 | Dotted | +//! | 5 | Dashed | +//! +//! Underline values 6 and 7 are invalid in snapshot version 1. + +const std = @import("std"); +const test_fixture = @import("fixture.zig"); +const io = @import("io.zig"); +const sgr = @import("../sgr.zig"); +const terminal_style = @import("../style.zig"); + +/// Number of bytes written by `encode`, calculated using the encoder itself +/// so this remains synchronized with the field-by-field wire format. +pub const len = computeLen(); + +comptime { + // This size is part of the wire format. If it changes, the snapshot + // version and golden fixtures must also change. + std.debug.assert(len == 16); +} + +const Flags = packed struct(u16) { + bold: bool = false, + italic: bool = false, + faint: bool = false, + blink: bool = false, + inverse: bool = false, + invisible: bool = false, + strikethrough: bool = false, + overline: bool = false, + underline: u3 = 0, + reserved: u5 = 0, +}; + +const ColorKind = enum(u8) { + none = 0, + palette = 1, + rgb = 2, +}; + +/// Errors possible while decoding one style entry. +pub const DecodeError = std.Io.Reader.Error || error{ + /// A color kind is not defined by snapshot version 1. + InvalidColorKind, + + /// Bytes unused by the selected color kind are not zero. + InvalidColor, + + /// The encoded underline kind is not defined by snapshot version 1. + InvalidUnderline, + + /// One or more reserved style flag bits are set. + InvalidFlags, + + /// The trailing reserved field is not zero. + InvalidReserved, +}; + +/// Encode one terminal style as a fixed-size snapshot style entry. +pub fn encode( + value: terminal_style.Style, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { + try encodeColor(value.fg_color, writer); + try encodeColor(value.bg_color, writer); + try encodeColor(value.underline_color, writer); + + const flags: Flags = .{ + .bold = value.flags.bold, + .italic = value.flags.italic, + .faint = value.flags.faint, + .blink = value.flags.blink, + .inverse = value.flags.inverse, + .invisible = value.flags.invisible, + .strikethrough = value.flags.strikethrough, + .overline = value.flags.overline, + .underline = @intFromEnum(value.flags.underline), + }; + try io.writeInt(writer, u16, @bitCast(flags)); + try io.writeInt(writer, u16, 0); +} + +/// Decode and validate one fixed-size snapshot style entry. +pub fn decode(reader: *std.Io.Reader) DecodeError!terminal_style.Style { + const fg_color = try decodeColor(reader); + const bg_color = try decodeColor(reader); + const underline_color = try decodeColor(reader); + + const flags: Flags = @bitCast(try io.readInt(reader, u16)); + if (flags.reserved != 0) return error.InvalidFlags; + + const underline = std.enums.fromInt( + sgr.Attribute.Underline, + flags.underline, + ) orelse return error.InvalidUnderline; + + const reserved = try io.readInt(reader, u16); + if (reserved != 0) return error.InvalidReserved; + + return .{ + .fg_color = fg_color, + .bg_color = bg_color, + .underline_color = underline_color, + .flags = .{ + .bold = flags.bold, + .italic = flags.italic, + .faint = flags.faint, + .blink = flags.blink, + .inverse = flags.inverse, + .invisible = flags.invisible, + .strikethrough = flags.strikethrough, + .overline = flags.overline, + .underline = underline, + }, + }; +} + +/// Decode strictly after consuming one complete fixed-size style entry. +/// +/// Unlike `decode`, a semantic error leaves `reader` at the next entry. This +/// lets an enclosing codec catch the error and choose its own fallback without +/// losing the surrounding payload boundary. +pub fn decodeOrDiscard( + reader: *std.Io.Reader, +) DecodeError!terminal_style.Style { + var encoded: [len]u8 = undefined; + try reader.readSliceAll(&encoded); + + var source: std.Io.Reader = .fixed(&encoded); + return decode(&source); +} + +fn encodeColor( + value: terminal_style.Style.Color, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { + var encoded: [4]u8 = @splat(0); + switch (value) { + .none => encoded[0] = @intFromEnum(ColorKind.none), + .palette => |index| { + encoded[0] = @intFromEnum(ColorKind.palette); + encoded[1] = index; + }, + .rgb => |rgb| { + encoded[0] = @intFromEnum(ColorKind.rgb); + encoded[1] = rgb.r; + encoded[2] = rgb.g; + encoded[3] = rgb.b; + }, + } + try writer.writeAll(&encoded); +} + +fn decodeColor( + reader: *std.Io.Reader, +) DecodeError!terminal_style.Style.Color { + // Colors are always 4 bytes + var encoded: [4]u8 = undefined; + try reader.readSliceAll(&encoded); + + // Kind must be something we know about. + const kind = std.enums.fromInt(ColorKind, encoded[0]) orelse { + return error.InvalidColorKind; + }; + + return switch (kind) { + .none => if (std.mem.eql(u8, encoded[1..], &.{ 0, 0, 0 })) + .none + else + error.InvalidColor, + .palette => if (encoded[2] == 0 and encoded[3] == 0) + .{ .palette = encoded[1] } + else + error.InvalidColor, + .rgb => .{ .rgb = .{ + .r = encoded[1], + .g = encoded[2], + .b = encoded[3], + } }, + }; +} + +fn computeLen() usize { + comptime { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + encode(.{}, &writer) catch unreachable; + return writer.end; + } +} + +const test_golden_fixture = test_fixture.parse(@embedFile("testdata/style-v1.hex")); + +test "golden encoding and decoding" { + const value: terminal_style.Style = .{ + .fg_color = .none, + .bg_color = .{ .palette = 0x7f }, + .underline_color = .{ .rgb = .{ + .r = 0x12, + .g = 0x34, + .b = 0x56, + } }, + .flags = .{ + .bold = true, + .italic = true, + .faint = true, + .blink = true, + .inverse = true, + .invisible = true, + .strikethrough = true, + .overline = true, + .underline = .curly, + }, + }; + var buf: [len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(value, &writer); + try test_fixture.expectEqual( + .bytes, + "src/terminal/snapshot/testdata/style-v1.hex", + "snapshot_fixture-style-v1.hex", + &test_golden_fixture, + writer.buffered(), + ); + + var source: std.Io.Reader = .fixed(&test_golden_fixture); + var read_buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &read_buf); + try std.testing.expect(value.eql(try decode(&limited.interface))); +} + +test "flag bit layout" { + const Case = struct { + value: Flags, + expected: u16, + }; + const cases = [_]Case{ + .{ .value = .{ .bold = true }, .expected = 1 << 0 }, + .{ .value = .{ .italic = true }, .expected = 1 << 1 }, + .{ .value = .{ .faint = true }, .expected = 1 << 2 }, + .{ .value = .{ .blink = true }, .expected = 1 << 3 }, + .{ .value = .{ .inverse = true }, .expected = 1 << 4 }, + .{ .value = .{ .invisible = true }, .expected = 1 << 5 }, + .{ .value = .{ .strikethrough = true }, .expected = 1 << 6 }, + .{ .value = .{ .overline = true }, .expected = 1 << 7 }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.single), + }, + .expected = 1 << 8, + }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.double), + }, + .expected = 2 << 8, + }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.curly), + }, + .expected = 3 << 8, + }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.dotted), + }, + .expected = 4 << 8, + }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.dashed), + }, + .expected = 5 << 8, + }, + .{ .value = .{ .reserved = 1 }, .expected = 1 << 11 }, + }; + + for (cases) |case| { + try std.testing.expectEqual( + case.expected, + @as(u16, @bitCast(case.value)), + ); + } +} + +test "reject invalid colors" { + for ([_]usize{ 0, 4, 8 }) |offset| { + var invalid_kind: [len]u8 = @splat(0); + invalid_kind[offset] = 3; + var reader: std.Io.Reader = .fixed(&invalid_kind); + try std.testing.expectError(error.InvalidColorKind, decode(&reader)); + } + + var invalid_none: [len]u8 = @splat(0); + invalid_none[1] = 1; + var none_reader: std.Io.Reader = .fixed(&invalid_none); + try std.testing.expectError(error.InvalidColor, decode(&none_reader)); + + var invalid_palette: [len]u8 = @splat(0); + invalid_palette[0] = @intFromEnum(ColorKind.palette); + invalid_palette[2] = 1; + var palette_reader: std.Io.Reader = .fixed(&invalid_palette); + try std.testing.expectError(error.InvalidColor, decode(&palette_reader)); +} + +test "reject invalid flags and reserved field" { + for ([_]u16{ 6, 7 }) |underline| { + var invalid_underline: [len]u8 = @splat(0); + std.mem.writeInt( + u16, + invalid_underline[12..14], + underline << 8, + .little, + ); + var reader: std.Io.Reader = .fixed(&invalid_underline); + try std.testing.expectError(error.InvalidUnderline, decode(&reader)); + } + + var invalid_flags: [len]u8 = @splat(0); + std.mem.writeInt(u16, invalid_flags[12..14], 1 << 11, .little); + var flags_reader: std.Io.Reader = .fixed(&invalid_flags); + try std.testing.expectError(error.InvalidFlags, decode(&flags_reader)); + + var invalid_reserved: [len]u8 = @splat(0); + std.mem.writeInt(u16, invalid_reserved[14..16], 1, .little); + var reserved_reader: std.Io.Reader = .fixed(&invalid_reserved); + try std.testing.expectError( + error.InvalidReserved, + decode(&reserved_reader), + ); +} + +test "decodeOrDiscard preserves the next entry boundary" { + var fixture: [len + 1]u8 = @splat(0); + fixture[0] = 3; + fixture[len] = 0xFF; + + var reader: std.Io.Reader = .fixed(&fixture); + try std.testing.expectError(error.InvalidColorKind, decodeOrDiscard(&reader)); + try std.testing.expectEqual(@as(u8, 0xFF), try reader.takeByte()); +} + +test "reject every truncation" { + const fixture = [_]u8{0} ** len; + for (0..len) |fixture_len| { + var reader: std.Io.Reader = .fixed(fixture[0..fixture_len]); + try std.testing.expectError(error.EndOfStream, decode(&reader)); + } +} diff --git a/src/terminal/snapshot/terminal.zig b/src/terminal/snapshot/terminal.zig new file mode 100644 index 000000000..f20b97707 --- /dev/null +++ b/src/terminal/snapshot/terminal.zig @@ -0,0 +1,1840 @@ +//! TERMINAL record payload encoding. +//! +//! One TERMINAL record contains terminal-wide state shared by every screen. It +//! is the first record in a snapshot and declares which SCREEN sequences follow +//! it. Screen contents, cursors, and history are encoded by `screen.zig` and +//! `history.zig`. +//! +//! Snapshot version 1 supports the primary screen and an optional alternate +//! screen. `screen_count` is therefore one or two. SCREEN records identify +//! their destination by key and may appear in either order. Canonical encoders +//! write primary first, then alternate when present. `active_screen_key` must +//! name one of those declared screens. +//! +//! All integers are unsigned and little-endian. +//! +//! ## Binary Format +//! +//! The payload begins with a fixed header followed by terminal-wide variable +//! state. The enclosing record supplies the payload boundary. +//! +//! ```text +//! 0 +--------------------------+ +//! | Header | +//! 103 +--------------------------+ +//! | Tab stops | +//! | ceil(columns / 8) bytes | +//! +--------------------------+ +//! | Original palette | +//! | 256 * RGB | +//! +--------------------------+ +//! | Palette override mask | +//! | 32 bytes | +//! +--------------------------+ +//! | Palette overrides | +//! | 3 * popcount(mask) bytes | +//! +--------------------------+ +//! | PWD length (u32) | +//! +--------------------------+ +//! | PWD bytes | +//! +--------------------------+ +//! | Title length (u32) | +//! +--------------------------+ +//! | Title bytes | +//! end +--------------------------+ +//! ``` +//! +//! ### Header +//! +//! ```text +//! 0 +-----------------------------------+ +//! | Columns (u16) | +//! 2 +-----------------------------------+ +//! | Rows (u16) | +//! 4 +-----------------------------------+ +//! | Pixel width (u32) | +//! 8 +-----------------------------------+ +//! | Pixel height (u32) | +//! 12 +-----------------------------------+ +//! | Scrolling-region top (u16) | +//! 14 +-----------------------------------+ +//! | Scrolling-region bottom (u16) | +//! 16 +-----------------------------------+ +//! | Scrolling-region left (u16) | +//! 18 +-----------------------------------+ +//! | Scrolling-region right (u16) | +//! 20 +-----------------------------------+ +//! | Status display (u8) | +//! 21 +-----------------------------------+ +//! | Active screen key (u16) | +//! 23 +-----------------------------------+ +//! | Screen count (u16) | +//! 25 +-----------------------------------+ +//! | Previous codepoint (u32) | +//! 29 +-----------------------------------+ +//! | Cursor is-default (u8) | +//! 30 +-----------------------------------+ +//! | Cursor default visual style (u8) | +//! 31 +-----------------------------------+ +//! | Cursor default blink policy (u8) | +//! 32 +-----------------------------------+ +//! | Shell-redraw policy (u8) | +//! 33 +-----------------------------------+ +//! | Modify-other-keys level 2 (u8) | +//! 34 +-----------------------------------+ +//! | Mouse event (u8) | +//! 35 +-----------------------------------+ +//! | Mouse format (u8) | +//! 36 +-----------------------------------+ +//! | Mouse shift-capture policy (u8) | +//! 37 +-----------------------------------+ +//! | Mouse shape (u8) | +//! 38 +-----------------------------------+ +//! | Password-input flag (u8) | +//! 39 +-----------------------------------+ +//! | Current modes (ModePacked) | +//! 47 +-----------------------------------+ +//! | Saved modes (ModePacked) | +//! 55 +-----------------------------------+ +//! | Default modes (ModePacked) | +//! 63 +-----------------------------------+ +//! | Background color (DynamicRGB) | +//! 71 +-----------------------------------+ +//! | Foreground color (DynamicRGB) | +//! 79 +-----------------------------------+ +//! | Cursor color (DynamicRGB) | +//! 87 +-----------------------------------+ +//! | Maximum scrollback bytes (u64) | +//! 95 +-----------------------------------+ +//! | Maximum scrollback rows (u64) | +//! 103 +-----------------------------------+ +//! ``` +//! +//! The previous codepoint is a Unicode scalar value or `0xffffffff` when there +//! is no previous character. Boolean fields are zero or one. +//! Cursor blink and mouse shift-capture policies encode null as zero, false as +//! one, and true as two. +//! Canonical encoders use only the documented enum and boolean values. +//! Decoders replace unknown semantic values with neutral native defaults, +//! discard invalid previous codepoints, and reset each invalid scrolling axis +//! to the terminal's full extent. Dimensions and screen count remain +//! structural. +//! +//! A scrollback limit of `0xffffffffffffffff` means unlimited. Every other +//! value is finite, including zero. The values are the source primary +//! PageList's explicit policies, not its dimension-adjusted effective limits. +//! Alternate-screen no-scrollback behavior is derived from the screen key. +//! +//! ### Tab stops +//! +//! One bit is encoded for each column, least-significant bit first within each +//! byte. Bit zero of the first byte is column zero. Unused high bits in the last +//! byte are canonically zero and ignored by decoders. +//! +//! ### Dynamic RGB +//! +//! The three terminal dynamic colors share this layout: +//! +//! ```text +//! 0 +----------------------------+ +//! | Default present (u8) | +//! 1 +----------------------------+ +//! | Default RGB | +//! 4 +----------------------------+ +//! | Override present (u8) | +//! 5 +----------------------------+ +//! | Override RGB | +//! 8 +----------------------------+ +//! ``` +//! +//! Presence values are zero or one. The corresponding RGB bytes must be zero +//! when a value is absent. Decoders treat every other presence value as absent +//! and ignore RGB bytes belonging to an absent value. +//! +//! ### Palette +//! +//! The complete original 256-color palette is encoded first in index order. +//! Each mask bit then states whether the current palette entry overrides that +//! original value. Bit zero of the first mask byte is palette index zero. +//! Override RGB values follow in increasing index order with no index or count +//! fields. +//! +//! ### Modes +//! +//! Current, saved, and default modes use the same stable bit registry: +//! +//! ```text +//! bit 0 disable_keyboard +//! bit 1 insert +//! bit 2 send_receive_mode +//! bit 3 linefeed +//! bit 4 cursor_keys +//! bit 5 132_column +//! bit 6 slow_scroll +//! bit 7 reverse_colors +//! bit 8 origin +//! bit 9 wraparound +//! bit 10 autorepeat +//! bit 11 mouse_event_x10 +//! bit 12 cursor_blinking +//! bit 13 cursor_visible +//! bit 14 enable_mode_3 +//! bit 15 reverse_wrap +//! bit 16 alt_screen_legacy +//! bit 17 keypad_keys +//! bit 18 backarrow_key_mode +//! bit 19 enable_left_and_right_margin +//! bit 20 mouse_event_normal +//! bit 21 mouse_event_button +//! bit 22 mouse_event_any +//! bit 23 focus_event +//! bit 24 mouse_format_utf8 +//! bit 25 mouse_format_sgr +//! bit 26 mouse_alternate_scroll +//! bit 27 mouse_format_urxvt +//! bit 28 mouse_format_sgr_pixels +//! bit 29 ignore_keypad_with_numlock +//! bit 30 alt_esc_prefix +//! bit 31 alt_sends_escape +//! bit 32 reverse_wrap_extended +//! bit 33 alt_screen +//! bit 34 save_cursor +//! bit 35 alt_screen_save_cursor_clear_enter +//! bit 36 bracketed_paste +//! bit 37 synchronized_output +//! bit 38 grapheme_cluster +//! bit 39 report_color_scheme +//! bit 40 report_visibility +//! bit 41 in_band_size_reports +//! bits 42-63 reserved, zero +//! ``` +//! +//! This is the packed field order of native `ModePacked`. Its layout is +//! well-defined and is the snapshot registry. Moving or adding a native mode +//! therefore requires a snapshot version bump. Decoders ignore reserved bits. +//! +//! ## Field classification +//! +//! The TERMINAL payload encodes terminal dimensions, pixel dimensions, +//! scrolling region, status display, tab stops, PWD, title, color state, +//! previous character, all three mode sets, cursor defaults, mouse behavior, +//! password-input state, source scrollback policies, the active screen key, and +//! the set of declared screens. +//! +//! SCREEN and HISTORY sequences encode each Screen's pages, cursor, saved +//! cursor, charset, protected mode, Kitty keyboard stack, semantic-click state, +//! and complete history. The semantic-prompt `seen` bit is derived from +//! restored resident content. +//! +//! Native allocators, I/O implementations, pools, pointers, page IDs, byte +//! accounting, tracked pins, and ScreenSet generations are reconstructed. +//! PageList row totals and the active viewport are derived from decoded pages. +//! The destination surface supplies its own focus state. Selections, scrolled +//! viewports, selection-scroll activity, dirty flags, search flags, hyperlink +//! hover state, and incremental compression state are presentation or cache +//! state and reset during restore. +//! +//! Kitty image state and glyph glossary registrations are unsupported by this +//! snapshot version and are ignored during capture. Unicode virtual placeholder +//! cells are preserved as grid content, but their image and placement state is +//! not restored. Build-time terminal behavior, Unicode width policy, parser +//! continuation, and external callbacks are version-level or caller-local +//! requirements. +//! +//! Native enum declaration indices and mode bit positions used by this format +//! are snapshot-version registries. Changing any of them requires a snapshot +//! version bump. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const test_fixture = @import("fixture.zig"); +const io = @import("io.zig"); +const record = @import("record.zig"); +const terminal_ansi = @import("../ansi.zig"); +const terminal_color = @import("../color.zig"); +const terminal_modes = @import("../modes.zig"); +const terminal_mouse = @import("../mouse.zig"); +const terminal_osc = @import("../osc.zig"); +const Terminal = @import("../Terminal.zig"); +const TerminalScreen = @import("../Screen.zig"); +const TerminalScreenKey = @import("../ScreenSet.zig").Key; +const TerminalTabstops = @import("../Tabstops.zig"); +const ModeBits = @typeInfo( + terminal_modes.ModePacked, +).@"struct".backing_integer.?; + +/// Reuse the terminal's semantic RGB value without adopting its memory layout +/// as a wire encoding. +pub const RGB = terminal_color.RGB; + +/// Reuse the terminal's semantic dynamic color without adopting its memory +/// layout as a wire encoding. +pub const DynamicRGB = terminal_color.DynamicRGB; + +const ValidationError = error{ + InvalidDimensions, + InvalidScrollingRegion, + InvalidScreenCount, + InvalidActiveScreenKey, + InvalidPreviousCodepoint, + InvalidScrollbackLimit, +}; + +const HeaderInitError = error{ + ScrollbackLimitOverflow, +}; + +/// Errors possible while encoding a TERMINAL payload. +const PayloadEncodeError = std.Io.Writer.Error || + ValidationError || + error{ + InvalidTabStops, + InvalidPalette, + StringTooLong, + }; + +/// Errors possible while decoding a TERMINAL payload. +const PayloadDecodeError = std.Io.Reader.Error || + Allocator.Error || + error{ + /// Terminal dimensions control every following native allocation. + InvalidDimensions, + + /// The count determines how many SCREEN and HISTORY sequences follow. + InvalidScreenCount, + }; + +/// The fixed fields at the start of every TERMINAL payload. +pub const Header = struct { + /// Number of bytes written by `encode`, calculated using the encoder. + pub const len = computeLen(); + + comptime { + std.debug.assert(len == 103); + } + + // Terminal geometry and its current scrolling region. + columns: u16, + rows: u16, + width_px: u32, + height_px: u32, + + scrolling_region_top: u16, + scrolling_region_bottom: u16, + scrolling_region_left: u16, + scrolling_region_right: u16, + + // Status display, screen routing, and character context. + status_display: terminal_ansi.StatusDisplay, + active_screen_key: TerminalScreenKey, + screen_count: u16, + previous_codepoint: ?u21, + + // Cursor presentation defaults shared by the screens. + cursor_is_default: bool, + cursor_default_style: TerminalScreen.CursorStyle, + cursor_default_blink: ?bool, + + // Terminal input, semantic redraw, and pointer behavior. + shell_redraw: terminal_osc.semantic_prompt.Redraw, + modify_other_keys_2: bool, + mouse_event: terminal_mouse.Event, + mouse_format: terminal_mouse.Format, + mouse_shift_capture: ?bool, + mouse_shape: terminal_mouse.Shape, + password_input: bool, + + // Runtime, saved, and reset mode sets. + current_modes: terminal_modes.ModePacked, + saved_modes: terminal_modes.ModePacked, + default_modes: terminal_modes.ModePacked, + + // Terminal-wide dynamic color state. + background: DynamicRGB, + foreground: DynamicRGB, + cursor_color: DynamicRGB, + + // Explicit primary-screen scrollback policies. + max_scrollback_bytes: ?u64, + max_scrollback_rows: ?u64, + + /// Capture the terminal-wide native state represented by this header. + fn init(terminal: *const Terminal) HeaderInitError!Header { + const primary = terminal.screens.get(.primary).?; + + return .{ + // Terminal geometry and its current scrolling region. + .columns = terminal.cols, + .rows = terminal.rows, + .width_px = terminal.width_px, + .height_px = terminal.height_px, + .scrolling_region_top = terminal.scrolling_region.top, + .scrolling_region_bottom = terminal.scrolling_region.bottom, + .scrolling_region_left = terminal.scrolling_region.left, + .scrolling_region_right = terminal.scrolling_region.right, + + // Status display, screen routing, and character context. + .status_display = terminal.status_display, + .active_screen_key = terminal.screens.active_key, + .screen_count = if (terminal.screens.get(.alternate) == null) + 1 + else + 2, + .previous_codepoint = terminal.previous_char, + + // Cursor presentation defaults shared by the screens. + .cursor_is_default = terminal.cursor.is_default, + .cursor_default_style = terminal.cursor.default_style, + .cursor_default_blink = terminal.cursor.default_blink, + + // Terminal input, semantic redraw, and pointer behavior. + .shell_redraw = terminal.flags.shell_redraws_prompt, + .modify_other_keys_2 = terminal.flags.modify_other_keys_2, + .mouse_event = terminal.flags.mouse_event, + .mouse_format = terminal.flags.mouse_format, + .mouse_shift_capture = switch (terminal.flags.mouse_shift_capture) { + .null => null, + .false => false, + .true => true, + }, + .mouse_shape = terminal.mouse_shape, + .password_input = terminal.flags.password_input, + + // Runtime, saved, and reset mode sets. + .current_modes = terminal.modes.values, + .saved_modes = terminal.modes.saved, + .default_modes = terminal.modes.default, + + // Terminal-wide dynamic color state. + .background = terminal.colors.background, + .foreground = terminal.colors.foreground, + .cursor_color = terminal.colors.cursor, + + // Explicit primary-screen scrollback policies. + .max_scrollback_bytes = try encodeScrollbackLimit( + primary.pages.limits.bytes.explicit, + ), + .max_scrollback_rows = try encodeScrollbackLimit( + primary.pages.limits.lines.explicit, + ), + }; + } + + /// Encode the fixed TERMINAL payload header field by field. + pub fn encode( + self: Header, + writer: *std.Io.Writer, + ) PayloadEncodeError!void { + try self.validate(); + + // Terminal geometry and its current scrolling region. + try io.writeInt(writer, u16, self.columns); + try io.writeInt(writer, u16, self.rows); + try io.writeInt(writer, u32, self.width_px); + try io.writeInt(writer, u32, self.height_px); + try io.writeInt(writer, u16, self.scrolling_region_top); + try io.writeInt(writer, u16, self.scrolling_region_bottom); + try io.writeInt(writer, u16, self.scrolling_region_left); + try io.writeInt(writer, u16, self.scrolling_region_right); + + // Status display, screen routing, and character context. + try writer.writeByte(@intCast(@intFromEnum(self.status_display))); + try io.writeInt( + writer, + u16, + @intCast(@intFromEnum(self.active_screen_key)), + ); + try io.writeInt(writer, u16, self.screen_count); + try io.writeInt( + writer, + u32, + if (self.previous_codepoint) |value| + @intCast(value) + else + std.math.maxInt(u32), + ); + + // Cursor presentation defaults shared by the screens. + try writer.writeByte(@intFromBool(self.cursor_is_default)); + try writer.writeByte( + @intCast(@intFromEnum(self.cursor_default_style)), + ); + try writer.writeByte(encodeOptionalBool(self.cursor_default_blink)); + + // Terminal input, semantic redraw, and pointer behavior. + try writer.writeByte(@intCast(@intFromEnum(self.shell_redraw))); + try writer.writeByte(@intFromBool(self.modify_other_keys_2)); + try writer.writeByte(@intCast(@intFromEnum(self.mouse_event))); + try writer.writeByte(@intCast(@intFromEnum(self.mouse_format))); + try writer.writeByte(encodeOptionalBool(self.mouse_shift_capture)); + try writer.writeByte(@intCast(@intFromEnum(self.mouse_shape))); + try writer.writeByte(@intFromBool(self.password_input)); + + // Runtime, saved, and reset mode sets. ModePacked occupies 41 bits; + // its eight-byte wire slots zero-extend the native packed value. + const mode_values = [_]terminal_modes.ModePacked{ + self.current_modes, + self.saved_modes, + self.default_modes, + }; + for (mode_values) |value| { + const bits: ModeBits = @bitCast(value); + try io.writeInt(writer, u64, @intCast(bits)); + } + + // Terminal-wide dynamic color state. + try encodeDynamicRGB(self.background, writer); + try encodeDynamicRGB(self.foreground, writer); + try encodeDynamicRGB(self.cursor_color, writer); + + // Explicit primary-screen scrollback policies. + try io.writeInt( + writer, + u64, + self.max_scrollback_bytes orelse std.math.maxInt(u64), + ); + try io.writeInt( + writer, + u64, + self.max_scrollback_rows orelse std.math.maxInt(u64), + ); + } + + /// Decode the fixed TERMINAL payload header, normalizing semantic values. + pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!Header { + // Terminal geometry and its current scrolling region. + const columns = try io.readInt(reader, u16); + const rows = try io.readInt(reader, u16); + const width_px = try io.readInt(reader, u32); + const height_px = try io.readInt(reader, u32); + const scrolling_region_top = try io.readInt(reader, u16); + const scrolling_region_bottom = try io.readInt(reader, u16); + const scrolling_region_left = try io.readInt(reader, u16); + const scrolling_region_right = try io.readInt(reader, u16); + + // Status display, screen routing, and character context. + const status_display = enumFromInt( + terminal_ansi.StatusDisplay, + try reader.takeByte(), + ) orelse .main; + const active_screen_key_raw = try io.readInt(reader, u16); + const screen_count = try io.readInt(reader, u16); + const previous_raw = try io.readInt(reader, u32); + const previous_codepoint: ?u21 = previous: { + if (previous_raw == std.math.maxInt(u32)) break :previous null; + const value = std.math.cast(u21, previous_raw) orelse + break :previous null; + if (value > 0x10FFFF or + (value >= 0xD800 and value <= 0xDFFF)) + { + break :previous null; + } + break :previous value; + }; + + // Cursor presentation defaults shared by the screens. + const cursor_is_default = switch (try reader.takeByte()) { + 0 => false, + 1 => true, + else => true, + }; + const cursor_default_style = enumFromInt( + TerminalScreen.CursorStyle, + try reader.takeByte(), + ) orelse .block; + const cursor_default_blink: ?bool = switch (try reader.takeByte()) { + 0 => null, + 1 => false, + 2 => true, + else => null, + }; + + // Terminal input, semantic redraw, and pointer behavior. + const shell_redraw = enumFromInt( + terminal_osc.semantic_prompt.Redraw, + try reader.takeByte(), + ) orelse .true; + const modify_other_keys_2 = switch (try reader.takeByte()) { + 0 => false, + 1 => true, + else => false, + }; + const mouse_event = enumFromInt( + terminal_mouse.Event, + try reader.takeByte(), + ) orelse .none; + const mouse_format = enumFromInt( + terminal_mouse.Format, + try reader.takeByte(), + ) orelse .x10; + const mouse_shift_capture: ?bool = switch (try reader.takeByte()) { + 0 => null, + 1 => false, + 2 => true, + else => null, + }; + const mouse_shape = enumFromInt( + terminal_mouse.Shape, + try reader.takeByte(), + ) orelse .text; + const password_input = switch (try reader.takeByte()) { + 0 => false, + 1 => true, + else => false, + }; + + // Runtime, saved, and reset mode sets. + var mode_values: [3]terminal_modes.ModePacked = undefined; + for (&mode_values) |*value| { + const raw = try io.readInt(reader, u64); + const bits: ModeBits = @truncate(raw); + value.* = @bitCast(bits); + } + + // Terminal-wide dynamic color state. + const background = try decodeDynamicRGB(reader); + const foreground = try decodeDynamicRGB(reader); + const cursor_color = try decodeDynamicRGB(reader); + + // Explicit primary-screen scrollback policies. + const max_scrollback_bytes = decodeOptionalLimit( + try io.readInt(reader, u64), + ); + const max_scrollback_rows = decodeOptionalLimit( + try io.readInt(reader, u64), + ); + + // Dimensions and sequence count are the only header fields that define + // following allocation or record boundaries. + if (columns == 0 or rows == 0) return error.InvalidDimensions; + if (screen_count != 1 and screen_count != 2) { + return error.InvalidScreenCount; + } + + const scrolling_region_vertical_valid = + scrolling_region_top <= scrolling_region_bottom and + scrolling_region_bottom < rows; + const scrolling_region_horizontal_valid = + scrolling_region_left <= scrolling_region_right and + scrolling_region_right < columns; + const active_screen_key: TerminalScreenKey = active: { + const decoded = enumFromInt( + TerminalScreenKey, + active_screen_key_raw, + ) orelse break :active .primary; + if (decoded == .alternate and screen_count != 2) { + break :active .primary; + } + break :active decoded; + }; + + const result: Header = .{ + .columns = columns, + .rows = rows, + .width_px = width_px, + .height_px = height_px, + + .scrolling_region_top = if (scrolling_region_vertical_valid) + scrolling_region_top + else + 0, + .scrolling_region_bottom = if (scrolling_region_vertical_valid) + scrolling_region_bottom + else + rows - 1, + .scrolling_region_left = if (scrolling_region_horizontal_valid) + scrolling_region_left + else + 0, + .scrolling_region_right = if (scrolling_region_horizontal_valid) + scrolling_region_right + else + columns - 1, + + .status_display = status_display, + .active_screen_key = active_screen_key, + .screen_count = screen_count, + .previous_codepoint = previous_codepoint, + + .cursor_is_default = cursor_is_default, + .cursor_default_style = cursor_default_style, + .cursor_default_blink = cursor_default_blink, + + .shell_redraw = shell_redraw, + .modify_other_keys_2 = modify_other_keys_2, + .mouse_event = mouse_event, + .mouse_format = mouse_format, + .mouse_shift_capture = mouse_shift_capture, + .mouse_shape = mouse_shape, + .password_input = password_input, + + .current_modes = mode_values[0], + .saved_modes = mode_values[1], + .default_modes = mode_values[2], + + .background = background, + .foreground = foreground, + .cursor_color = cursor_color, + + .max_scrollback_bytes = max_scrollback_bytes, + .max_scrollback_rows = max_scrollback_rows, + }; + return result; + } + + fn validate(self: Header) ValidationError!void { + if (self.columns == 0 or self.rows == 0) { + return error.InvalidDimensions; + } + if (self.scrolling_region_top > self.scrolling_region_bottom or + self.scrolling_region_bottom >= self.rows or + self.scrolling_region_left > self.scrolling_region_right or + self.scrolling_region_right >= self.columns) + { + return error.InvalidScrollingRegion; + } + + if (self.screen_count != 1 and self.screen_count != 2) { + return error.InvalidScreenCount; + } + if (self.active_screen_key == .alternate and self.screen_count != 2) { + return error.InvalidActiveScreenKey; + } + + if (self.previous_codepoint) |value| { + if (value > 0x10FFFF or + (value >= 0xD800 and value <= 0xDFFF)) + { + return error.InvalidPreviousCodepoint; + } + } + + if (self.max_scrollback_bytes == std.math.maxInt(u64) or + self.max_scrollback_rows == std.math.maxInt(u64)) + { + return error.InvalidScrollbackLimit; + } + } + + fn computeLen() usize { + comptime { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + const value: Header = .{ + .columns = 1, + .rows = 1, + .width_px = 0, + .height_px = 0, + .scrolling_region_top = 0, + .scrolling_region_bottom = 0, + .scrolling_region_left = 0, + .scrolling_region_right = 0, + .status_display = .main, + .active_screen_key = .primary, + .screen_count = 1, + .previous_codepoint = null, + .cursor_is_default = true, + .cursor_default_style = .block, + .cursor_default_blink = null, + .shell_redraw = .true, + .modify_other_keys_2 = false, + .mouse_event = .none, + .mouse_format = .x10, + .mouse_shift_capture = null, + .mouse_shape = .text, + .password_input = false, + .current_modes = .{}, + .saved_modes = .{}, + .default_modes = .{}, + .background = .unset, + .foreground = .unset, + .cursor_color = .unset, + .max_scrollback_bytes = null, + .max_scrollback_rows = null, + }; + value.encode(&writer) catch unreachable; + return writer.end; + } + } +}; + +/// Allocator-owned semantic state decoded from one TERMINAL payload. +/// +/// The palette and fixed header are values. `tabstops`, `pwd`, and `title` +/// own allocations and are released by `deinit`. +const DecodedPayload = struct { + header: Header, + tabstops: TerminalTabstops, + palette: terminal_color.DynamicPalette, + pwd: []u8, + title: []u8, + + fn deinit(self: *DecodedPayload, alloc: Allocator) void { + self.tabstops.deinit(alloc); + alloc.free(self.pwd); + alloc.free(self.title); + self.* = undefined; + } +}; + +/// Encode one complete TERMINAL payload without record framing. +fn encodePayload( + header: Header, + tabstops: *const TerminalTabstops, + palette: *const terminal_color.DynamicPalette, + pwd: []const u8, + title: []const u8, + writer: *std.Io.Writer, +) PayloadEncodeError!void { + // Validate the complete semantic value before writing any bytes. + try header.validate(); + if (tabstops.cols != @as(usize, header.columns)) { + return error.InvalidTabStops; + } + if (pwd.len > std.math.maxInt(u32) or + title.len > std.math.maxInt(u32)) + { + return error.StringTooLong; + } + for (palette.original, palette.current, 0..) |original, current, index| { + if (!palette.mask.isSet(index) and !original.eql(current)) { + return error.InvalidPalette; + } + } + + try header.encode(writer); + + // Tab stops are packed least-significant bit first. + const tabstop_len = (@as(usize, header.columns) + 7) / 8; + for (0..tabstop_len) |byte_index| { + var raw: u8 = 0; + for (0..8) |bit| { + const column = byte_index * 8 + bit; + if (column >= header.columns) break; + if (tabstops.get(column)) { + raw |= @as(u8, 1) << @intCast(bit); + } + } + try writer.writeByte(raw); + } + + // Encode the original palette, then a compact mask and only the active + // overrides in increasing palette-index order. + for (palette.original) |value| try encodeRGB(value, writer); + for (0..32) |byte_index| { + var raw: u8 = 0; + for (0..8) |bit| { + if (palette.mask.isSet(byte_index * 8 + bit)) { + raw |= @as(u8, 1) << @intCast(bit); + } + } + try writer.writeByte(raw); + } + for (palette.current, 0..) |value, index| { + if (palette.mask.isSet(index)) try encodeRGB(value, writer); + } + + // Length-prefixed terminal strings end the payload. + try io.writeInt(writer, u32, @intCast(pwd.len)); + try writer.writeAll(pwd); + try io.writeInt(writer, u32, @intCast(title.len)); + try writer.writeAll(title); +} + +/// Decode one complete allocator-owned TERMINAL payload. +/// +/// The enclosing record reader remains responsible for verifying exact payload +/// exhaustion. On success, the caller owns the result and must call `deinit`. +fn decodePayload( + reader: *std.Io.Reader, + alloc: Allocator, +) PayloadDecodeError!DecodedPayload { + const header = try Header.decode(reader); + + // Restore the native tab-stop representation from its packed wire bits. + var tabstops = try TerminalTabstops.init(alloc, header.columns, 0); + errdefer tabstops.deinit(alloc); + const tabstop_len = (@as(usize, header.columns) + 7) / 8; + for (0..tabstop_len) |byte_index| { + const raw = try reader.takeByte(); + for (0..8) |bit| { + const mask = @as(u8, 1) << @intCast(bit); + const column = byte_index * 8 + bit; + if (column < header.columns and raw & mask != 0) { + tabstops.set(column); + } + } + } + + // Reconstruct the dynamic palette from its original values and sparse + // current-value overrides. + var original: terminal_color.Palette = undefined; + for (&original) |*value| value.* = try decodeRGB(reader); + var override_mask: [32]u8 = undefined; + try reader.readSliceAll(&override_mask); + + var palette: terminal_color.DynamicPalette = .init(original); + for (0..256) |index| { + const mask = @as(u8, 1) << @intCast(index % 8); + if (override_mask[index / 8] & mask != 0) { + palette.set(@intCast(index), try decodeRGB(reader)); + } + } + + // Read the two allocator-owned terminal strings last so all earlier + // fixed-size validation happens before allocating them. + const pwd_len: usize = @intCast(try io.readInt(reader, u32)); + const pwd = try alloc.alloc(u8, pwd_len); + errdefer alloc.free(pwd); + try reader.readSliceAll(pwd); + + const title_len: usize = @intCast(try io.readInt(reader, u32)); + const title = try alloc.alloc(u8, title_len); + errdefer alloc.free(title); + try reader.readSliceAll(title); + + return .{ + .header = header, + .tabstops = tabstops, + .palette = palette, + .pwd = pwd, + .title = title, + }; +} + +/// Errors possible while encoding one native TERMINAL record. +pub const EncodeError = HeaderInitError || + PayloadEncodeError || + record.Writer.FinishError; + +/// Encode terminal-wide native state as one framed TERMINAL record. +/// +/// State not represented by this snapshot version is ignored. Payload failures +/// emit no part of the TERMINAL record. +pub fn encode( + terminal: *const Terminal, + destination: *record.Writer, +) EncodeError!void { + const header = try Header.init(terminal); + + const payload = destination.begin(.terminal); + errdefer destination.cancel(); + try encodePayload( + header, + &terminal.tabstops, + &terminal.colors.palette, + terminal.getPwd() orelse "", + terminal.getTitle() orelse "", + payload, + ); + try destination.finish(); +} + +/// Errors possible while decoding one native TERMINAL record. +pub const DecodeError = PayloadDecodeError || + record.Reader.InitError || + record.Reader.FinishError || + error{ + /// The next record is valid but is not a TERMINAL. + UnexpectedRecordTag, + }; + +/// Decode one framed TERMINAL record directly into native terminal state. +/// +/// The returned terminal owns every decoded allocation and contains empty +/// primary and optional alternate screens with the declared routing. Later +/// SCREEN records replace those screens in place. On failure, all decoded and +/// partially initialized native state is released. +pub fn decode( + source: *std.Io.Reader, + io_: std.Io, + alloc: Allocator, +) DecodeError!Terminal { + // Finish the complete record before constructing native terminal state. + var payload: DecodedPayload = payload: { + var record_reader: record.Reader = undefined; + try record_reader.init(source); + if (record_reader.header.tag != .terminal) { + return error.UnexpectedRecordTag; + } + + var payload = try decodePayload(record_reader.payloadReader(), alloc); + errdefer payload.deinit(alloc); + try record_reader.finish(); + break :payload payload; + }; + defer payload.deinit(alloc); + + const header = payload.header; + const max_scrollback_bytes = nativeScrollbackLimit( + header.max_scrollback_bytes, + ); + const max_scrollback_lines = nativeScrollbackLimit( + header.max_scrollback_rows, + ); + + // Terminal.init establishes native pools, pins, defaults, and ownership. + var result = try Terminal.init(io_, alloc, .{ + .cols = header.columns, + .rows = header.rows, + .max_scrollback_bytes = max_scrollback_bytes, + .max_scrollback_lines = max_scrollback_lines, + .colors = .{ + .background = header.background, + .foreground = header.foreground, + .cursor = header.cursor_color, + .palette = payload.palette, + }, + .default_modes = header.default_modes, + .default_cursor_style = header.cursor_default_style, + .default_cursor_blink = header.cursor_default_blink, + }); + errdefer result.deinit(alloc); + + // Recreate the optional screen now so ScreenSet routing is complete before + // its empty screens are replaced by the following SCREEN records. + if (header.screen_count == 2) { + _ = try result.screens.getInit(io_, alloc, .alternate, .{ + .cols = header.columns, + .rows = header.rows, + .max_scrollback_bytes = 0, + }); + } + result.screens.switchTo(header.active_screen_key); + + // Restore terminal-wide runtime state that is not an initialization + // policy. Presentation-only flags retain Terminal.init defaults. + result.width_px = header.width_px; + result.height_px = header.height_px; + result.scrolling_region = .{ + .top = header.scrolling_region_top, + .bottom = header.scrolling_region_bottom, + .left = header.scrolling_region_left, + .right = header.scrolling_region_right, + }; + result.status_display = header.status_display; + result.previous_char = header.previous_codepoint; + result.cursor = .{ + .is_default = header.cursor_is_default, + .default_style = header.cursor_default_style, + .default_blink = header.cursor_default_blink, + }; + result.modes = .{ + .values = header.current_modes, + .saved = header.saved_modes, + .default = header.default_modes, + }; + result.flags.shell_redraws_prompt = header.shell_redraw; + result.flags.modify_other_keys_2 = header.modify_other_keys_2; + result.flags.mouse_event = header.mouse_event; + result.flags.mouse_format = header.mouse_format; + result.flags.mouse_shift_capture = if (header.mouse_shift_capture) |value| + if (value) .true else .false + else + .null; + result.flags.password_input = header.password_input; + result.mouse_shape = header.mouse_shape; + + // Native strings retain trailing NUL sentinels. Setters construct those + // without exposing that detail in the wire format. + try result.setPwd(payload.pwd); + try result.setTitle(payload.title); + + // Transfer decoded tab-stop ownership last. The deferred payload cleanup + // releases the table originally allocated by Terminal.init. + const initialized_tabstops = result.tabstops; + result.tabstops = payload.tabstops; + payload.tabstops = initialized_tabstops; + + result.screens.active.pages.assertIntegrity(); + return result; +} + +fn enumFromInt(comptime T: type, raw: anytype) ?T { + const Tag = @typeInfo(T).@"enum".tag_type; + const value = std.math.cast(Tag, raw) orelse return null; + return std.enums.fromInt(T, value); +} + +fn encodeOptionalBool(value: ?bool) u8 { + return if (value) |present| + if (present) 2 else 1 + else + 0; +} + +fn encodeRGB( + value: RGB, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { + try writer.writeByte(value.r); + try writer.writeByte(value.g); + try writer.writeByte(value.b); +} + +fn decodeRGB(reader: *std.Io.Reader) std.Io.Reader.Error!RGB { + return .{ + .r = try reader.takeByte(), + .g = try reader.takeByte(), + .b = try reader.takeByte(), + }; +} + +fn encodeDynamicRGB( + value: DynamicRGB, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { + if (value.default) |rgb| { + try writer.writeByte(1); + try encodeRGB(rgb, writer); + } else { + try writer.writeByte(0); + try writer.splatByteAll(0, 3); + } + + if (value.override) |rgb| { + try writer.writeByte(1); + try encodeRGB(rgb, writer); + } else { + try writer.writeByte(0); + try writer.splatByteAll(0, 3); + } +} + +fn decodeDynamicRGB( + reader: *std.Io.Reader, +) std.Io.Reader.Error!DynamicRGB { + const default_present = try reader.takeByte(); + const default_rgb = try decodeRGB(reader); + const default: ?RGB = if (default_present == 1) default_rgb else null; + + const override_present = try reader.takeByte(); + const override_rgb = try decodeRGB(reader); + const override: ?RGB = if (override_present == 1) override_rgb else null; + + return .{ .default = default, .override = override }; +} + +fn decodeOptionalLimit(raw: u64) ?u64 { + return if (raw == std.math.maxInt(u64)) null else raw; +} + +fn encodeScrollbackLimit(value: usize) HeaderInitError!?u64 { + if (value == std.math.maxInt(usize)) return null; + const result = std.math.cast(u64, value) orelse + return error.ScrollbackLimitOverflow; + if (result == std.math.maxInt(u64)) { + return error.ScrollbackLimitOverflow; + } + return result; +} + +fn nativeScrollbackLimit( + value: ?u64, +) ?usize { + const present = value orelse return null; + return @intCast(@min( + present, + @as(u64, std.math.maxInt(usize) - 1), + )); +} + +const test_header: Header = header: { + var current_modes = std.mem.zeroes(terminal_modes.ModePacked); + current_modes.disable_keyboard = true; + var saved_modes = std.mem.zeroes(terminal_modes.ModePacked); + saved_modes.in_band_size_reports = true; + var default_modes = current_modes; + default_modes.in_band_size_reports = true; + + break :header .{ + .columns = 0x0102, + .rows = 0x0304, + .width_px = 0x05060708, + .height_px = 0x090a0b0c, + + .scrolling_region_top = 1, + .scrolling_region_bottom = 2, + .scrolling_region_left = 3, + .scrolling_region_right = 4, + + .status_display = .status_line, + .active_screen_key = .alternate, + .screen_count = 2, + .previous_codepoint = 'A', + + .cursor_is_default = true, + .cursor_default_style = .block_hollow, + .cursor_default_blink = true, + + .shell_redraw = .last, + .modify_other_keys_2 = true, + .mouse_event = .any, + .mouse_format = .sgr_pixels, + .mouse_shift_capture = false, + .mouse_shape = .zoom_out, + .password_input = true, + + .current_modes = current_modes, + .saved_modes = saved_modes, + .default_modes = default_modes, + + .background = .{ + .default = .{ .r = 1, .g = 2, .b = 3 }, + .override = null, + }, + .foreground = .{ + .default = null, + .override = .{ .r = 4, .g = 5, .b = 6 }, + }, + .cursor_color = .{ + .default = .{ .r = 7, .g = 8, .b = 9 }, + .override = .{ .r = 10, .g = 11, .b = 12 }, + }, + + .max_scrollback_bytes = null, + .max_scrollback_rows = 0x0102030405060708, + }; +}; + +const test_header_fixture = test_fixture.parse( + @embedFile("testdata/terminal-header-v1.hex"), +); + +test "TERMINAL mode bit layout" { + try std.testing.expectEqual( + @as(usize, 42), + @bitSizeOf(terminal_modes.ModePacked), + ); + + var first: terminal_modes.ModePacked = std.mem.zeroes( + terminal_modes.ModePacked, + ); + first.disable_keyboard = true; + try std.testing.expectEqual( + @as(u42, 1) << 0, + @as(u42, @bitCast(first)), + ); + + var visibility: terminal_modes.ModePacked = std.mem.zeroes( + terminal_modes.ModePacked, + ); + visibility.report_visibility = true; + try std.testing.expectEqual( + @as(u42, 1) << 40, + @as(u42, @bitCast(visibility)), + ); + + var last: terminal_modes.ModePacked = std.mem.zeroes( + terminal_modes.ModePacked, + ); + last.in_band_size_reports = true; + try std.testing.expectEqual( + @as(u42, 1) << 41, + @as(u42, @bitCast(last)), + ); +} + +test "TERMINAL header golden encoding and decoding" { + const testing = std.testing; + + var encoded: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try test_header.encode(&writer); + try test_fixture.expectEqual( + .bytes, + "src/terminal/snapshot/testdata/terminal-header-v1.hex", + "snapshot_fixture-terminal-header-v1.hex", + &test_header_fixture, + writer.buffered(), + ); + try testing.expectEqual(Header.len, test_header_fixture.len); + + // Exercise the streaming path with less buffered data than every integer. + var source: std.Io.Reader = .fixed(&test_header_fixture); + var buffer: [1]u8 = undefined; + var limited = source.limited(.unlimited, &buffer); + try testing.expectEqualDeep( + test_header, + try Header.decode(&limited.interface), + ); + + for (0..Header.len) |fixture_len| { + var truncated: std.Io.Reader = .fixed( + test_header_fixture[0..fixture_len], + ); + try testing.expectError( + error.EndOfStream, + Header.decode(&truncated), + ); + } +} + +test "TERMINAL header encoding rejects noncanonical values" { + const testing = std.testing; + + // Semantic values are validated before encoding writes anything. + var invalid_header = test_header; + invalid_header.columns = 0; + var encoded: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try testing.expectError( + error.InvalidDimensions, + invalid_header.encode(&writer), + ); + try testing.expectEqual(@as(usize, 0), writer.end); + + invalid_header = test_header; + invalid_header.max_scrollback_bytes = std.math.maxInt(u64); + writer = .fixed(&encoded); + try testing.expectError( + error.InvalidScrollbackLimit, + invalid_header.encode(&writer), + ); + try testing.expectEqual(@as(usize, 0), writer.end); +} + +test "TERMINAL header decoding rejects structural values" { + const testing = std.testing; + + var invalid_dimensions = test_header_fixture; + invalid_dimensions[0] = 0; + invalid_dimensions[1] = 0; + var dimensions_reader: std.Io.Reader = .fixed(&invalid_dimensions); + try testing.expectError( + error.InvalidDimensions, + Header.decode(&dimensions_reader), + ); + + var invalid_screen_count = test_header_fixture; + invalid_screen_count[23] = 3; + var screen_count_reader: std.Io.Reader = .fixed(&invalid_screen_count); + try testing.expectError( + error.InvalidScreenCount, + Header.decode(&screen_count_reader), + ); +} + +test "TERMINAL header decoding normalizes semantic values" { + const testing = std.testing; + + var fixture = test_header_fixture; + + // Unknown enum and boolean encodings use their neutral native defaults. + fixture[20] = 2; + fixture[21] = 2; + fixture[22] = 0; + fixture[29] = 2; + fixture[30] = 4; + fixture[31] = 3; + fixture[32] = 3; + fixture[33] = 2; + fixture[34] = 5; + fixture[35] = 5; + fixture[36] = 3; + fixture[37] = 34; + fixture[38] = 2; + + // Reserved mode bits are ignored while known low bits survive. + fixture[44] = 4; + + // Unknown presence and nonzero absent-value bytes both become absent. + fixture[63] = 2; + fixture[68] = 1; + fixture[72] = 0xAA; + + // An invalid scrolling region becomes the full terminal region. + fixture[14] = 0x04; + fixture[15] = 0x03; + + // Surrogates cannot become native previous-character state. + fixture[25] = 0x00; + fixture[26] = 0xD8; + fixture[27] = 0x00; + fixture[28] = 0x00; + + var reader: std.Io.Reader = .fixed(&fixture); + const decoded = try Header.decode(&reader); + try testing.expectEqual(terminal_ansi.StatusDisplay.main, decoded.status_display); + try testing.expectEqual(TerminalScreenKey.primary, decoded.active_screen_key); + try testing.expectEqual(@as(u16, 0), decoded.scrolling_region_top); + try testing.expectEqual(test_header.rows - 1, decoded.scrolling_region_bottom); + try testing.expectEqual(test_header.scrolling_region_left, decoded.scrolling_region_left); + try testing.expectEqual(test_header.scrolling_region_right, decoded.scrolling_region_right); + try testing.expectEqual(null, decoded.previous_codepoint); + try testing.expect(decoded.cursor_is_default); + try testing.expectEqual(TerminalScreen.CursorStyle.block, decoded.cursor_default_style); + try testing.expectEqual(null, decoded.cursor_default_blink); + try testing.expectEqual(terminal_osc.semantic_prompt.Redraw.true, decoded.shell_redraw); + try testing.expect(!decoded.modify_other_keys_2); + try testing.expectEqual(terminal_mouse.Event.none, decoded.mouse_event); + try testing.expectEqual(terminal_mouse.Format.x10, decoded.mouse_format); + try testing.expectEqual(null, decoded.mouse_shift_capture); + try testing.expectEqual(terminal_mouse.Shape.text, decoded.mouse_shape); + try testing.expect(!decoded.password_input); + try testing.expect(decoded.current_modes.disable_keyboard); + try testing.expectEqualDeep(DynamicRGB.unset, decoded.background); + try testing.expectEqual(null, decoded.foreground.default); + try testing.expectEqual( + RGB{ .r = 4, .g = 5, .b = 6 }, + decoded.foreground.override.?, + ); + + // Each scrolling axis is normalized independently. + var invalid_horizontal = test_header_fixture; + invalid_horizontal[18] = 0x02; + invalid_horizontal[19] = 0x01; + var horizontal_reader: std.Io.Reader = .fixed(&invalid_horizontal); + const horizontal = try Header.decode(&horizontal_reader); + try testing.expectEqual( + test_header.scrolling_region_top, + horizontal.scrolling_region_top, + ); + try testing.expectEqual( + test_header.scrolling_region_bottom, + horizontal.scrolling_region_bottom, + ); + try testing.expectEqual(@as(u16, 0), horizontal.scrolling_region_left); + try testing.expectEqual( + test_header.columns - 1, + horizontal.scrolling_region_right, + ); + + // Alternate cannot be active when only the primary screen is declared. + var missing_active_screen = test_header_fixture; + missing_active_screen[23] = 1; + var active_screen_reader: std.Io.Reader = .fixed(&missing_active_screen); + try testing.expectEqual( + TerminalScreenKey.primary, + (try Header.decode(&active_screen_reader)).active_screen_key, + ); + + const largest_finite = std.math.maxInt(u64) - 1; + try testing.expectEqual( + @as(usize, @intCast(@min( + largest_finite, + @as(u64, std.math.maxInt(usize) - 1), + ))), + nativeScrollbackLimit(largest_finite).?, + ); +} + +test "TERMINAL payload round trip" { + const testing = std.testing; + + // Include a partial final tab-stop byte so its bit order and padding are + // both exercised. + var tabstops = try TerminalTabstops.init( + testing.allocator, + test_header.columns, + 0, + ); + defer tabstops.deinit(testing.allocator); + tabstops.set(0); + tabstops.set(8); + tabstops.set(test_header.columns - 1); + + // Use overrides at both ends of the palette to make ordering explicit. + var palette: terminal_color.DynamicPalette = .init( + terminal_color.default, + ); + palette.set(0, .{ .r = 1, .g = 2, .b = 3 }); + palette.set(255, .{ .r = 4, .g = 5, .b = 6 }); + + var destination: std.Io.Writer.Allocating = .init(testing.allocator); + defer destination.deinit(); + try encodePayload( + test_header, + &tabstops, + &palette, + "file:///tmp/example", + "snapshot title", + &destination.writer, + ); + + // Tab stops and the sparse palette mask both use least-significant-bit + // first ordering. + const bytes = destination.written(); + const tabstop_len = (@as(usize, test_header.columns) + 7) / 8; + try testing.expectEqual(@as(u8, 0x01), bytes[Header.len]); + try testing.expectEqual(@as(u8, 0x01), bytes[Header.len + 1]); + try testing.expectEqual( + @as(u8, 0x02), + bytes[Header.len + tabstop_len - 1], + ); + const palette_mask_offset = Header.len + tabstop_len + 256 * 3; + try testing.expectEqual(@as(u8, 0x01), bytes[palette_mask_offset]); + try testing.expectEqual( + @as(u8, 0x80), + bytes[palette_mask_offset + 31], + ); + + // Decode through a one-byte reader buffer to cover streaming of the large + // fixed palette and allocator-owned strings. + var source: std.Io.Reader = .fixed(bytes); + var buffer: [1]u8 = undefined; + var limited = source.limited(.unlimited, &buffer); + var decoded = try decodePayload( + &limited.interface, + testing.allocator, + ); + defer decoded.deinit(testing.allocator); + + try testing.expectEqualDeep(test_header, decoded.header); + for (0..test_header.columns) |column| { + try testing.expectEqual( + tabstops.get(column), + decoded.tabstops.get(column), + ); + } + try testing.expectEqualDeep(palette, decoded.palette); + try testing.expectEqualStrings("file:///tmp/example", decoded.pwd); + try testing.expectEqualStrings("snapshot title", decoded.title); +} + +test "TERMINAL payload rejects noncanonical state" { + const testing = std.testing; + + // A tab-stop set for different dimensions cannot represent this header. + var tabstops = try TerminalTabstops.init( + testing.allocator, + test_header.columns - 1, + 0, + ); + defer tabstops.deinit(testing.allocator); + var palette: terminal_color.DynamicPalette = .init( + terminal_color.default, + ); + var encoded: [2048]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try testing.expectError( + error.InvalidTabStops, + encodePayload( + test_header, + &tabstops, + &palette, + "", + "", + &writer, + ), + ); + try testing.expectEqual(@as(usize, 0), writer.end); + + // Unmasked current colors must remain equal to their originals. + tabstops.cols = test_header.columns; + palette.current[7] = .{ .r = 1, .g = 2, .b = 3 }; + writer = .fixed(&encoded); + try testing.expectError( + error.InvalidPalette, + encodePayload( + test_header, + &tabstops, + &palette, + "", + "", + &writer, + ), + ); + try testing.expectEqual(@as(usize, 0), writer.end); +} + +test "TERMINAL payload ignores tab-stop padding" { + const testing = std.testing; + + var tabstops = try TerminalTabstops.init( + testing.allocator, + test_header.columns, + 0, + ); + defer tabstops.deinit(testing.allocator); + var palette: terminal_color.DynamicPalette = .init( + terminal_color.default, + ); + palette.set(1, .{ .r = 1, .g = 2, .b = 3 }); + + var destination: std.Io.Writer.Allocating = .init(testing.allocator); + defer destination.deinit(); + try encodePayload( + test_header, + &tabstops, + &palette, + "pwd", + "title", + &destination.writer, + ); + + // Only two bits in the last byte correspond to terminal columns. + const tabstop_len = (@as(usize, test_header.columns) + 7) / 8; + const last_tabstop = Header.len + tabstop_len - 1; + const invalid_padding = try testing.allocator.dupe( + u8, + destination.written(), + ); + defer testing.allocator.free(invalid_padding); + invalid_padding[last_tabstop] |= 1 << 7; + var padding_reader: std.Io.Reader = .fixed(invalid_padding); + var decoded = try decodePayload( + &padding_reader, + testing.allocator, + ); + defer decoded.deinit(testing.allocator); + for (0..test_header.columns) |column| { + try testing.expectEqual( + tabstops.get(column), + decoded.tabstops.get(column), + ); + } +} + +test "TERMINAL record encodes native terminal state" { + const testing = std.testing; + + var terminal = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 10, + .rows = 3, + .max_scrollback_bytes = 12_345, + .max_scrollback_lines = 67, + }); + defer terminal.deinit(testing.allocator); + + // Exercise every native header section plus sentinel-backed strings. + terminal.width_px = 800; + terminal.height_px = 600; + terminal.scrolling_region = .{ + .top = 1, + .bottom = 2, + .left = 2, + .right = 8, + }; + terminal.status_display = .status_line; + terminal.previous_char = 'X'; + terminal.cursor = .{ + .is_default = false, + .default_style = .underline, + .default_blink = true, + }; + terminal.flags.shell_redraws_prompt = .last; + terminal.flags.modify_other_keys_2 = true; + terminal.flags.mouse_event = .button; + terminal.flags.mouse_format = .sgr; + terminal.flags.mouse_shift_capture = .true; + terminal.flags.password_input = true; + terminal.mouse_shape = .pointer; + terminal.modes.values.disable_keyboard = true; + terminal.modes.saved.in_band_size_reports = true; + terminal.modes.default.bracketed_paste = true; + terminal.colors.background = .{ + .default = .{ .r = 1, .g = 2, .b = 3 }, + .override = .{ .r = 4, .g = 5, .b = 6 }, + }; + terminal.colors.palette.set(7, .{ .r = 7, .g = 8, .b = 9 }); + terminal.tabstops.reset(0); + terminal.tabstops.set(1); + terminal.tabstops.set(9); + try terminal.setPwd("file:///tmp/native"); + try terminal.setTitle("native title"); + + // Encode and restore through the public record codec. + var destination: std.Io.Writer.Allocating = .init(testing.allocator); + defer destination.deinit(); + var stream: record.Writer = .init( + testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + try encode(&terminal, &stream); + + var source: std.Io.Reader = .fixed(destination.written()); + var restored = try decode( + &source, + testing.io, + testing.allocator, + ); + defer restored.deinit(testing.allocator); + + try testing.expectEqual(@as(u16, 10), restored.cols); + try testing.expectEqual(@as(u16, 3), restored.rows); + try testing.expectEqual(@as(u32, 800), restored.width_px); + try testing.expectEqual(@as(u32, 600), restored.height_px); + try testing.expectEqual(terminal.scrolling_region, restored.scrolling_region); + try testing.expectEqual(terminal.status_display, restored.status_display); + try testing.expectEqual(.primary, restored.screens.active_key); + try testing.expect(restored.screens.get(.alternate) == null); + try testing.expectEqual(@as(?u21, 'X'), restored.previous_char); + try testing.expectEqualDeep(terminal.cursor, restored.cursor); + try testing.expectEqual( + terminal.flags.shell_redraws_prompt, + restored.flags.shell_redraws_prompt, + ); + try testing.expectEqual( + terminal.flags.modify_other_keys_2, + restored.flags.modify_other_keys_2, + ); + try testing.expectEqual( + terminal.flags.mouse_event, + restored.flags.mouse_event, + ); + try testing.expectEqual( + terminal.flags.mouse_format, + restored.flags.mouse_format, + ); + try testing.expectEqual( + terminal.flags.mouse_shift_capture, + restored.flags.mouse_shift_capture, + ); + try testing.expectEqual( + terminal.flags.password_input, + restored.flags.password_input, + ); + try testing.expectEqual(terminal.mouse_shape, restored.mouse_shape); + try testing.expectEqualDeep(terminal.modes, restored.modes); + try testing.expectEqualDeep(terminal.colors, restored.colors); + try testing.expectEqual( + @as(usize, 12_345), + restored.screens.active.pages.limits.bytes.explicit, + ); + try testing.expectEqual( + @as(usize, 67), + restored.screens.active.pages.limits.lines.explicit, + ); + try testing.expect(restored.tabstops.get(1)); + try testing.expect(restored.tabstops.get(9)); + try testing.expectEqualStrings( + "file:///tmp/native", + restored.getPwd().?, + ); + try testing.expectEqualStrings("native title", restored.getTitle().?); +} + +test "TERMINAL record declares an initialized alternate screen" { + const testing = std.testing; + + var terminal = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 10, + .rows = 3, + }); + defer terminal.deinit(testing.allocator); + _ = try terminal.switchScreen(.alternate); + + var destination: std.Io.Writer.Allocating = .init(testing.allocator); + defer destination.deinit(); + var stream: record.Writer = .init( + testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + try encode(&terminal, &stream); + + var source: std.Io.Reader = .fixed(destination.written()); + var restored = try decode( + &source, + testing.io, + testing.allocator, + ); + defer restored.deinit(testing.allocator); + + const primary = restored.screens.get(.primary).?; + const alternate = restored.screens.get(.alternate).?; + try testing.expectEqual(.alternate, restored.screens.active_key); + try testing.expectEqual(alternate, restored.screens.active); + try testing.expect(primary != alternate); + try testing.expectEqual(@as(u16, 10), alternate.pages.cols); + try testing.expectEqual(@as(u16, 3), alternate.pages.rows); + try testing.expectEqual( + @as(usize, 0), + alternate.pages.limits.bytes.explicit, + ); +} + +test "TERMINAL payload failure emits no incomplete record" { + const testing = std.testing; + + var terminal = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 10, + .rows = 3, + }); + defer terminal.deinit(testing.allocator); + + var destination: std.Io.Writer.Allocating = .init(testing.allocator); + defer destination.deinit(); + try destination.writer.writeAll("prefix"); + var stream: record.Writer = .init( + testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + + // Payload validation occurs in the reusable record scratch buffer. The + // invalid TERMINAL is never emitted to the destination. + terminal.colors.palette.current[7] = .{ .r = 1, .g = 2, .b = 3 }; + try testing.expectError( + error.InvalidPalette, + encode(&terminal, &stream), + ); + try testing.expectEqualStrings("prefix", destination.written()); +} + +test "TERMINAL record decoding rejects malformed input transactionally" { + const testing = std.testing; + + // A valid record of another type is not accepted as TERMINAL state. + var wrong_tag: std.Io.Writer.Allocating = .init(testing.allocator); + defer wrong_tag.deinit(); + var wrong_tag_stream: record.Writer = .init( + testing.allocator, + &wrong_tag.writer, + ); + defer wrong_tag_stream.deinit(); + { + _ = wrong_tag_stream.begin(.screen); + errdefer wrong_tag_stream.cancel(); + try wrong_tag_stream.finish(); + } + var wrong_tag_source: std.Io.Reader = .fixed(wrong_tag.written()); + try testing.expectError( + error.UnexpectedRecordTag, + decode(&wrong_tag_source, testing.io, testing.allocator), + ); + + // Build one canonical record for exhaustive truncation and allocation + // failure checks below. + var terminal = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 10, + .rows = 3, + }); + defer terminal.deinit(testing.allocator); + try terminal.setPwd("pwd"); + try terminal.setTitle("title"); + + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + var encoded_stream: record.Writer = .init( + testing.allocator, + &encoded.writer, + ); + defer encoded_stream.deinit(); + try encode(&terminal, &encoded_stream); + + for (0..encoded.written().len) |fixture_len| { + var source: std.Io.Reader = .fixed( + encoded.written()[0..fixture_len], + ); + var restored = decode( + &source, + testing.io, + testing.allocator, + ) catch continue; + restored.deinit(testing.allocator); + try testing.expect(false); + } + + var failing = testing.FailingAllocator.init( + testing.allocator, + .{ .fail_index = 0 }, + ); + var failing_source: std.Io.Reader = .fixed(encoded.written()); + try testing.expectError( + error.OutOfMemory, + decode(&failing_source, testing.io, failing.allocator()), + ); +} diff --git a/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex b/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex new file mode 100644 index 000000000..51381196c --- /dev/null +++ b/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex @@ -0,0 +1,17 @@ +# 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. + +# prefix covered by READY +61 62 63 + +# READY record header: tag, payload length, CRC32C +05 00 20 00 00 00 90 5b 2d 06 + +# BLAKE3-256 digest of the prefix +64 37 b3 ac 38 46 51 33 ff b6 3b 75 27 3a 8d b5 +48 c5 58 46 5d 79 db 03 fd 35 9c 6c d5 bd 9d 85 diff --git a/src/terminal/snapshot/testdata/complete-v1.hex b/src/terminal/snapshot/testdata/complete-v1.hex new file mode 100644 index 000000000..7731a9c9d --- /dev/null +++ b/src/terminal/snapshot/testdata/complete-v1.hex @@ -0,0 +1,139 @@ +# 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. + +# offset 0x00000000: snapshot envelope +47 48 4f 53 54 53 4e 50 01 00 # 0x00000000 + +# offset 0x0000000a: terminal record, payload 952 bytes +01 00 b8 03 00 00 e3 5f 8b cc 02 00 03 00 20 03 # 0x0000000a +00 00 58 02 00 00 00 00 02 00 00 00 01 00 00 01 # 0x0000001a +00 02 00 65 00 00 00 01 01 01 00 00 00 00 00 08 # 0x0000002a +00 04 22 00 64 10 00 00 00 04 22 00 64 00 00 00 # 0x0000003a +00 04 22 00 64 00 00 00 00 00 00 00 00 00 00 00 # 0x0000004a +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000005a +00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff # 0x0000006a +ff 00 1d 1f 21 cc 66 66 b5 bd 68 f0 c6 74 81 a2 # 0x0000007a +be b2 94 bb 8a be b7 c5 c8 c6 66 66 66 d5 4e 53 # 0x0000008a +b9 ca 4a e7 c5 47 7a a6 da c3 97 d8 70 c0 b1 ea # 0x0000009a +ea ea 00 00 00 00 00 5f 00 00 87 00 00 af 00 00 # 0x000000aa +d7 00 00 ff 00 5f 00 00 5f 5f 00 5f 87 00 5f af # 0x000000ba +00 5f d7 00 5f ff 00 87 00 00 87 5f 00 87 87 00 # 0x000000ca +87 af 00 87 d7 00 87 ff 00 af 00 00 af 5f 00 af # 0x000000da +87 00 af af 00 af d7 00 af ff 00 d7 00 00 d7 5f # 0x000000ea +00 d7 87 00 d7 af 00 d7 d7 00 d7 ff 00 ff 00 00 # 0x000000fa +ff 5f 00 ff 87 00 ff af 00 ff d7 00 ff ff 5f 00 # 0x0000010a +00 5f 00 5f 5f 00 87 5f 00 af 5f 00 d7 5f 00 ff # 0x0000011a +5f 5f 00 5f 5f 5f 5f 5f 87 5f 5f af 5f 5f d7 5f # 0x0000012a +5f ff 5f 87 00 5f 87 5f 5f 87 87 5f 87 af 5f 87 # 0x0000013a +d7 5f 87 ff 5f af 00 5f af 5f 5f af 87 5f af af # 0x0000014a +5f af d7 5f af ff 5f d7 00 5f d7 5f 5f d7 87 5f # 0x0000015a +d7 af 5f d7 d7 5f d7 ff 5f ff 00 5f ff 5f 5f ff # 0x0000016a +87 5f ff af 5f ff d7 5f ff ff 87 00 00 87 00 5f # 0x0000017a +87 00 87 87 00 af 87 00 d7 87 00 ff 87 5f 00 87 # 0x0000018a +5f 5f 87 5f 87 87 5f af 87 5f d7 87 5f ff 87 87 # 0x0000019a +00 87 87 5f 87 87 87 87 87 af 87 87 d7 87 87 ff # 0x000001aa +87 af 00 87 af 5f 87 af 87 87 af af 87 af d7 87 # 0x000001ba +af ff 87 d7 00 87 d7 5f 87 d7 87 87 d7 af 87 d7 # 0x000001ca +d7 87 d7 ff 87 ff 00 87 ff 5f 87 ff 87 87 ff af # 0x000001da +87 ff d7 87 ff ff af 00 00 af 00 5f af 00 87 af # 0x000001ea +00 af af 00 d7 af 00 ff af 5f 00 af 5f 5f af 5f # 0x000001fa +87 af 5f af af 5f d7 af 5f ff af 87 00 af 87 5f # 0x0000020a +af 87 87 af 87 af af 87 d7 af 87 ff af af 00 af # 0x0000021a +af 5f af af 87 af af af af af d7 af af ff af d7 # 0x0000022a +00 af d7 5f af d7 87 af d7 af af d7 d7 af d7 ff # 0x0000023a +af ff 00 af ff 5f af ff 87 af ff af af ff d7 af # 0x0000024a +ff ff d7 00 00 d7 00 5f d7 00 87 d7 00 af d7 00 # 0x0000025a +d7 d7 00 ff d7 5f 00 d7 5f 5f d7 5f 87 d7 5f af # 0x0000026a +d7 5f d7 d7 5f ff d7 87 00 d7 87 5f d7 87 87 d7 # 0x0000027a +87 af d7 87 d7 d7 87 ff d7 af 00 d7 af 5f d7 af # 0x0000028a +87 d7 af af d7 af d7 d7 af ff d7 d7 00 d7 d7 5f # 0x0000029a +d7 d7 87 d7 d7 af d7 d7 d7 d7 d7 ff d7 ff 00 d7 # 0x000002aa +ff 5f d7 ff 87 d7 ff af d7 ff d7 d7 ff ff ff 00 # 0x000002ba +00 ff 00 5f ff 00 87 ff 00 af ff 00 d7 ff 00 ff # 0x000002ca +ff 5f 00 ff 5f 5f ff 5f 87 ff 5f af ff 5f d7 ff # 0x000002da +5f ff ff 87 00 ff 87 5f ff 87 87 ff 87 af ff 87 # 0x000002ea +d7 ff 87 ff ff af 00 ff af 5f ff af 87 ff af af # 0x000002fa +ff af d7 ff af ff ff d7 00 ff d7 5f ff d7 87 ff # 0x0000030a +d7 af ff d7 d7 ff d7 ff ff ff 00 ff ff 5f ff ff # 0x0000031a +87 ff ff af ff ff d7 ff ff ff 08 08 08 12 12 12 # 0x0000032a +1c 1c 1c 26 26 26 30 30 30 3a 3a 3a 44 44 44 4e # 0x0000033a +4e 4e 58 58 58 62 62 62 6c 6c 6c 76 76 76 80 80 # 0x0000034a +80 8a 8a 8a 94 94 94 9e 9e 9e a8 a8 a8 b2 b2 b2 # 0x0000035a +bc bc bc c6 c6 c6 d0 d0 d0 da da da e4 e4 e4 ee # 0x0000036a +ee ee 80 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000037a +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000038a +00 00 01 02 03 14 00 00 00 66 69 6c 65 3a 2f 2f # 0x0000039a +2f 74 6d 70 2f 73 6e 61 70 73 68 6f 74 11 00 00 # 0x000003aa +00 63 6f 6d 70 6c 65 74 65 20 73 6e 61 70 73 68 # 0x000003ba +6f 74 # 0x000003ca + +# offset 0x000003cc: screen record, payload 54 bytes +02 00 36 00 00 00 67 31 0e c6 00 00 01 00 04 00 # 0x000003cc +00 00 00 00 00 00 00 00 00 00 01 00 00 00 00 00 # 0x000003dc +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000003ec +00 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000003fc + +# offset 0x0000040c: page record, payload 119 bytes +03 00 77 00 00 00 48 b7 91 cb 02 00 03 00 00 00 # 0x0000040c +00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x0000041c +00 00 00 00 00 00 00 43 00 00 00 00 00 00 00 00 # 0x0000042c +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000043c +00 00 00 00 00 00 00 00 44 00 00 00 00 00 00 00 # 0x0000044c +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000045c +00 00 00 00 00 00 00 00 00 45 00 00 00 00 00 00 # 0x0000046c +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000047c +00 # 0x0000048c + +# offset 0x0000048d: screen record, payload 54 bytes +02 00 36 00 00 00 ce 1f 9f 08 01 00 01 00 00 00 # 0x0000048d +00 00 00 00 00 00 01 00 02 00 01 00 00 00 00 00 # 0x0000049d +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000004ad +00 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000004bd + +# offset 0x000004cd: page record, payload 119 bytes +03 00 77 00 00 00 84 a6 e9 08 02 00 03 00 00 00 # 0x000004cd +00 00 80 00 c0 00 00 02 00 00 00 08 00 00 03 00 # 0x000004dd +00 00 00 00 00 00 00 72 00 00 00 00 00 00 00 00 # 0x000004ed +00 00 00 00 00 00 00 6e 00 00 00 00 00 00 00 03 # 0x000004fd +00 00 00 00 00 00 00 00 61 00 00 00 00 00 00 00 # 0x0000050d +00 00 00 00 00 00 00 00 74 00 00 00 00 00 00 00 # 0x0000051d +03 00 00 00 00 00 00 00 00 65 00 00 00 00 00 00 # 0x0000052d +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000053d +00 # 0x0000054d + +# offset 0x0000054e: ready record, payload 32 bytes +05 00 20 00 00 00 43 0d 7b 3a 84 06 56 ba 02 59 # 0x0000054e +04 24 20 59 96 92 14 ba 76 53 80 40 09 12 85 a7 # 0x0000055e +f2 b5 b8 f9 63 98 e8 dc 9a 39 # 0x0000056e + +# offset 0x00000578: history record, payload 6 bytes +04 00 06 00 00 00 20 32 ed e1 00 00 02 00 00 00 # 0x00000578 + +# offset 0x00000588: page record, payload 86 bytes +03 00 56 00 00 00 52 3b 6e 8d 02 00 02 00 00 00 # 0x00000588 +00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x00000598 +00 00 00 00 00 00 00 42 00 00 00 00 00 00 00 00 # 0x000005a8 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005b8 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005c8 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005d8 + +# offset 0x000005e8: page record, payload 86 bytes +03 00 56 00 00 00 fb bc 15 06 02 00 02 00 00 00 # 0x000005e8 +00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x000005f8 +00 00 00 00 00 00 00 41 00 00 00 00 00 00 00 00 # 0x00000608 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000618 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000628 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000638 + +# offset 0x00000648: history record, payload 6 bytes +04 00 06 00 00 00 b8 7a ba b1 01 00 00 00 00 00 # 0x00000648 + +# offset 0x00000658: finish record, payload 32 bytes +06 00 20 00 00 00 8d d6 46 7a ac 02 c8 09 9d 1a # 0x00000658 +5a 00 ce 47 5e d7 b0 ae 54 10 bb 0d f1 35 7c ab # 0x00000668 +92 fd ae d3 5e 1a 77 1e 40 7f # 0x00000678 diff --git a/src/terminal/snapshot/testdata/envelope-v1.hex b/src/terminal/snapshot/testdata/envelope-v1.hex new file mode 100644 index 000000000..04a7eb6d5 --- /dev/null +++ b/src/terminal/snapshot/testdata/envelope-v1.hex @@ -0,0 +1,10 @@ +# 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. + +# offset 0x00000000: encoded bytes +47 48 4f 53 54 53 4e 50 01 00 # 0x00000000 diff --git a/src/terminal/snapshot/testdata/grid-v1.hex b/src/terminal/snapshot/testdata/grid-v1.hex new file mode 100644 index 000000000..3389a876f --- /dev/null +++ b/src/terminal/snapshot/testdata/grid-v1.hex @@ -0,0 +1,32 @@ +# 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. + +# row 0: prompt +04 + +# row 0, cell 0: protected prompt, wide "A" +00 01 05 00 00 00 00 00 41 00 00 00 00 00 00 00 + +# row 0, cell 1: input spacer tail +00 02 02 00 00 00 00 00 00 00 00 00 00 00 00 00 + +# row 0, cell 2: "x" with two grapheme suffixes +00 00 00 00 00 00 00 00 78 00 00 00 02 00 00 00 +01 03 00 00 02 03 00 00 + +# row 1: wrapped prompt continuation +0b + +# row 1, cell 0: palette background 7 +01 00 00 00 00 00 00 00 07 00 00 00 00 00 00 00 + +# row 1, cell 1: protected RGB background +02 00 01 00 00 00 00 00 aa bb cc 00 00 00 00 00 + +# row 1, cell 2: spacer head +00 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 diff --git a/src/terminal/snapshot/testdata/history-header-v1.hex b/src/terminal/snapshot/testdata/history-header-v1.hex new file mode 100644 index 000000000..dc8333e5b --- /dev/null +++ b/src/terminal/snapshot/testdata/history-header-v1.hex @@ -0,0 +1,10 @@ +# 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. + +# offset 0x00000000: encoded bytes +01 00 04 03 02 01 # 0x00000000 diff --git a/src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex b/src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex new file mode 100644 index 000000000..7643b203a --- /dev/null +++ b/src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex @@ -0,0 +1,10 @@ +# 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. + +# offset 0x00000000: encoded bytes +02 02 00 00 00 69 64 03 00 00 00 75 72 69 # 0x00000000 diff --git a/src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex b/src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex new file mode 100644 index 000000000..cb15cf123 --- /dev/null +++ b/src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex @@ -0,0 +1,10 @@ +# 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. + +# offset 0x00000000: encoded bytes +01 04 03 02 01 03 00 00 00 75 72 69 # 0x00000000 diff --git a/src/terminal/snapshot/testdata/page-empty-record-v1.hex b/src/terminal/snapshot/testdata/page-empty-record-v1.hex new file mode 100644 index 000000000..4b759b704 --- /dev/null +++ b/src/terminal/snapshot/testdata/page-empty-record-v1.hex @@ -0,0 +1,12 @@ +# 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. + +# offset 0x00000000: encoded bytes +03 00 25 00 00 00 8c 05 d6 d3 01 00 01 00 00 00 # 0x00000000 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000010 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000020 diff --git a/src/terminal/snapshot/testdata/page-header-v1.hex b/src/terminal/snapshot/testdata/page-header-v1.hex new file mode 100644 index 000000000..b7b00765f --- /dev/null +++ b/src/terminal/snapshot/testdata/page-header-v1.hex @@ -0,0 +1,11 @@ +# 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. + +# offset 0x00000000: encoded bytes +02 01 04 03 06 05 08 07 0a 09 0c 0b 10 0f 0e 0d # 0x00000000 +14 13 12 11 # 0x00000010 diff --git a/src/terminal/snapshot/testdata/page-v1.hex b/src/terminal/snapshot/testdata/page-v1.hex new file mode 100644 index 000000000..dfc53eeaa --- /dev/null +++ b/src/terminal/snapshot/testdata/page-v1.hex @@ -0,0 +1,24 @@ +# 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. + +# offset 0x00000000: PAGE header +03 00 02 00 02 00 02 00 08 00 00 02 80 00 00 00 # 0x00000000 +00 01 00 00 # 0x00000010 + +# offset 0x00000014: style and hyperlink tables, rows, and cells +01 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 # 0x00000014 +00 00 03 00 00 00 00 00 01 2a 00 00 00 00 00 00 # 0x00000024 +00 00 00 00 01 00 02 01 00 00 00 61 05 00 00 00 # 0x00000034 +61 6c 70 68 61 03 00 01 04 03 02 01 04 00 00 00 # 0x00000044 +62 65 74 61 04 00 01 05 00 01 00 01 00 41 00 00 # 0x00000054 +00 00 00 00 00 00 02 02 00 03 00 03 00 00 00 00 # 0x00000064 +00 00 00 00 00 01 00 00 00 01 00 01 00 07 00 00 # 0x00000074 +00 00 00 00 00 0b 00 00 00 00 00 00 00 00 78 00 # 0x00000084 +00 00 02 00 00 00 01 03 00 00 02 03 00 00 02 00 # 0x00000094 +01 00 00 00 00 00 aa bb cc 00 00 00 00 00 00 03 # 0x000000a4 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000000b4 diff --git a/src/terminal/snapshot/testdata/record-page-header-v1.hex b/src/terminal/snapshot/testdata/record-page-header-v1.hex new file mode 100644 index 000000000..28e6269ae --- /dev/null +++ b/src/terminal/snapshot/testdata/record-page-header-v1.hex @@ -0,0 +1,10 @@ +# 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. + +# offset 0x00000000: encoded bytes +03 00 18 00 00 00 1b 44 78 71 # 0x00000000 diff --git a/src/terminal/snapshot/testdata/screen-header-v1.hex b/src/terminal/snapshot/testdata/screen-header-v1.hex new file mode 100644 index 000000000..7a29df76c --- /dev/null +++ b/src/terminal/snapshot/testdata/screen-header-v1.hex @@ -0,0 +1,13 @@ +# 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. + +# offset 0x00000000: encoded bytes +01 00 03 02 08 07 06 05 04 03 02 01 05 04 07 06 # 0x00000000 +03 19 00 00 00 00 01 7f 00 00 02 12 34 56 ff 03 # 0x00000010 +00 00 0d 0c 0b 0a e4 3d 02 07 01 02 04 08 10 1f # 0x00000020 +00 11 02 03 01 # 0x00000030 diff --git a/src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex b/src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex new file mode 100644 index 000000000..f4bd7e3ab --- /dev/null +++ b/src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex @@ -0,0 +1,11 @@ +# 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. + +# offset 0x00000000: encoded bytes +02 01 04 03 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000000 +00 00 00 00 07 e4 3d # 0x00000010 diff --git a/src/terminal/snapshot/testdata/style-v1.hex b/src/terminal/snapshot/testdata/style-v1.hex new file mode 100644 index 000000000..0e834f870 --- /dev/null +++ b/src/terminal/snapshot/testdata/style-v1.hex @@ -0,0 +1,10 @@ +# 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. + +# offset 0x00000000: encoded bytes +00 00 00 00 01 7f 00 00 02 12 34 56 ff 03 00 00 # 0x00000000 diff --git a/src/terminal/snapshot/testdata/terminal-header-v1.hex b/src/terminal/snapshot/testdata/terminal-header-v1.hex new file mode 100644 index 000000000..9c9eb4bfb --- /dev/null +++ b/src/terminal/snapshot/testdata/terminal-header-v1.hex @@ -0,0 +1,16 @@ +# 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. + +# offset 0x00000000: encoded bytes +02 01 04 03 08 07 06 05 0c 0b 0a 09 01 00 02 00 # 0x00000000 +03 00 04 00 01 01 00 02 00 41 00 00 00 01 03 02 # 0x00000010 +02 01 04 04 01 21 01 01 00 00 00 00 00 00 00 00 # 0x00000020 +00 00 00 00 02 00 00 01 00 00 00 00 02 00 00 01 # 0x00000030 +01 02 03 00 00 00 00 00 00 00 00 01 04 05 06 01 # 0x00000040 +07 08 09 01 0a 0b 0c ff ff ff ff ff ff ff ff 08 # 0x00000050 +07 06 05 04 03 02 01 # 0x00000060 diff --git a/src/terminal/snapshot/verify-kaitai.py b/src/terminal/snapshot/verify-kaitai.py new file mode 100755 index 000000000..fb3c750fe --- /dev/null +++ b/src/terminal/snapshot/verify-kaitai.py @@ -0,0 +1,417 @@ +#!/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 + history_rows = overlap_rows + sum( + page.payload.header.rows for page in sequence.pages + ) + if history_rows != screen.screen.payload.header.history_rows: + raise ValueError( + f"SCREEN key {payload.key}: history_rows " + f"{screen.screen.payload.header.history_rows} " + f"does not match {history_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()) diff --git a/typos.toml b/typos.toml index 42303dd00..2deb781be 100644 --- a/typos.toml +++ b/typos.toml @@ -28,6 +28,8 @@ extend-exclude = [ "valgrind.supp", # Fuzz corpus (binary test inputs) "test/fuzz-libghostty/corpus/*", + # Annotated binary snapshot fixtures + "src/terminal/snapshot/testdata/*.hex", # Other "*.pdf", "*.data",