From fdf8dfd7b17410751d9ec7e082665b07ec9d31b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 22 Jul 2026 13:48:10 -0700 Subject: [PATCH 01/35] terminal/snapshot: define v0 record framing --- src/terminal/snapshot/envelope.zig | 87 +++++++ src/terminal/snapshot/io.zig | 53 ++++ src/terminal/snapshot/record.zig | 378 +++++++++++++++++++++++++++++ 3 files changed, 518 insertions(+) create mode 100644 src/terminal/snapshot/envelope.zig create mode 100644 src/terminal/snapshot/io.zig create mode 100644 src/terminal/snapshot/record.zig diff --git a/src/terminal/snapshot/envelope.zig b/src/terminal/snapshot/envelope.zig new file mode 100644 index 000000000..7a3496676 --- /dev/null +++ b/src/terminal/snapshot/envelope.zig @@ -0,0 +1,87 @@ +//! 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 (`BOOSNAP\0`) | +//! | 8 | 2 | Version (`u16`) | + +const std = @import("std"); +const io = @import("io.zig"); + +/// Identifies a Ghostty terminal snapshot and rejects unrelated input before +/// any record decoding begins. The trailing NUL is part of the wire value. +pub const magic = "BOOSNAP\x00"; + +/// The complete compatibility boundary for snapshot layout and behavior. +/// Version 0 readers require this value to match exactly. +pub const version: u16 = 0; + +/// Number of bytes in the fixed envelope: magic followed by version. +pub const encoded_len = magic.len + @sizeOf(@TypeOf(version)); + +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; +} + +test "golden encoding" { + var buf: [encoded_len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer); + + try std.testing.expectEqualStrings( + "BOOSNAP\x00\x00\x00", + writer.buffered(), + ); +} + +test "reject invalid magic and version" { + var invalid_magic: std.Io.Reader = .fixed("BOOSNAX\x00\x00\x00"); + try std.testing.expectError(error.InvalidMagic, decode(&invalid_magic)); + + var invalid_version: std.Io.Reader = .fixed("BOOSNAP\x00\x01\x00"); + try std.testing.expectError( + error.UnsupportedVersion, + decode(&invalid_version), + ); +} + +test "reject every truncation" { + const fixture = "BOOSNAP\x00\x00\x00"; + for (0..encoded_len) |len| { + var reader: std.Io.Reader = .fixed(fixture[0..len]); + try std.testing.expectError(error.EndOfStream, decode(&reader)); + } +} diff --git a/src/terminal/snapshot/io.zig b/src/terminal/snapshot/io.zig new file mode 100644 index 000000000..77c1c5926 --- /dev/null +++ b/src/terminal/snapshot/io.zig @@ -0,0 +1,53 @@ +//! 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); + + 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/record.zig b/src/terminal/snapshot/record.zig new file mode 100644 index 000000000..7ef9e5a7a --- /dev/null +++ b/src/terminal/snapshot/record.zig @@ -0,0 +1,378 @@ +//! 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 io = @import("io.zig"); + +/// 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, + + /// Unfinished UTF-8 and terminal parser input. + continuation = 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. + std.debug.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(); + } +}; + +/// A checksum-verifying reader limited to one record payload. +/// +/// Initialize this only after decoding a `Header`, decode the payload through +/// `reader`, and then call `finish`. The caller owns both buffers and chooses +/// their sizes. They only need to remain valid until `finish` returns. +pub const PayloadReader = struct { + header: Header, + limited: std.Io.Reader.Limited, + hashing: std.Io.Reader.Hashed(Crc32c), + + /// Caller-owned storage for the reader wrappers. + pub const Buffers = struct { + /// Buffer used to enforce the payload-length boundary. + limited: []u8, + + /// Buffer used while updating CRC32C over consumed payload bytes. + hashing: []u8, + }; + + /// 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, + }; + + /// Initialize a reader for the payload described by `header`. + /// + /// `self` and both buffers must remain at stable addresses until `finish` + /// returns. Decode only through the reader returned by `reader`. + pub fn init( + self: *PayloadReader, + source: *std.Io.Reader, + header: Header, + buffers: Buffers, + ) void { + self.* = undefined; + self.header = header; + self.limited = .init( + source, + .limited(header.payload_len), + buffers.limited, + ); + + const checksum: Checksum = .init(header.tag, header.payload_len); + self.hashing = .init( + &self.limited.interface, + checksum.hashing.hasher, + buffers.hashing, + ); + } + + /// Return the length-limited, checksum-updating payload reader. + pub fn reader(self: *PayloadReader) *std.Io.Reader { + return &self.hashing.reader; + } + + /// Require exact payload exhaustion and validate its CRC32C. + pub fn finish(self: *PayloadReader) 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); +} + +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 std.testing.expectEqualStrings( + "\x03\x00\x18\x00\x00\x00\x1b\x44\x78\x71", + writer.buffered(), + ); +} + +test "decode header with a one-byte reader buffer" { + const fixture = "\x03\x00\x18\x00\x00\x00\x1b\x44\x78\x71"; + var source: std.Io.Reader = .fixed(fixture); + var buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &buf); + + try std.testing.expectEqual( + Header{ + .tag = .page, + .payload_len = 24, + .crc32c = 0x7178441b, + }, + try Header.decode(&limited.interface), + ); +} + +test "reject invalid tags" { + inline for (.{ 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" { + const fixture = "\x03\x00\x18\x00\x00\x00\x1b\x44\x78\x71"; + for (0..Header.len) |len| { + var reader: std.Io.Reader = .fixed(fixture[0..len]); + try std.testing.expectError(error.EndOfStream, Header.decode(&reader)); + } +} + +test "payload 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 source: std.Io.Reader = .fixed(payload ++ "next"); + var payload_reader: PayloadReader = undefined; + var limited_buf: [1]u8 = undefined; + var hashing_buf: [1]u8 = undefined; + payload_reader.init(&source, header, .{ + .limited = &limited_buf, + .hashing = &hashing_buf, + }); + + var decoded: [payload.len]u8 = undefined; + try payload_reader.reader().readSliceAll(&decoded); + try payload_reader.finish(); + try std.testing.expectEqualStrings(payload, &decoded); + try std.testing.expectEqualStrings("next", try source.take(4)); +} + +test "payload reader rejects remaining bytes and invalid checksum" { + const payload = "payload"; + const header: Header = .{ + .tag = .page, + .payload_len = payload.len, + .crc32c = 0, + }; + + { + var source: std.Io.Reader = .fixed(payload); + var payload_reader: PayloadReader = undefined; + var limited_buf: [1]u8 = undefined; + var hashing_buf: [1]u8 = undefined; + payload_reader.init(&source, header, .{ + .limited = &limited_buf, + .hashing = &hashing_buf, + }); + _ = try payload_reader.reader().takeByte(); + try std.testing.expectError( + error.PayloadNotExhausted, + payload_reader.finish(), + ); + } + + { + var source: std.Io.Reader = .fixed(payload); + var payload_reader: PayloadReader = undefined; + var limited_buf: [1]u8 = undefined; + var hashing_buf: [1]u8 = undefined; + payload_reader.init(&source, header, .{ + .limited = &limited_buf, + .hashing = &hashing_buf, + }); + try payload_reader.reader().discardAll(payload.len); + try std.testing.expectError( + error.InvalidChecksum, + payload_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 source: std.Io.Reader = .fixed(payload ++ "next"); + var payload_reader: PayloadReader = undefined; + var limited_buf: [1]u8 = undefined; + var hashing_buf: [1]u8 = undefined; + payload_reader.init(&source, header, .{ + .limited = &limited_buf, + .hashing = &hashing_buf, + }); + + var too_long: [3]u8 = undefined; + try std.testing.expectError( + error.EndOfStream, + payload_reader.reader().readSliceAll(&too_long), + ); + try payload_reader.finish(); + try std.testing.expectEqualStrings("next", try source.take(4)); +} From b4fd26f0d934809f12333f1bf81c38f7ab6beeec Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 22 Jul 2026 13:48:10 -0700 Subject: [PATCH 02/35] terminal/snapshot: hyperlink and style encoding --- src/terminal/snapshot/hyperlink.zig | 354 +++++++++++++++++++++++++++ src/terminal/snapshot/style.zig | 358 ++++++++++++++++++++++++++++ 2 files changed, 712 insertions(+) create mode 100644 src/terminal/snapshot/hyperlink.zig create mode 100644 src/terminal/snapshot/style.zig diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig new file mode 100644 index 000000000..966bed247 --- /dev/null +++ b/src/terminal/snapshot/hyperlink.zig @@ -0,0 +1,354 @@ +//! 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. +//! +//! IDs and URIs are arbitrary byte strings. Their lengths are retained on the +//! wire; they are not NUL-terminated and need not contain UTF-8. +//! +//! 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. +//! +//! Decoding never allocates. The caller provides storage for the explicit ID +//! and URI bytes, and the returned hyperlink borrows slices of that storage. + +const std = @import("std"); +const io = @import("io.zig"); +const terminal_hyperlink = @import("../hyperlink.zig"); + +const Kind = enum(u8) { + implicit = 1, + explicit = 2, +}; + +/// Errors possible while determining encoded string lengths. +pub const StringBytesError = error{ + /// A string cannot fit its `u32` length or local size arithmetic overflowed. + StringTooLong, +}; + +/// Errors possible while encoding one hyperlink entry. +pub const EncodeError = std.Io.Writer.Error || StringBytesError; + +/// Errors possible while decoding one hyperlink entry. +pub const DecodeError = std.Io.Reader.Error || error{ + /// The hyperlink kind is not defined by snapshot version 0. + InvalidKind, + + /// Caller-provided string storage cannot hold the decoded entry. + BufferTooSmall, +}; + +/// One decoded hyperlink and the number of caller-owned string bytes it uses. +pub const Decoded = struct { + value: terminal_hyperlink.Hyperlink, + string_bytes: usize, +}; + +/// Return the raw explicit-ID and URI bytes encoded by `value`. +/// +/// Length prefixes and the numeric implicit ID are not included. +pub fn stringBytes( + value: terminal_hyperlink.Hyperlink, +) StringBytesError!usize { + _ = std.math.cast(u32, value.uri.len) orelse { + return error.StringTooLong; + }; + var result = value.uri.len; + + switch (value.id) { + .implicit => {}, + .explicit => |id| { + _ = std.math.cast(u32, id.len) orelse { + return error.StringTooLong; + }; + result = std.math.add(usize, result, id.len) catch { + return error.StringTooLong; + }; + }, + } + + return result; +} + +/// Return the complete encoded length of one hyperlink entry. +pub fn encodedLen( + value: terminal_hyperlink.Hyperlink, +) StringBytesError!usize { + const string_bytes = try stringBytes(value); + return std.math.add(usize, 9, string_bytes) catch { + return error.StringTooLong; + }; +} + +/// Encode one hyperlink entry. +pub fn encode( + value: terminal_hyperlink.Hyperlink, + writer: *std.Io.Writer, +) EncodeError!void { + // Validate every length before writing any part of the entry. + _ = try stringBytes(value); + + 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 hyperlink entry into caller-owned string storage. +/// +/// The returned hyperlink remains valid only as long as `strings` remains +/// valid and unmodified. `string_bytes` identifies the prefix of `strings` +/// used by this entry. +pub fn decode( + reader: *std.Io.Reader, + strings: []u8, +) DecodeError!Decoded { + const kind_raw = try reader.takeByte(); + const kind = std.enums.fromInt(Kind, kind_raw) orelse { + return error.InvalidKind; + }; + + return switch (kind) { + .implicit => decodeImplicit(reader, strings), + .explicit => decodeExplicit(reader, strings), + }; +} + +fn decodeImplicit( + reader: *std.Io.Reader, + strings: []u8, +) DecodeError!Decoded { + const id = try io.readInt(reader, u32); + const uri_len: usize = @intCast(try io.readInt(reader, u32)); + if (uri_len > strings.len) return error.BufferTooSmall; + + const uri = strings[0..uri_len]; + try reader.readSliceAll(uri); + return .{ + .value = .{ + .id = .{ .implicit = id }, + .uri = uri, + }, + .string_bytes = uri.len, + }; +} + +fn decodeExplicit( + reader: *std.Io.Reader, + strings: []u8, +) DecodeError!Decoded { + const id_len: usize = @intCast(try io.readInt(reader, u32)); + if (id_len > strings.len) return error.BufferTooSmall; + + const id = strings[0..id_len]; + try reader.readSliceAll(id); + + const uri_len: usize = @intCast(try io.readInt(reader, u32)); + const remaining = strings[id.len..]; + if (uri_len > remaining.len) return error.BufferTooSmall; + + const uri = remaining[0..uri_len]; + try reader.readSliceAll(uri); + return .{ + .value = .{ + .id = .{ .explicit = id }, + .uri = uri, + }, + .string_bytes = id.len + uri.len, + }; +} + +test "golden implicit encoding" { + const value: terminal_hyperlink.Hyperlink = .{ + .id = .{ .implicit = 0x01020304 }, + .uri = "uri", + }; + + var buf: [encodedLen(value) catch unreachable]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(value, &writer); + + try std.testing.expectEqualStrings( + "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", + writer.buffered(), + ); + try std.testing.expectEqual(@as(usize, 3), try stringBytes(value)); +} + +test "golden explicit encoding" { + const value: terminal_hyperlink.Hyperlink = .{ + .id = .{ .explicit = "id" }, + .uri = "uri", + }; + + var buf: [encodedLen(value) catch unreachable]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(value, &writer); + + try std.testing.expectEqualStrings( + "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", + writer.buffered(), + ); + try std.testing.expectEqual(@as(usize, 5), try stringBytes(value)); +} + +test "empty strings round trip" { + const values = .{ + terminal_hyperlink.Hyperlink{ + .id = .{ .implicit = 0 }, + .uri = "", + }, + terminal_hyperlink.Hyperlink{ + .id = .{ .explicit = "" }, + .uri = "", + }, + }; + + inline for (values) |value| { + var encoded: [9]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try encode(value, &writer); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + var strings: [0]u8 = .{}; + const decoded = try decode(&reader, &strings); + try std.testing.expectEqual(@as(usize, 0), decoded.string_bytes); + try std.testing.expectEqualStrings("", decoded.value.uri); + switch (value.id) { + .implicit => |id| try std.testing.expectEqual( + id, + decoded.value.id.implicit, + ), + .explicit => |id| try std.testing.expectEqualStrings( + id, + decoded.value.id.explicit, + ), + } + } +} + +test "decode with a one-byte reader buffer" { + const fixture = + "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri" ++ + "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri"; + var source: std.Io.Reader = .fixed(fixture); + var read_buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &read_buf); + + var implicit_strings: [3]u8 = undefined; + const implicit = try decode(&limited.interface, &implicit_strings); + try std.testing.expectEqual(@as(usize, 3), implicit.string_bytes); + try std.testing.expectEqualStrings("uri", implicit.value.uri); + try std.testing.expectEqual( + @as(u32, 0x01020304), + implicit.value.id.implicit, + ); + + var explicit_strings: [5]u8 = undefined; + const explicit = try decode(&limited.interface, &explicit_strings); + try std.testing.expectEqual(@as(usize, 5), explicit.string_bytes); + try std.testing.expectEqualStrings("id", explicit.value.id.explicit); + try std.testing.expectEqualStrings("uri", explicit.value.uri); +} + +test "reject invalid kinds" { + inline for (.{ 0, 3, std.math.maxInt(u8) }) |kind| { + var fixture: [1]u8 = .{kind}; + var reader: std.Io.Reader = .fixed(&fixture); + var strings: [0]u8 = .{}; + try std.testing.expectError( + error.InvalidKind, + decode(&reader, &strings), + ); + } +} + +test "reject insufficient string storage" { + { + var reader: std.Io.Reader = .fixed( + "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", + ); + var strings: [2]u8 = undefined; + try std.testing.expectError( + error.BufferTooSmall, + decode(&reader, &strings), + ); + } + + { + var reader: std.Io.Reader = .fixed( + "\x02\x03\x00\x00\x00id!\x03\x00\x00\x00uri", + ); + var strings: [2]u8 = undefined; + try std.testing.expectError( + error.BufferTooSmall, + decode(&reader, &strings), + ); + } + + { + var reader: std.Io.Reader = .fixed( + "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", + ); + var strings: [4]u8 = undefined; + try std.testing.expectError( + error.BufferTooSmall, + decode(&reader, &strings), + ); + } +} + +test "reject every truncation" { + const fixtures = .{ + "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", + "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", + }; + + inline for (fixtures) |fixture| { + for (0..fixture.len) |fixture_len| { + var reader: std.Io.Reader = .fixed(fixture[0..fixture_len]); + var strings: [5]u8 = undefined; + try std.testing.expectError( + error.EndOfStream, + decode(&reader, &strings), + ); + } + } +} diff --git a/src/terminal/snapshot/style.zig b/src/terminal/snapshot/style.zig new file mode 100644 index 000000000..87bc3f5be --- /dev/null +++ b/src/terminal/snapshot/style.zig @@ -0,0 +1,358 @@ +//! 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 0. + +const std = @import("std"); +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 0. + InvalidColorKind, + + /// The encoded underline kind is not defined by snapshot version 0. + 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, + }, + }; +} + +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 => .none, + .palette => .{ .palette = encoded[1] }, + .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; + } +} + +test "golden encoding" { + 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 std.testing.expectEqualStrings( + "\x00\x00\x00\x00" ++ + "\x01\x7f\x00\x00" ++ + "\x02\x12\x34\x56" ++ + "\xff\x03\x00\x00", + writer.buffered(), + ); +} + +test "flag bit layout" { + const cases = .{ + .{ Flags{ .bold = true }, @as(u16, 1 << 0) }, + .{ Flags{ .italic = true }, @as(u16, 1 << 1) }, + .{ Flags{ .faint = true }, @as(u16, 1 << 2) }, + .{ Flags{ .blink = true }, @as(u16, 1 << 3) }, + .{ Flags{ .inverse = true }, @as(u16, 1 << 4) }, + .{ Flags{ .invisible = true }, @as(u16, 1 << 5) }, + .{ Flags{ .strikethrough = true }, @as(u16, 1 << 6) }, + .{ Flags{ .overline = true }, @as(u16, 1 << 7) }, + .{ + Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.single) }, + @as(u16, 1 << 8), + }, + .{ + Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.double) }, + @as(u16, 2 << 8), + }, + .{ + Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.curly) }, + @as(u16, 3 << 8), + }, + .{ + Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.dotted) }, + @as(u16, 4 << 8), + }, + .{ + Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.dashed) }, + @as(u16, 5 << 8), + }, + .{ Flags{ .reserved = 1 }, @as(u16, 1 << 11) }, + }; + + inline for (cases) |case| { + try std.testing.expectEqual(case[1], @as(u16, @bitCast(case[0]))); + } +} + +test "decode with a one-byte reader buffer" { + const fixture = + "\x00\x00\x00\x00" ++ + "\x01\x7f\x00\x00" ++ + "\x02\x12\x34\x56" ++ + "\xff\x03\x00\x00"; + var source: std.Io.Reader = .fixed(fixture); + var buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &buf); + + const expected: terminal_style.Style = .{ + .fg_color = .none, + .bg_color = .{ .palette = 0x7f }, + .underline_color = .{ .rgb = .{ + .r = 0x12, + .g = 0x34, + .b = 0x56, + } }, + .flags = .{ + .bold = true, + .italic = true, + .faint = true, + .blink = true, + .inverse = true, + .invisible = true, + .strikethrough = true, + .overline = true, + .underline = .curly, + }, + }; + const actual = try decode(&limited.interface); + try std.testing.expect(expected.eql(actual)); +} + +test "reject invalid color kinds" { + inline for (.{ 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)); + } +} + +test "reject invalid flags and reserved field" { + inline for (.{ 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 "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)); + } +} From 805c3b0bafc28bc063d3fe9b8e73261075a0525a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 22 Jul 2026 13:48:10 -0700 Subject: [PATCH 03/35] terminal/snapshot: start page encoding --- src/terminal/snapshot/page.zig | 697 +++++++++++++++++++++++++++++++++ 1 file changed, 697 insertions(+) create mode 100644 src/terminal/snapshot/page.zig diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig new file mode 100644 index 000000000..1bdd32e3a --- /dev/null +++ b/src/terminal/snapshot/page.zig @@ -0,0 +1,697 @@ +//! 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 wire +//! index zero and are not included in these counts. Encoded table entries +//! receive one-based wire indexes in their encoded order. +//! +//! 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 +//! +//! This is still a work-in-progress. The current implementation encodes and +//! decodes the header, style table, and hyperlink table only. It does not yet +//! produce or consume a complete PAGE payload. +//! +//! Following the header, data is tightly packed in the following order: +//! styles, hyperlinks, cells. TODO! + +const std = @import("std"); +const hyperlink = @import("hyperlink.zig"); +const io = @import("io.zig"); +const style = @import("style.zig"); +const terminal_hyperlink = @import("../hyperlink.zig"); +const terminal_page = @import("../page.zig"); +const terminal_style = @import("../style.zig"); + +/// Errors possible while encoding the native PAGE prefix. +pub const EncodeError = hyperlink.EncodeError; + +/// Encode the PAGE header and lookup tables directly from a native page. +/// +/// Only the currently implemented PAGE prefix is written. Rows and cells will +/// be appended by a later increment. The page is iterated in place and no +/// temporary storage is allocated or retained. +pub fn encode( + page: *const terminal_page.Page, + writer: *std.Io.Writer, +) EncodeError!void { + // Write header + try Header.init(page).encode(writer); + + // Packed styles + var style_it = page.styles.iterator(page.memory); + while (style_it.next()) |entry| { + try style.encode(entry.value_ptr.*, writer); + } + + // Packed hyperlinks + var hyperlink_it = page.hyperlink_set.iterator(page.memory); + while (hyperlink_it.next()) |entry| { + try hyperlink.encode(pageHyperlink(page, entry.value_ptr), writer); + } +} + +/// 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 terminal_page.Page) 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, + }; + } + + 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; + } +}; + +/// The implemented prefix of a PAGE payload: its header and lookup tables. +/// +/// This borrowed representation does not own either table. Encoding writes +/// the header, exactly `header.style_count` non-default styles, and exactly +/// `header.hyperlink_count` unique hyperlinks. Rows and cells will be appended +/// by later increments. +pub const Payload = struct { + header: Header, + styles: []const terminal_style.Style, + hyperlinks: []const terminal_hyperlink.Hyperlink, + + pub const EncodeError = hyperlink.EncodeError || error{ + /// `header.style_count` does not match the provided style slice. + StyleCountMismatch, + + /// The default style cannot appear in the non-default style table. + DefaultStyle, + + /// `header.hyperlink_count` does not match the provided hyperlink slice. + HyperlinkCountMismatch, + }; + + pub const DecodeError = style.DecodeError || hyperlink.DecodeError || error{ + /// `header.style_count` does not match the provided style storage. + StyleCountMismatch, + + /// The default style cannot appear in the non-default style table. + DefaultStyle, + + /// `header.hyperlink_count` does not match the provided hyperlink storage. + HyperlinkCountMismatch, + }; + + /// Caller-owned storage used while decoding the implemented payload prefix. + pub const Buffers = struct { + /// Exact storage for the non-default style table. + styles: []terminal_style.Style, + + /// Exact storage for the unique hyperlink table. + hyperlinks: []terminal_hyperlink.Hyperlink, + + /// Storage for explicit hyperlink IDs and URIs. This must be large + /// enough for the decoded entries; unused trailing bytes are allowed. + hyperlink_strings: []u8, + }; + + /// Encode the PAGE header and its complete style and hyperlink tables. + pub fn encode( + self: Payload, + writer: *std.Io.Writer, + ) Payload.EncodeError!void { + // Some purposeful validation here, don't want to just assert + // this because its very important to get this right for the wire + // format. + if (self.styles.len != self.header.style_count) { + return error.StyleCountMismatch; + } + for (self.styles) |entry| { + if (entry.default()) return error.DefaultStyle; + } + if (self.hyperlinks.len != self.header.hyperlink_count) { + return error.HyperlinkCountMismatch; + } + + // (1) Header + // (2) Styles + // (3) Hyperlinks + try self.header.encode(writer); + for (self.styles) |entry| try style.encode(entry, writer); + for (self.hyperlinks) |entry| try hyperlink.encode(entry, writer); + } + + /// Decode the lookup tables after `header` has already been decoded. + /// + /// Separating header decoding lets the caller inspect the table counts and + /// capacity hints before choosing fixed, stack, pooled, or allocated + /// storage. Every buffer remains owned by the caller. Decoded hyperlink + /// strings borrow from `buffers.hyperlink_strings`. + pub fn decode( + header: Header, + reader: *std.Io.Reader, + buffers: Buffers, + ) DecodeError!Payload { + if (buffers.styles.len != header.style_count) { + return error.StyleCountMismatch; + } + if (buffers.hyperlinks.len != header.hyperlink_count) { + return error.HyperlinkCountMismatch; + } + for (buffers.styles) |*entry| { + entry.* = try style.decode(reader); + if (entry.default()) return error.DefaultStyle; + } + + var string_offset: usize = 0; + for (buffers.hyperlinks) |*entry| { + const decoded = try hyperlink.decode( + reader, + buffers.hyperlink_strings[string_offset..], + ); + entry.* = decoded.value; + string_offset += decoded.string_bytes; + } + + return .{ + .header = header, + .styles = buffers.styles, + .hyperlinks = buffers.hyperlinks, + }; + } +}; + +fn pageHyperlink( + page: *const terminal_page.Page, + entry: *const terminal_hyperlink.PageEntry, +) terminal_hyperlink.Hyperlink { + return .{ + .id = switch (entry.id) { + .implicit => |value| .{ .implicit = value }, + .explicit => |value| .{ .explicit = value.slice(page.memory) }, + }, + .uri = entry.uri.slice(page.memory), + }; +} + +test "golden encoding" { + 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 std.testing.expectEqualStrings( + "\x02\x01\x04\x03\x06\x05\x08\x07" ++ + "\x0a\x09\x0c\x0b\x10\x0f\x0e\x0d" ++ + "\x14\x13\x12\x11", + writer.buffered(), + ); +} + +test "decode with a one-byte reader buffer" { + const fixture = + "\x02\x01\x04\x03\x06\x05\x08\x07" ++ + "\x0a\x09\x0c\x0b\x10\x0f\x0e\x0d" ++ + "\x14\x13\x12\x11"; + var source: std.Io.Reader = .fixed(fixture); + var buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &buf); + + try std.testing.expectEqual( + Header{ + .columns = 0x0102, + .rows = 0x0304, + .style_count = 0x0506, + .hyperlink_count = 0x0708, + .style_capacity = 0x090a, + .hyperlink_capacity_bytes = 0x0b0c, + .grapheme_capacity_bytes = 0x0d0e0f10, + .string_capacity_bytes = 0x11121314, + }, + try Header.decode(&limited.interface), + ); +} + +test "reject every truncation" { + const fixture = + "\x02\x01\x04\x03\x06\x05\x08\x07" ++ + "\x0a\x09\x0c\x0b\x10\x0f\x0e\x0d" ++ + "\x14\x13\x12\x11"; + for (0..Header.len) |len| { + var reader: std.Io.Reader = .fixed(fixture[0..len]); + try std.testing.expectError(error.EndOfStream, Header.decode(&reader)); + } +} + +test "encode native page lookup tables" { + const capacity: terminal_page.Capacity = .{ + .cols = 3, + .rows = 2, + .styles = 8, + .hyperlink_bytes = 512, + .grapheme_bytes = 128, + .string_bytes = 256, + }; + var page = try terminal_page.Page.init(capacity); + defer page.deinit(); + + 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); + + const grapheme = page.getRowAndCell(0, 1); + grapheme.cell.* = .init('x'); + try page.setGraphemes( + grapheme.row, + grapheme.cell, + &.{ 0x0301, 0x0302 }, + ); + + 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)); + + var counter: std.Io.Writer.Discarding = .init(&.{}); + try encode(&page, &counter.writer); + + var encoded: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try encode(&page, &writer); + + const fixture = + "\x03\x00\x02\x00\x02\x00\x02\x00" ++ + "\x08\x00\x00\x02\x80\x00\x00\x00" ++ + "\x00\x01\x00\x00" ++ + "\x00\x00\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x00\x00\x01\x00\x00\x00" ++ + "\x00\x00\x00\x00\x01\x2a\x00\x00" ++ + "\x00\x00\x00\x00\x00\x00\x00\x00" ++ + "\x02\x01\x00\x00\x00a\x05\x00\x00\x00alpha" ++ + "\x01\x04\x03\x02\x01\x04\x00\x00\x00beta"; + try std.testing.expectEqualStrings(fixture, writer.buffered()); + try std.testing.expectEqual(@as(u64, fixture.len), counter.count); +} + +test "payload prefix encodes and decodes lookup tables" { + const styles = [_]terminal_style.Style{ + .{ + .fg_color = .{ .palette = 42 }, + .flags = .{ .bold = true }, + }, + .{ + .bg_color = .{ .rgb = .{ + .r = 0xaa, + .g = 0xbb, + .b = 0xcc, + } }, + .flags = .{ .underline = .double }, + }, + }; + const hyperlinks = [_]terminal_hyperlink.Hyperlink{ + .{ + .id = .{ .implicit = 0x01020304 }, + .uri = "uri", + }, + .{ + .id = .{ .explicit = "id" }, + .uri = "url", + }, + }; + const header: Header = .{ + .columns = 80, + .rows = 24, + .style_count = styles.len, + .hyperlink_count = hyperlinks.len, + .style_capacity = 16, + .hyperlink_capacity_bytes = 512, + .grapheme_capacity_bytes = 128, + .string_capacity_bytes = 256, + }; + const payload: Payload = .{ + .header = header, + .styles = &styles, + .hyperlinks = &hyperlinks, + }; + + const encoded_len = comptime len: { + break :len Header.len + + styles.len * style.len + + (hyperlink.encodedLen(hyperlinks[0]) catch unreachable) + + (hyperlink.encodedLen(hyperlinks[1]) catch unreachable); + }; + var encoded: [encoded_len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try payload.encode(&writer); + + const fixture = + "\x50\x00\x18\x00\x02\x00\x02\x00" ++ + "\x10\x00\x00\x02\x80\x00\x00\x00" ++ + "\x00\x01\x00\x00" ++ + "\x01\x2a\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x00\x00\x01\x00\x00\x00" ++ + "\x00\x00\x00\x00\x02\xaa\xbb\xcc" ++ + "\x00\x00\x00\x00\x00\x02\x00\x00" ++ + "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri" ++ + "\x02\x02\x00\x00\x00id\x03\x00\x00\x00url"; + try std.testing.expectEqualStrings(fixture, writer.buffered()); + + var source: std.Io.Reader = .fixed(writer.buffered()); + var read_buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &read_buf); + const decoded_header = try Header.decode(&limited.interface); + var decoded_styles: [styles.len]terminal_style.Style = undefined; + var decoded_hyperlinks: [hyperlinks.len]terminal_hyperlink.Hyperlink = + undefined; + var decoded_hyperlink_strings: [8]u8 = undefined; + const decoded = try Payload.decode( + decoded_header, + &limited.interface, + .{ + .styles = &decoded_styles, + .hyperlinks = &decoded_hyperlinks, + .hyperlink_strings = &decoded_hyperlink_strings, + }, + ); + + try std.testing.expectEqual(header, decoded.header); + for (styles, decoded.styles) |expected, actual| { + try std.testing.expect(expected.eql(actual)); + } + try std.testing.expectEqual( + hyperlinks[0].id.implicit, + decoded.hyperlinks[0].id.implicit, + ); + try std.testing.expectEqualStrings( + hyperlinks[0].uri, + decoded.hyperlinks[0].uri, + ); + try std.testing.expectEqualStrings( + hyperlinks[1].id.explicit, + decoded.hyperlinks[1].id.explicit, + ); + try std.testing.expectEqualStrings( + hyperlinks[1].uri, + decoded.hyperlinks[1].uri, + ); +} + +test "payload prefix validates its style table" { + const non_default = [_]terminal_style.Style{ + .{ .flags = .{ .bold = true } }, + }; + const header: Header = .{ + .columns = 80, + .rows = 24, + .style_count = 2, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var encoded: [Header.len + style.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try std.testing.expectError( + error.StyleCountMismatch, + (Payload{ + .header = header, + .styles = &non_default, + .hyperlinks = &.{}, + }).encode(&writer), + ); + try std.testing.expectEqual(@as(usize, 0), writer.end); + + var reader: std.Io.Reader = .fixed(&.{}); + var decoded_styles: [1]terminal_style.Style = undefined; + var decoded_hyperlinks: [0]terminal_hyperlink.Hyperlink = .{}; + var decoded_hyperlink_strings: [0]u8 = .{}; + try std.testing.expectError( + error.StyleCountMismatch, + Payload.decode(header, &reader, .{ + .styles = &decoded_styles, + .hyperlinks = &decoded_hyperlinks, + .hyperlink_strings = &decoded_hyperlink_strings, + }), + ); + + const default_styles = [_]terminal_style.Style{.{}}; + const default_header: Header = .{ + .columns = 80, + .rows = 24, + .style_count = 1, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + try std.testing.expectError( + error.DefaultStyle, + (Payload{ + .header = default_header, + .styles = &default_styles, + .hyperlinks = &.{}, + }).encode(&writer), + ); + + var default_fixture: [style.len]u8 = @splat(0); + var default_reader: std.Io.Reader = .fixed(&default_fixture); + var decoded_default: [1]terminal_style.Style = undefined; + try std.testing.expectError( + error.DefaultStyle, + Payload.decode(default_header, &default_reader, .{ + .styles = &decoded_default, + .hyperlinks = &decoded_hyperlinks, + .hyperlink_strings = &decoded_hyperlink_strings, + }), + ); +} + +test "payload prefix validates its hyperlink table" { + const hyperlinks = [_]terminal_hyperlink.Hyperlink{.{ + .id = .{ .implicit = 42 }, + .uri = "uri", + }}; + const header: Header = .{ + .columns = 80, + .rows = 24, + .style_count = 0, + .hyperlink_count = 2, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var encoded: [Header.len + 12]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try std.testing.expectError( + error.HyperlinkCountMismatch, + (Payload{ + .header = header, + .styles = &.{}, + .hyperlinks = &hyperlinks, + }).encode(&writer), + ); + try std.testing.expectEqual(@as(usize, 0), writer.end); + + var empty_reader: std.Io.Reader = .fixed(&.{}); + var decoded_styles: [0]terminal_style.Style = .{}; + var decoded_hyperlinks: [1]terminal_hyperlink.Hyperlink = undefined; + var decoded_strings: [3]u8 = undefined; + try std.testing.expectError( + error.HyperlinkCountMismatch, + Payload.decode(header, &empty_reader, .{ + .styles = &decoded_styles, + .hyperlinks = &decoded_hyperlinks, + .hyperlink_strings = &decoded_strings, + }), + ); +} From d44baa91476ad3747271a52575ff147193102b14 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 22 Jul 2026 13:48:10 -0700 Subject: [PATCH 04/35] terminal/snapshot: setup the snapshot main --- src/terminal/main.zig | 1 + src/terminal/snapshot/main.zig | 50 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/terminal/snapshot/main.zig 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/snapshot/main.zig b/src/terminal/snapshot/main.zig new file mode 100644 index 000000000..46921d7d4 --- /dev/null +++ b/src/terminal/snapshot/main.zig @@ -0,0 +1,50 @@ +//! 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 down the terminal state, viewport, etc. followed +//! by a "READY" event. At the READY state, the terminal is functional and +//! could in theory begin processing pty bytes. After the READY state the +//! binary format continues transmitting history and extra assets such as +//! images and so on. +//! +//! ## Snapshot Format +//! +//! This documents snapshot format 0. Version 0 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 | +//! +------------------+ +//! | ... | +//! +------------------+ +//! ``` +//! +//! Records have a strict order: TERMINAL, the primary SCREEN and its +//! PAGE records, an optional alternate SCREEN and its PAGE records, +//! CONTINUATION, READY, and FINISH. + +pub const envelope = @import("envelope.zig"); +pub const hyperlink = @import("hyperlink.zig"); +pub const page = @import("page.zig"); +pub const record = @import("record.zig"); +pub const style = @import("style.zig"); From 2fc238ed01bca7c3bc46a8a728ac35356fb4b4a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Jul 2026 14:34:46 -0700 Subject: [PATCH 05/35] terminal/snapshot: decode directly into pages --- src/terminal/hyperlink.zig | 4 +- src/terminal/snapshot/hyperlink.zig | 297 +++++++------- src/terminal/snapshot/page.zig | 574 +++++++++++++++------------- 3 files changed, 462 insertions(+), 413 deletions(-) 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/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig index 966bed247..01e36702c 100644 --- a/src/terminal/snapshot/hyperlink.zig +++ b/src/terminal/snapshot/hyperlink.zig @@ -34,86 +34,43 @@ //! Both variants have nine bytes of fixed overhead. The remaining bytes are //! the URI plus the explicit ID, when present. //! -//! Decoding never allocates. The caller provides storage for the explicit ID -//! and URI bytes, and the returned hyperlink borrows slices of that storage. +//! 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 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 determining encoded string lengths. -pub const StringBytesError = error{ - /// A string cannot fit its `u32` length or local size arithmetic overflowed. - StringTooLong, -}; - /// Errors possible while encoding one hyperlink entry. -pub const EncodeError = std.Io.Writer.Error || StringBytesError; +pub const EncodeError = std.Io.Writer.Error; -/// Errors possible while decoding one hyperlink entry. -pub const DecodeError = std.Io.Reader.Error || error{ +/// 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 0. InvalidKind, - - /// Caller-provided string storage cannot hold the decoded entry. - BufferTooSmall, }; -/// One decoded hyperlink and the number of caller-owned string bytes it uses. -pub const Decoded = struct { - value: terminal_hyperlink.Hyperlink, - string_bytes: usize, -}; - -/// Return the raw explicit-ID and URI bytes encoded by `value`. -/// -/// Length prefixes and the numeric implicit ID are not included. -pub fn stringBytes( - value: terminal_hyperlink.Hyperlink, -) StringBytesError!usize { - _ = std.math.cast(u32, value.uri.len) orelse { - return error.StringTooLong; +/// Errors possible while decoding directly into a native page. +pub const DecodePageError = std.Io.Reader.Error || + terminal_page.Page.InsertHyperlinkError || + error{ + /// The hyperlink kind is not defined by snapshot version 0. + InvalidKind, }; - var result = value.uri.len; - - switch (value.id) { - .implicit => {}, - .explicit => |id| { - _ = std.math.cast(u32, id.len) orelse { - return error.StringTooLong; - }; - result = std.math.add(usize, result, id.len) catch { - return error.StringTooLong; - }; - }, - } - - return result; -} - -/// Return the complete encoded length of one hyperlink entry. -pub fn encodedLen( - value: terminal_hyperlink.Hyperlink, -) StringBytesError!usize { - const string_bytes = try stringBytes(value); - return std.math.add(usize, 9, string_bytes) catch { - return error.StringTooLong; - }; -} /// Encode one hyperlink entry. pub fn encode( value: terminal_hyperlink.Hyperlink, writer: *std.Io.Writer, ) EncodeError!void { - // Validate every length before writing any part of the entry. - _ = try stringBytes(value); - switch (value.id) { .implicit => |id| { try writer.writeByte(@intFromEnum(Kind.implicit)); @@ -131,67 +88,139 @@ pub fn encode( } } -/// Decode one hyperlink entry into caller-owned string storage. +/// Decode one allocator-owned hyperlink entry. /// -/// The returned hyperlink remains valid only as long as `strings` remains -/// valid and unmodified. `string_bytes` identifies the prefix of `strings` -/// used by this entry. +/// The caller owns the returned value and must call `Hyperlink.deinit`. pub fn decode( reader: *std.Io.Reader, - strings: []u8, -) DecodeError!Decoded { + alloc: Allocator, +) DecodeError!terminal_hyperlink.Hyperlink { const kind_raw = try reader.takeByte(); - const kind = std.enums.fromInt(Kind, kind_raw) orelse { + const kind = std.enums.fromInt(Kind, kind_raw) orelse return error.InvalidKind; - }; return switch (kind) { - .implicit => decodeImplicit(reader, strings), - .explicit => decodeExplicit(reader, strings), + .implicit => implicit: { + const id = try io.readInt(reader, u32); + const uri_len: usize = @intCast(try io.readInt(reader, u32)); + 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)); + 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)); + const uri = try alloc.alloc(u8, uri_len); + errdefer alloc.free(uri); + try reader.readSliceAll(uri); + + break :explicit .{ + .id = .{ .explicit = id }, + .uri = uri, + }; + }, }; } -fn decodeImplicit( +/// 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 page ID assigned to the entry. +pub fn decodePage( + page: *terminal_page.Page, reader: *std.Io.Reader, - strings: []u8, -) DecodeError!Decoded { - const id = try io.readInt(reader, u32); - const uri_len: usize = @intCast(try io.readInt(reader, u32)); - if (uri_len > strings.len) return error.BufferTooSmall; +) DecodePageError!terminal_hyperlink.Id { + const kind_raw = try reader.takeByte(); + const kind = std.enums.fromInt(Kind, kind_raw) orelse + return error.InvalidKind; - const uri = strings[0..uri_len]; - try reader.readSliceAll(uri); - return .{ - .value = .{ - .id = .{ .implicit = id }, - .uri = uri, + 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)); + const uri = try decodePageString( + page, + reader, + uri_len, + ); + + break :implicit .{ + .id = .{ .implicit = id }, + .uri = uri, + }; }, - .string_bytes = uri.len, + + .explicit => explicit: { + const id_len: usize = @intCast(try io.readInt(reader, u32)); + const id = try decodePageString( + page, + reader, + id_len, + ); + errdefer if (id.len > 0) page.string_alloc.free( + page.memory, + id.slice(page.memory), + ); + + const uri_len: usize = @intCast(try io.readInt(reader, u32)); + const uri = try decodePageString( + page, + reader, + uri_len, + ); + + break :explicit .{ + .id = .{ .explicit = id }, + .uri = uri, + }; + }, + }; + errdefer entry.free(page); + + return page.hyperlink_set.addContext( + page.memory, + entry, + .{ .page = page }, + ) catch |err| switch (err) { + error.OutOfMemory => error.SetOutOfMemory, + error.NeedsRehash => error.SetNeedsRehash, }; } -fn decodeExplicit( +fn decodePageString( + page: *terminal_page.Page, reader: *std.Io.Reader, - strings: []u8, -) DecodeError!Decoded { - const id_len: usize = @intCast(try io.readInt(reader, u32)); - if (id_len > strings.len) return error.BufferTooSmall; + len: usize, +) (std.Io.Reader.Error || error{StringsOutOfMemory})!terminal_size.Offset(u8).Slice { + if (len == 0) return .{}; - const id = strings[0..id_len]; - try reader.readSliceAll(id); + // 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); - const uri_len: usize = @intCast(try io.readInt(reader, u32)); - const remaining = strings[id.len..]; - if (uri_len > remaining.len) return error.BufferTooSmall; - - const uri = remaining[0..uri_len]; - try reader.readSliceAll(uri); return .{ - .value = .{ - .id = .{ .explicit = id }, - .uri = uri, - }, - .string_bytes = id.len + uri.len, + .len = value.len, + .offset = terminal_size.getOffset( + u8, + page.memory, + value.ptr, + ), }; } @@ -201,7 +230,7 @@ test "golden implicit encoding" { .uri = "uri", }; - var buf: [encodedLen(value) catch unreachable]u8 = undefined; + var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); try encode(value, &writer); @@ -209,7 +238,6 @@ test "golden implicit encoding" { "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", writer.buffered(), ); - try std.testing.expectEqual(@as(usize, 3), try stringBytes(value)); } test "golden explicit encoding" { @@ -218,7 +246,7 @@ test "golden explicit encoding" { .uri = "uri", }; - var buf: [encodedLen(value) catch unreachable]u8 = undefined; + var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); try encode(value, &writer); @@ -226,7 +254,6 @@ test "golden explicit encoding" { "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", writer.buffered(), ); - try std.testing.expectEqual(@as(usize, 5), try stringBytes(value)); } test "empty strings round trip" { @@ -247,18 +274,17 @@ test "empty strings round trip" { try encode(value, &writer); var reader: std.Io.Reader = .fixed(writer.buffered()); - var strings: [0]u8 = .{}; - const decoded = try decode(&reader, &strings); - try std.testing.expectEqual(@as(usize, 0), decoded.string_bytes); - try std.testing.expectEqualStrings("", decoded.value.uri); + const decoded = try decode(&reader, std.testing.allocator); + defer decoded.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("", decoded.uri); switch (value.id) { .implicit => |id| try std.testing.expectEqual( id, - decoded.value.id.implicit, + decoded.id.implicit, ), .explicit => |id| try std.testing.expectEqualStrings( id, - decoded.value.id.explicit, + decoded.id.explicit, ), } } @@ -272,65 +298,57 @@ test "decode with a one-byte reader buffer" { var read_buf: [1]u8 = undefined; var limited = source.limited(.unlimited, &read_buf); - var implicit_strings: [3]u8 = undefined; - const implicit = try decode(&limited.interface, &implicit_strings); - try std.testing.expectEqual(@as(usize, 3), implicit.string_bytes); - try std.testing.expectEqualStrings("uri", implicit.value.uri); + const implicit = try decode(&limited.interface, std.testing.allocator); + defer implicit.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("uri", implicit.uri); try std.testing.expectEqual( @as(u32, 0x01020304), - implicit.value.id.implicit, + implicit.id.implicit, ); - var explicit_strings: [5]u8 = undefined; - const explicit = try decode(&limited.interface, &explicit_strings); - try std.testing.expectEqual(@as(usize, 5), explicit.string_bytes); - try std.testing.expectEqualStrings("id", explicit.value.id.explicit); - try std.testing.expectEqualStrings("uri", explicit.value.uri); + const explicit = try decode(&limited.interface, std.testing.allocator); + defer explicit.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("id", explicit.id.explicit); + try std.testing.expectEqualStrings("uri", explicit.uri); } test "reject invalid kinds" { inline for (.{ 0, 3, std.math.maxInt(u8) }) |kind| { var fixture: [1]u8 = .{kind}; var reader: std.Io.Reader = .fixed(&fixture); - var strings: [0]u8 = .{}; try std.testing.expectError( error.InvalidKind, - decode(&reader, &strings), + decode(&reader, std.testing.allocator), ); } } -test "reject insufficient string storage" { +test "decode allocation failure" { { var reader: std.Io.Reader = .fixed( "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", ); - var strings: [2]u8 = undefined; + var failing = std.testing.FailingAllocator.init( + std.testing.allocator, + .{ .fail_index = 0 }, + ); try std.testing.expectError( - error.BufferTooSmall, - decode(&reader, &strings), + error.OutOfMemory, + decode(&reader, failing.allocator()), ); } - { - var reader: std.Io.Reader = .fixed( - "\x02\x03\x00\x00\x00id!\x03\x00\x00\x00uri", + for (0..2) |fail_index| { + var failing = std.testing.FailingAllocator.init( + std.testing.allocator, + .{ .fail_index = fail_index }, ); - var strings: [2]u8 = undefined; - try std.testing.expectError( - error.BufferTooSmall, - decode(&reader, &strings), - ); - } - - { var reader: std.Io.Reader = .fixed( "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", ); - var strings: [4]u8 = undefined; try std.testing.expectError( - error.BufferTooSmall, - decode(&reader, &strings), + error.OutOfMemory, + decode(&reader, failing.allocator()), ); } } @@ -344,10 +362,9 @@ test "reject every truncation" { inline for (fixtures) |fixture| { for (0..fixture.len) |fixture_len| { var reader: std.Io.Reader = .fixed(fixture[0..fixture_len]); - var strings: [5]u8 = undefined; try std.testing.expectError( error.EndOfStream, - decode(&reader, &strings), + decode(&reader, std.testing.allocator), ); } } diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 1bdd32e3a..6d5eb9dd4 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -66,16 +66,49 @@ 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 TerminalHyperlinkPageEntry = terminal_hyperlink.PageEntry; +const TerminalHyperlinkSet = terminal_hyperlink.Set; +const TerminalPage = terminal_page.Page; +const TerminalPageCapacity = terminal_page.Capacity; +const TerminalStyle = terminal_style.Style; +const TerminalStyleId = terminal_style.Id; +const TerminalStyleSet = terminal_style.Set; + /// Errors possible while encoding the native PAGE prefix. pub const EncodeError = hyperlink.EncodeError; +/// Errors possible while decoding the native PAGE prefix. +pub const DecodeError = style.DecodeError || + Header.CapacityError || + error{ + /// The hyperlink kind is not defined by snapshot version 0. + InvalidKind, + + /// The advertised string capacity cannot hold the encoded hyperlinks. + InvalidStringCapacity, + + /// A non-default style was encoded more than once. + DuplicateStyle, + + /// A hyperlink was encoded more than once. + DuplicateHyperlink, + + /// The default style cannot appear in the non-default style table. + DefaultStyle, + + /// Native page backing memory could not be allocated. + OutOfMemory, + }; + /// Encode the PAGE header and lookup tables directly from a native page. /// /// Only the currently implemented PAGE prefix is written. Rows and cells will /// be appended by a later increment. The page is iterated in place and no /// temporary storage is allocated or retained. pub fn encode( - page: *const terminal_page.Page, + page: *const TerminalPage, writer: *std.Io.Writer, ) EncodeError!void { // Write header @@ -94,6 +127,55 @@ pub fn encode( } } +/// Decode the PAGE header and lookup tables directly into a native page. +/// +/// The fixed header is validated before allocating the page. Style entries +/// are inserted directly into the page style set, while hyperlink strings are +/// read into the page string allocator before their native entries are +/// inserted. No caller-owned table or string buffers are required. +/// +/// Only the currently implemented PAGE prefix is consumed. Rows and cells +/// will be decoded by a later increment. +pub fn decode( + reader: *std.Io.Reader, +) DecodeError!TerminalPage { + // Decode the header, validate capacities, init page + const header = try Header.decode(reader); + const capacity = try header.pageCapacity(); + var page = TerminalPage.init(capacity) catch + return error.OutOfMemory; + errdefer page.deinit(); + + // Styles + for (0..header.style_count) |wire_index| { + const value = try style.decode(reader); + if (value.default()) return error.DefaultStyle; + + const native_id = page.styles.add( + page.memory, + value, + ) catch return error.InvalidStyleCapacity; + if (native_id != wire_index + 1) return error.DuplicateStyle; + } + + // Hyperlinks + for (0..header.hyperlink_count) |wire_index| { + const native_id = hyperlink.decodePage( + &page, + reader, + ) catch |err| switch (err) { + error.StringsOutOfMemory => return error.InvalidStringCapacity, + error.SetOutOfMemory, + error.SetNeedsRehash, + => return error.InvalidHyperlinkCapacity, + else => return err, + }; + if (native_id != wire_index + 1) return error.DuplicateHyperlink; + } + + return page; +} + /// The fixed logical dimensions, table counts, and allocation hints at the /// start of PAGE. pub const Header = struct { @@ -167,7 +249,7 @@ pub const Header = struct { /// /// This copies the page's existing allocation capacities. It does not /// scan cells, allocate, or encode any bytes. - fn init(page: *const terminal_page.Page) Header { + fn init(page: *const TerminalPage) Header { return .{ .columns = page.size.cols, .rows = page.size.rows, @@ -180,6 +262,44 @@ pub const Header = struct { }; } + pub const CapacityError = error{ + InvalidDimensions, + InvalidStyleCapacity, + InvalidHyperlinkCapacity, + }; + + /// 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; + } + + const style_layout: TerminalStyleSet.Layout = .init(self.style_capacity); + if (self.style_count > style_layout.cap -| 1) { + return error.InvalidStyleCapacity; + } + + const hyperlink_capacity_count = @divFloor( + self.hyperlink_capacity_bytes, + @sizeOf(TerminalHyperlinkSet.Item), + ); + const hyperlink_layout: TerminalHyperlinkSet.Layout = .init( + hyperlink_capacity_count, + ); + if (self.hyperlink_count > hyperlink_layout.cap -| 1) { + return error.InvalidHyperlinkCapacity; + } + + 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); @@ -198,122 +318,10 @@ pub const Header = struct { } }; -/// The implemented prefix of a PAGE payload: its header and lookup tables. -/// -/// This borrowed representation does not own either table. Encoding writes -/// the header, exactly `header.style_count` non-default styles, and exactly -/// `header.hyperlink_count` unique hyperlinks. Rows and cells will be appended -/// by later increments. -pub const Payload = struct { - header: Header, - styles: []const terminal_style.Style, - hyperlinks: []const terminal_hyperlink.Hyperlink, - - pub const EncodeError = hyperlink.EncodeError || error{ - /// `header.style_count` does not match the provided style slice. - StyleCountMismatch, - - /// The default style cannot appear in the non-default style table. - DefaultStyle, - - /// `header.hyperlink_count` does not match the provided hyperlink slice. - HyperlinkCountMismatch, - }; - - pub const DecodeError = style.DecodeError || hyperlink.DecodeError || error{ - /// `header.style_count` does not match the provided style storage. - StyleCountMismatch, - - /// The default style cannot appear in the non-default style table. - DefaultStyle, - - /// `header.hyperlink_count` does not match the provided hyperlink storage. - HyperlinkCountMismatch, - }; - - /// Caller-owned storage used while decoding the implemented payload prefix. - pub const Buffers = struct { - /// Exact storage for the non-default style table. - styles: []terminal_style.Style, - - /// Exact storage for the unique hyperlink table. - hyperlinks: []terminal_hyperlink.Hyperlink, - - /// Storage for explicit hyperlink IDs and URIs. This must be large - /// enough for the decoded entries; unused trailing bytes are allowed. - hyperlink_strings: []u8, - }; - - /// Encode the PAGE header and its complete style and hyperlink tables. - pub fn encode( - self: Payload, - writer: *std.Io.Writer, - ) Payload.EncodeError!void { - // Some purposeful validation here, don't want to just assert - // this because its very important to get this right for the wire - // format. - if (self.styles.len != self.header.style_count) { - return error.StyleCountMismatch; - } - for (self.styles) |entry| { - if (entry.default()) return error.DefaultStyle; - } - if (self.hyperlinks.len != self.header.hyperlink_count) { - return error.HyperlinkCountMismatch; - } - - // (1) Header - // (2) Styles - // (3) Hyperlinks - try self.header.encode(writer); - for (self.styles) |entry| try style.encode(entry, writer); - for (self.hyperlinks) |entry| try hyperlink.encode(entry, writer); - } - - /// Decode the lookup tables after `header` has already been decoded. - /// - /// Separating header decoding lets the caller inspect the table counts and - /// capacity hints before choosing fixed, stack, pooled, or allocated - /// storage. Every buffer remains owned by the caller. Decoded hyperlink - /// strings borrow from `buffers.hyperlink_strings`. - pub fn decode( - header: Header, - reader: *std.Io.Reader, - buffers: Buffers, - ) DecodeError!Payload { - if (buffers.styles.len != header.style_count) { - return error.StyleCountMismatch; - } - if (buffers.hyperlinks.len != header.hyperlink_count) { - return error.HyperlinkCountMismatch; - } - for (buffers.styles) |*entry| { - entry.* = try style.decode(reader); - if (entry.default()) return error.DefaultStyle; - } - - var string_offset: usize = 0; - for (buffers.hyperlinks) |*entry| { - const decoded = try hyperlink.decode( - reader, - buffers.hyperlink_strings[string_offset..], - ); - entry.* = decoded.value; - string_offset += decoded.string_bytes; - } - - return .{ - .header = header, - .styles = buffers.styles, - .hyperlinks = buffers.hyperlinks, - }; - } -}; - fn pageHyperlink( - page: *const terminal_page.Page, - entry: *const terminal_hyperlink.PageEntry, -) terminal_hyperlink.Hyperlink { + page: *const TerminalPage, + entry: *const TerminalHyperlinkPageEntry, +) TerminalHyperlink { return .{ .id = switch (entry.id) { .implicit => |value| .{ .implicit = value }, @@ -383,7 +391,7 @@ test "reject every truncation" { } test "encode native page lookup tables" { - const capacity: terminal_page.Capacity = .{ + const capacity: TerminalPageCapacity = .{ .cols = 3, .rows = 2, .styles = 8, @@ -391,7 +399,7 @@ test "encode native page lookup tables" { .grapheme_bytes = 128, .string_bytes = 256, }; - var page = try terminal_page.Page.init(capacity); + var page = try TerminalPage.init(capacity); defer page.deinit(); const style_a = try page.styles.add(page.memory, .{ @@ -478,56 +486,17 @@ test "encode native page lookup tables" { try std.testing.expectEqual(@as(u64, fixture.len), counter.count); } -test "payload prefix encodes and decodes lookup tables" { - const styles = [_]terminal_style.Style{ - .{ - .fg_color = .{ .palette = 42 }, - .flags = .{ .bold = true }, - }, - .{ - .bg_color = .{ .rgb = .{ - .r = 0xaa, - .g = 0xbb, - .b = 0xcc, - } }, - .flags = .{ .underline = .double }, - }, - }; - const hyperlinks = [_]terminal_hyperlink.Hyperlink{ - .{ - .id = .{ .implicit = 0x01020304 }, - .uri = "uri", - }, - .{ - .id = .{ .explicit = "id" }, - .uri = "url", - }, - }; +test "decode lookup tables directly into a native page" { const header: Header = .{ .columns = 80, .rows = 24, - .style_count = styles.len, - .hyperlink_count = hyperlinks.len, + .style_count = 2, + .hyperlink_count = 2, .style_capacity = 16, .hyperlink_capacity_bytes = 512, .grapheme_capacity_bytes = 128, .string_capacity_bytes = 256, }; - const payload: Payload = .{ - .header = header, - .styles = &styles, - .hyperlinks = &hyperlinks, - }; - - const encoded_len = comptime len: { - break :len Header.len + - styles.len * style.len + - (hyperlink.encodedLen(hyperlinks[0]) catch unreachable) + - (hyperlink.encodedLen(hyperlinks[1]) catch unreachable); - }; - var encoded: [encoded_len]u8 = undefined; - var writer: std.Io.Writer = .fixed(&encoded); - try payload.encode(&writer); const fixture = "\x50\x00\x18\x00\x02\x00\x02\x00" ++ @@ -539,159 +508,222 @@ test "payload prefix encodes and decodes lookup tables" { "\x00\x00\x00\x00\x00\x02\x00\x00" ++ "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri" ++ "\x02\x02\x00\x00\x00id\x03\x00\x00\x00url"; - try std.testing.expectEqualStrings(fixture, writer.buffered()); - var source: std.Io.Reader = .fixed(writer.buffered()); + var source: std.Io.Reader = .fixed(fixture); var read_buf: [1]u8 = undefined; var limited = source.limited(.unlimited, &read_buf); - const decoded_header = try Header.decode(&limited.interface); - var decoded_styles: [styles.len]terminal_style.Style = undefined; - var decoded_hyperlinks: [hyperlinks.len]terminal_hyperlink.Hyperlink = - undefined; - var decoded_hyperlink_strings: [8]u8 = undefined; - const decoded = try Payload.decode( - decoded_header, - &limited.interface, - .{ - .styles = &decoded_styles, - .hyperlinks = &decoded_hyperlinks, - .hyperlink_strings = &decoded_hyperlink_strings, - }, - ); + var decoded = try decode(&limited.interface); + defer decoded.deinit(); - try std.testing.expectEqual(header, decoded.header); - for (styles, decoded.styles) |expected, actual| { - try std.testing.expect(expected.eql(actual)); - } + try std.testing.expectEqual(header, Header.init(&decoded)); + try decoded.verifyIntegrity(std.testing.allocator); + + var style_it = decoded.styles.iterator(decoded.memory); + const style_a = style_it.next().?; + try std.testing.expectEqual(@as(TerminalStyleId, 1), style_a.id); + try std.testing.expect((TerminalStyle{ + .fg_color = .{ .palette = 42 }, + .flags = .{ .bold = true }, + }).eql(style_a.value_ptr.*)); + const style_b = style_it.next().?; + try std.testing.expectEqual(@as(TerminalStyleId, 2), style_b.id); + try std.testing.expect((TerminalStyle{ + .bg_color = .{ .rgb = .{ + .r = 0xaa, + .g = 0xbb, + .b = 0xcc, + } }, + .flags = .{ .underline = .double }, + }).eql(style_b.value_ptr.*)); + try std.testing.expectEqual(null, style_it.next()); + + var hyperlink_it = decoded.hyperlink_set.iterator(decoded.memory); + const hyperlink_a = pageHyperlink( + &decoded, + hyperlink_it.next().?.value_ptr, + ); try std.testing.expectEqual( - hyperlinks[0].id.implicit, - decoded.hyperlinks[0].id.implicit, + @as(u32, 0x01020304), + hyperlink_a.id.implicit, ); - try std.testing.expectEqualStrings( - hyperlinks[0].uri, - decoded.hyperlinks[0].uri, - ); - try std.testing.expectEqualStrings( - hyperlinks[1].id.explicit, - decoded.hyperlinks[1].id.explicit, - ); - try std.testing.expectEqualStrings( - hyperlinks[1].uri, - decoded.hyperlinks[1].uri, + try std.testing.expectEqualStrings("uri", hyperlink_a.uri); + const hyperlink_b = pageHyperlink( + &decoded, + hyperlink_it.next().?.value_ptr, ); + try std.testing.expectEqualStrings("id", hyperlink_b.id.explicit); + try std.testing.expectEqualStrings("url", hyperlink_b.uri); + try std.testing.expectEqual(null, hyperlink_it.next()); + + var reencoded: [fixture.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&reencoded); + try encode(&decoded, &writer); + try std.testing.expectEqualStrings(fixture, writer.buffered()); } -test "payload prefix validates its style table" { - const non_default = [_]terminal_style.Style{ - .{ .flags = .{ .bold = true } }, +test "decode validates dimensions and native table capacities" { + const cases = .{ + .{ + .expected = error.InvalidDimensions, + .header = 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, + }, + }, + .{ + .expected = error.InvalidDimensions, + .header = Header{ + .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, + }, + }, + .{ + .expected = error.InvalidStyleCapacity, + .header = Header{ + .columns = 80, + .rows = 24, + .style_count = 1, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }, + }, + .{ + .expected = error.InvalidHyperlinkCapacity, + .header = Header{ + .columns = 80, + .rows = 24, + .style_count = 0, + .hyperlink_count = 1, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }, + }, }; + + inline for (cases) |case| { + var encoded: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try case.header.encode(&writer); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try std.testing.expectError(case.expected, decode(&reader)); + } +} + +test "decode rejects duplicate and default style entries" { const header: Header = .{ .columns = 80, .rows = 24, .style_count = 2, .hyperlink_count = 0, - .style_capacity = 0, - .hyperlink_capacity_bytes = 0, + .style_capacity = 16, + .hyperlink_capacity_bytes = 512, .grapheme_capacity_bytes = 0, - .string_capacity_bytes = 0, + .string_capacity_bytes = 256, + }; + const duplicate_style: TerminalStyle = .{ + .flags = .{ .bold = true }, }; - var encoded: [Header.len + style.len]u8 = undefined; + var encoded: [Header.len + 2 * style.len]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); - try std.testing.expectError( - error.StyleCountMismatch, - (Payload{ - .header = header, - .styles = &non_default, - .hyperlinks = &.{}, - }).encode(&writer), - ); - try std.testing.expectEqual(@as(usize, 0), writer.end); + try header.encode(&writer); + try style.encode(duplicate_style, &writer); + try style.encode(duplicate_style, &writer); - var reader: std.Io.Reader = .fixed(&.{}); - var decoded_styles: [1]terminal_style.Style = undefined; - var decoded_hyperlinks: [0]terminal_hyperlink.Hyperlink = .{}; - var decoded_hyperlink_strings: [0]u8 = .{}; - try std.testing.expectError( - error.StyleCountMismatch, - Payload.decode(header, &reader, .{ - .styles = &decoded_styles, - .hyperlinks = &decoded_hyperlinks, - .hyperlink_strings = &decoded_hyperlink_strings, - }), - ); + var reader: std.Io.Reader = .fixed(writer.buffered()); + try std.testing.expectError(error.DuplicateStyle, decode(&reader)); - const default_styles = [_]terminal_style.Style{.{}}; const default_header: Header = .{ .columns = 80, .rows = 24, .style_count = 1, .hyperlink_count = 0, - .style_capacity = 0, + .style_capacity = 16, .hyperlink_capacity_bytes = 0, .grapheme_capacity_bytes = 0, .string_capacity_bytes = 0, }; - try std.testing.expectError( - error.DefaultStyle, - (Payload{ - .header = default_header, - .styles = &default_styles, - .hyperlinks = &.{}, - }).encode(&writer), - ); + var default_encoded: [Header.len + style.len]u8 = undefined; + var default_writer: std.Io.Writer = .fixed(&default_encoded); + try default_header.encode(&default_writer); + try style.encode(.{}, &default_writer); - var default_fixture: [style.len]u8 = @splat(0); - var default_reader: std.Io.Reader = .fixed(&default_fixture); - var decoded_default: [1]terminal_style.Style = undefined; - try std.testing.expectError( - error.DefaultStyle, - Payload.decode(default_header, &default_reader, .{ - .styles = &decoded_default, - .hyperlinks = &decoded_hyperlinks, - .hyperlink_strings = &decoded_hyperlink_strings, - }), - ); + var default_reader: std.Io.Reader = .fixed(default_writer.buffered()); + try std.testing.expectError(error.DefaultStyle, decode(&default_reader)); } -test "payload prefix validates its hyperlink table" { - const hyperlinks = [_]terminal_hyperlink.Hyperlink{.{ - .id = .{ .implicit = 42 }, - .uri = "uri", - }}; +test "decode rejects duplicate hyperlinks with empty strings" { const header: Header = .{ - .columns = 80, - .rows = 24, + .columns = 1, + .rows = 1, .style_count = 0, .hyperlink_count = 2, .style_capacity = 0, - .hyperlink_capacity_bytes = 0, + .hyperlink_capacity_bytes = 512, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + const duplicate: TerminalHyperlink = .{ + .id = .{ .explicit = "" }, + .uri = "", + }; + + var encoded: [Header.len + 18]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try header.encode(&writer); + try hyperlink.encode(duplicate, &writer); + try hyperlink.encode(duplicate, &writer); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + try std.testing.expectError(error.DuplicateHyperlink, decode(&reader)); +} + +test "decode reads hyperlink strings into page storage" { + const link: TerminalHyperlink = .{ + .id = .{ .explicit = "id" }, + .uri = "uri", + }; + const hyperlink_capacity: u16 = @intCast( + TerminalHyperlinkSet.capacityForCount(1) * + @sizeOf(TerminalHyperlinkSet.Item), + ); + const header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 0, + .hyperlink_count = 1, + .style_capacity = 0, + .hyperlink_capacity_bytes = hyperlink_capacity, .grapheme_capacity_bytes = 0, .string_capacity_bytes = 0, }; - var encoded: [Header.len + 12]u8 = undefined; + var encoded: [Header.len + 14]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); - try std.testing.expectError( - error.HyperlinkCountMismatch, - (Payload{ - .header = header, - .styles = &.{}, - .hyperlinks = &hyperlinks, - }).encode(&writer), - ); - try std.testing.expectEqual(@as(usize, 0), writer.end); + try header.encode(&writer); + try hyperlink.encode(link, &writer); - var empty_reader: std.Io.Reader = .fixed(&.{}); - var decoded_styles: [0]terminal_style.Style = .{}; - var decoded_hyperlinks: [1]terminal_hyperlink.Hyperlink = undefined; - var decoded_strings: [3]u8 = undefined; + var reader: std.Io.Reader = .fixed(writer.buffered()); try std.testing.expectError( - error.HyperlinkCountMismatch, - Payload.decode(header, &empty_reader, .{ - .styles = &decoded_styles, - .hyperlinks = &decoded_hyperlinks, - .hyperlink_strings = &decoded_strings, - }), + error.InvalidStringCapacity, + decode(&reader), ); } From 406f5e7d82466e4bee0a3b1289df9905053b1968 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 27 Jul 2026 13:37:09 -0700 Subject: [PATCH 06/35] terminal/snapshot: encode sparse page grids --- src/terminal/snapshot/grid.zig | 562 +++++++++++++++++++++++ src/terminal/snapshot/hyperlink.zig | 15 +- src/terminal/snapshot/main.zig | 5 + src/terminal/snapshot/page.zig | 668 +++++++++++++++++++++++----- 4 files changed, 1135 insertions(+), 115 deletions(-) create mode 100644 src/terminal/snapshot/grid.zig diff --git a/src/terminal/snapshot/grid.zig b/src/terminal/snapshot/grid.zig new file mode 100644 index 000000000..18d45b838 --- /dev/null +++ b/src/terminal/snapshot/grid.zig @@ -0,0 +1,562 @@ +//! 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 invalid in snapshot version 0. +//! +//! ## 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 invalid in snapshot version 0. +//! +//! 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 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; + +/// Encode every row and cell directly from a page. +pub fn encode( + page: *const TerminalPage, + writer: *std.Io.Writer, +) EncodeError!void { + // Encoding assumes a structurally valid page. This assertion performs the + // comprehensive check in slow-safety builds without adding an allocator to + // the encoder API. + 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 = switch (row.semantic_prompt) { + .none => .none, + .prompt => .prompt, + .prompt_continuation => .prompt_continuation, + }, + }; + try writer.writeByte(@bitCast(row_header)); + + // Cells + const cells = page.getCells(row); + for (cells) |*cell| { + 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, + ); + } + } +} + +pub const DecodeError = CellHeader.DecodeError || error{ + /// A row contains undefined flag values. + InvalidRow, + + /// A cell contains an undefined kind, width, flag, or reserved value. + InvalidCell, + + /// A grapheme suffix is invalid or attached to non-codepoint content. + InvalidGrapheme, + + /// The advertised grapheme capacity cannot hold the encoded suffixes. + InvalidGraphemeCapacity, + + /// A codepoint is not a Unicode scalar value. + InvalidCodepoint, + + /// A packed background color has nonzero reserved bytes. + InvalidColor, + + /// Wide and spacer cells do not form a valid row. + InvalidWideCell, + + /// The hyperlink map cannot hold the encoded cell references. + InvalidHyperlinkCapacity, + + /// PAGE records do not support Kitty graphics placeholders. + UnsupportedKittyGraphics, +}; + +/// 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. The page +/// must also have enough grapheme and hyperlink-map capacity for the decoded +/// contents. +/// +/// 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. +pub fn decode( + page: *TerminalPage, + reader: *std.Io.Reader, + style_remap: *const StyleRemap, + hyperlink_remap: *const HyperlinkRemap, +) DecodeError!void { + for (0..page.size.rows) |y| { + const row_header: RowHeader = @bitCast(try reader.takeByte()); + if (row_header._padding != 0) return error.InvalidRow; + const semantic_prompt: TerminalRow.SemanticPrompt = + switch (row_header.semantic_prompt) { + .none => .none, + .prompt => .prompt, + .prompt_continuation => .prompt_continuation, + .invalid => return error.InvalidRow, + }; + + const row = page.getRow(y); + row.wrap = row_header.wrap; + row.wrap_continuation = row_header.wrap_continuation; + row.semantic_prompt = semantic_prompt; + + const cells = page.getCells(row); + for (cells, 0..) |*cell, x| { + const header = try CellHeader.decode(reader); + + // 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; + + cell.* = .init(0); + switch (header.content_kind) { + .codepoint => { + const cp = std.math.cast(u21, header.value.codepoint) orelse + return error.InvalidCodepoint; + if (!validCodepoint(cp)) return error.InvalidCodepoint; + if (cp == kitty.graphics.unicode.placeholder) { + return error.UnsupportedKittyGraphics; + } + if (header.grapheme_count > 0 and cp == 0) { + return error.InvalidGrapheme; + } + cell.content = .{ .codepoint = .{ .data = cp } }; + }, + .bg_color_palette => { + const palette = header.value.bg_color_palette; + if (palette._padding != 0) return error.InvalidColor; + if (header.grapheme_count != 0) { + return error.InvalidGrapheme; + } + cell.content_tag = .bg_color_palette; + cell.content = .{ + .color_palette = .{ .data = palette.index }, + }; + }, + .bg_color_rgb => { + const rgb = header.value.bg_color_rgb; + if (rgb._padding != 0) return error.InvalidColor; + if (header.grapheme_count != 0) { + return error.InvalidGrapheme; + } + 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. + page.hyperlink_set.use(page.memory, hyperlink_id); + page.setHyperlink(row, cell, hyperlink_id) catch + return error.InvalidHyperlinkCapacity; + } + + // Append directly into page-owned grapheme storage so no + // intermediate suffix buffer is needed. + for (0..header.grapheme_count) |_| { + const suffix_raw = try io.readInt(reader, u32); + const suffix = std.math.cast(u21, suffix_raw) orelse + return error.InvalidCodepoint; + if (!validCodepoint(suffix)) return error.InvalidCodepoint; + if (suffix == kitty.graphics.unicode.placeholder) { + return error.UnsupportedKittyGraphics; + } + page.appendGrapheme(row, cell, suffix) catch + return error.InvalidGraphemeCapacity; + } + + switch (header.width) { + .narrow, .wide => {}, + .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; + }, + } + } + } +} + +/// The header before every row. +const RowHeader = packed struct(u8) { + wrap: bool = false, + wrap_continuation: bool = false, + semantic_prompt: SemanticPrompt = .none, + _padding: u4 = 0, + + const SemanticPrompt = enum(u2) { + none = 0, + prompt = 1, + prompt_continuation = 2, + invalid = 3, + }; +}; + +/// The fixed fields that precede a cell's grapheme suffix codepoints. +pub 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, + + /// Errors possible while decoding a fixed cell header. + pub const DecodeError = std.Io.Reader.Error || error{InvalidCell}; + + /// Encode the fixed cell header. + pub fn encode( + self: CellHeader, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + const semantic_content: Flags.SemanticContent = switch (self.semantic_content) { + .output => .output, + .input => .input, + .prompt => .prompt, + }; + const flags: Flags = .{ + .protected = self.protected, + .semantic_content = 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); + } + + /// Decode and validate the fixed cell header. + pub fn decode(reader: *std.Io.Reader) CellHeader.DecodeError!CellHeader { + const content_kind = std.enums.fromInt( + Kind, + try reader.takeByte(), + ) orelse return error.InvalidCell; + + const width = std.enums.fromInt( + TerminalCell.Wide, + try reader.takeByte(), + ) orelse return error.InvalidCell; + + const flags: Flags = @bitCast(try reader.takeByte()); + if (flags._padding != 0) return error.InvalidCell; + const semantic_content: TerminalCell.SemanticContent = switch (flags.semantic_content) { + .output => .output, + .input => .input, + .prompt => .prompt, + .invalid => return error.InvalidCell, + }; + + // This byte is reserved so the IDs and content value remain naturally + // aligned within the fixed cell header. + if (try reader.takeByte() != 0) return error.InvalidCell; + + return .{ + .content_kind = content_kind, + .width = width, + .protected = flags.protected, + .semantic_content = semantic_content, + .style_id = try io.readInt(reader, TerminalStyleId), + .hyperlink_id = try io.readInt(reader, TerminalHyperlinkId), + .value = @bitCast(try io.readInt(reader, u32)), + .grapheme_count = try io.readInt(reader, u32), + }; + } + + /// 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: SemanticContent = .output, + _padding: u5 = 0, + + const SemanticContent = enum(u2) { + output = 0, + input = 1, + prompt = 2, + invalid = 3, + }; + }; +}; + +fn validCodepoint(cp: u21) bool { + return cp <= 0x10FFFF and (cp < 0xD800 or cp > 0xDFFF); +} diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig index 01e36702c..5f2503c54 100644 --- a/src/terminal/snapshot/hyperlink.zig +++ b/src/terminal/snapshot/hyperlink.zig @@ -64,6 +64,9 @@ pub const DecodePageError = std.Io.Reader.Error || error{ /// The hyperlink kind is not defined by snapshot version 0. InvalidKind, + + /// The hyperlink value already exists in the page. + DuplicateHyperlink, }; /// Encode one hyperlink entry. @@ -136,7 +139,7 @@ pub fn decode( /// /// 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 page ID assigned to the entry. +/// the native ID assigned by the destination page. pub fn decodePage( page: *terminal_page.Page, reader: *std.Io.Reader, @@ -188,6 +191,14 @@ pub fn decodePage( }; errdefer entry.free(page); + if (page.hyperlink_set.lookupContext( + page.memory, + entry, + .{ .page = page }, + ) != null) { + return error.DuplicateHyperlink; + } + return page.hyperlink_set.addContext( page.memory, entry, @@ -219,7 +230,7 @@ fn decodePageString( .offset = terminal_size.getOffset( u8, page.memory, - value.ptr, + &value[0], ), }; } diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index 46921d7d4..fedcc4618 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -44,7 +44,12 @@ //! CONTINUATION, READY, and FINISH. pub const envelope = @import("envelope.zig"); +pub const grid = @import("grid.zig"); pub const hyperlink = @import("hyperlink.zig"); pub const page = @import("page.zig"); pub const record = @import("record.zig"); pub const style = @import("style.zig"); + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 6d5eb9dd4..1e18ced22 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -40,9 +40,9 @@ //! //! 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 wire -//! index zero and are not included in these counts. Encoded table entries -//! receive one-based wire indexes in their encoded order. +//! 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 @@ -51,14 +51,50 @@ //! //! ### Payload //! -//! This is still a work-in-progress. The current implementation encodes and -//! decodes the header, style table, and hyperlink table only. It does not yet -//! produce or consume a complete PAGE payload. +//! ```text +//! +---------------------------+ +//! | Header | +//! | 20 bytes | +//! +---------------------------+ +//! | Style table | +//! | style_count entries | +//! +---------------------------+ +//! | Hyperlink table | +//! | hyperlink_count entries | +//! +---------------------------+ +//! | Grid | +//! | one record per row | +//! +---------------------------+ //! -//! Following the header, data is tightly packed in the following order: -//! styles, hyperlinks, cells. TODO! +//! 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 grid = @import("grid.zig"); const hyperlink = @import("hyperlink.zig"); const io = @import("io.zig"); const style = @import("style.zig"); @@ -68,19 +104,23 @@ 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 TerminalHyperlinkSet = terminal_hyperlink.Set; +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 TerminalStyleSet = terminal_style.Set; -/// Errors possible while encoding the native PAGE prefix. -pub const EncodeError = hyperlink.EncodeError; +/// Errors possible while encoding a native PAGE. +pub const EncodeError = hyperlink.EncodeError || grid.EncodeError; -/// Errors possible while decoding the native PAGE prefix. +/// Errors possible while decoding a native PAGE. pub const DecodeError = style.DecodeError || + grid.DecodeError || Header.CapacityError || error{ /// The hyperlink kind is not defined by snapshot version 0. @@ -98,15 +138,15 @@ pub const DecodeError = style.DecodeError || /// The default style cannot appear in the non-default style table. DefaultStyle, + /// An encoded table ID is zero or appears more than once. + InvalidStyleId, + InvalidHyperlinkId, + /// Native page backing memory could not be allocated. OutOfMemory, }; -/// Encode the PAGE header and lookup tables directly from a native page. -/// -/// Only the currently implemented PAGE prefix is written. Rows and cells will -/// be appended by a later increment. The page is iterated in place and no -/// temporary storage is allocated or retained. +/// Encode a complete PAGE directly from a native page. pub fn encode( page: *const TerminalPage, writer: *std.Io.Writer, @@ -114,30 +154,31 @@ pub fn encode( // Write header try Header.init(page).encode(writer); - // Packed styles + // 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); } - // Packed hyperlinks + // 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 the PAGE header and lookup tables directly into a native page. +/// Decode a complete PAGE directly into a fresh native page. /// -/// The fixed header is validated before allocating the page. Style entries -/// are inserted directly into the page style set, while hyperlink strings are -/// read into the page string allocator before their native entries are -/// inserted. No caller-owned table or string buffers are required. -/// -/// Only the currently implemented PAGE prefix is consumed. Rows and cells -/// will be decoded by a later increment. +/// `alloc` is used only for the temporary native-ID remap tables. Styles, +/// hyperlinks, strings, graphemes, rows, and cells are stored in the page. pub fn decode( reader: *std.Io.Reader, + alloc: Allocator, ) DecodeError!TerminalPage { // Decode the header, validate capacities, init page const header = try Header.decode(reader); @@ -145,22 +186,65 @@ pub fn decode( var page = TerminalPage.init(capacity) catch return error.OutOfMemory; errdefer page.deinit(); + 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) |wire_index| { + for (0..header.style_count) |_| { + // Zero denotes the implicit default style. Reusing an encoded ID would + // make cell references ambiguous because it would name multiple table + // entries. + const native_id = try io.readInt(reader, TerminalStyleId); + if (native_id == 0 or style_remap.contains(native_id)) { + return error.InvalidStyleId; + } + + // Decode the style itself. It must never be the default style. const value = try style.decode(reader); if (value.default()) return error.DefaultStyle; - const native_id = page.styles.add( + // If we already have the style, its invalid. + if (page.styles.lookup( page.memory, value, - ) catch return error.InvalidStyleCapacity; - if (native_id != wire_index + 1) return error.DuplicateStyle; + ) != null) return error.DuplicateStyle; + + // Add our style, get our real ID on this side, and store it in the + // remap table. + const decoded_id = page.styles.add( + page.memory, + value, + ) catch |err| switch (err) { + error.OutOfMemory, + error.NeedsRehash, + => return error.InvalidStyleCapacity, + }; + style_remap.putAssumeCapacityNoClobber( + native_id, + decoded_id, + ); } // Hyperlinks - for (0..header.hyperlink_count) |wire_index| { - const native_id = hyperlink.decodePage( + for (0..header.hyperlink_count) |_| { + // Zero denotes no hyperlink. As with styles, a repeated encoded ID + // would make cell references ambiguous. + const native_id = try io.readInt(reader, TerminalHyperlinkId); + if (native_id == 0 or hyperlink_remap.contains(native_id)) { + return error.InvalidHyperlinkId; + } + + const decoded_id = hyperlink.decodePage( &page, reader, ) catch |err| switch (err) { @@ -168,11 +252,26 @@ pub fn decode( error.SetOutOfMemory, error.SetNeedsRehash, => return error.InvalidHyperlinkCapacity, - else => return err, + + error.DuplicateHyperlink => return error.DuplicateHyperlink, + error.InvalidKind => return error.InvalidKind, + error.EndOfStream => return error.EndOfStream, + error.ReadFailed => return error.ReadFailed, }; - if (native_id != wire_index + 1) return error.DuplicateHyperlink; + hyperlink_remap.putAssumeCapacityNoClobber( + native_id, + decoded_id, + ); } + // Rows and cells + try grid.decode( + &page, + reader, + &style_remap, + &hyperlink_remap, + ); + return page; } @@ -331,6 +430,21 @@ fn pageHyperlink( }; } +const test_page_fixture = + "\x03\x00\x02\x00\x02\x00\x02\x00\x08\x00\x00\x02\x80\x00\x00\x00" ++ + "\x00\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x01\x00\x00\x00\x03\x00\x00\x00\x00\x00\x01\x2a\x00\x00" ++ + "\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x01\x00\x00\x00\x61" ++ + "\x05\x00\x00\x00\x61\x6c\x70\x68\x61\x03\x00\x01\x04\x03\x02\x01" ++ + "\x04\x00\x00\x00\x62\x65\x74\x61\x04\x00\x01\x05\x00\x01\x00\x01" ++ + "\x00\x41\x00\x00\x00\x00\x00\x00\x00\x00\x02\x02\x00\x03\x00\x03" ++ + "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01" ++ + "\x00\x07\x00\x00\x00\x00\x00\x00\x00\x0b\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x78\x00\x00\x00\x02\x00\x00\x00\x01\x03\x00\x00\x02\x03" ++ + "\x00\x00\x02\x00\x01\x00\x00\x00\x00\x00\xaa\xbb\xcc\x00\x00\x00" ++ + "\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00"; + test "golden encoding" { const header: Header = .{ .columns = 0x0102, @@ -390,7 +504,7 @@ test "reject every truncation" { } } -test "encode native page lookup tables" { +test "encode and decode a sparse native page" { const capacity: TerminalPageCapacity = .{ .cols = 3, .rows = 2, @@ -453,6 +567,33 @@ test "encode native page lookup tables" { &.{ 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, @@ -468,97 +609,380 @@ test "encode native page lookup tables" { var counter: std.Io.Writer.Discarding = .init(&.{}); try encode(&page, &counter.writer); - var encoded: [128]u8 = undefined; + var encoded: [512]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try encode(&page, &writer); - const fixture = - "\x03\x00\x02\x00\x02\x00\x02\x00" ++ - "\x08\x00\x00\x02\x80\x00\x00\x00" ++ - "\x00\x01\x00\x00" ++ - "\x00\x00\x00\x00\x00\x00\x00\x00" ++ - "\x00\x00\x00\x00\x01\x00\x00\x00" ++ - "\x00\x00\x00\x00\x01\x2a\x00\x00" ++ - "\x00\x00\x00\x00\x00\x00\x00\x00" ++ - "\x02\x01\x00\x00\x00a\x05\x00\x00\x00alpha" ++ - "\x01\x04\x03\x02\x01\x04\x00\x00\x00beta"; + const fixture = test_page_fixture; try std.testing.expectEqualStrings(fixture, writer.buffered()); try std.testing.expectEqual(@as(u64, fixture.len), counter.count); -} -test "decode lookup tables directly into a native page" { - const header: Header = .{ - .columns = 80, - .rows = 24, - .style_count = 2, - .hyperlink_count = 2, - .style_capacity = 16, - .hyperlink_capacity_bytes = 512, - .grapheme_capacity_bytes = 128, - .string_capacity_bytes = 256, - }; - - const fixture = - "\x50\x00\x18\x00\x02\x00\x02\x00" ++ - "\x10\x00\x00\x02\x80\x00\x00\x00" ++ - "\x00\x01\x00\x00" ++ - "\x01\x2a\x00\x00\x00\x00\x00\x00" ++ - "\x00\x00\x00\x00\x01\x00\x00\x00" ++ - "\x00\x00\x00\x00\x02\xaa\xbb\xcc" ++ - "\x00\x00\x00\x00\x00\x02\x00\x00" ++ - "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri" ++ - "\x02\x02\x00\x00\x00id\x03\x00\x00\x00url"; - - var source: std.Io.Reader = .fixed(fixture); + var source: std.Io.Reader = .fixed(writer.buffered()); var read_buf: [1]u8 = undefined; var limited = source.limited(.unlimited, &read_buf); - var decoded = try decode(&limited.interface); + var decoded = try decode(&limited.interface, std.testing.allocator); defer decoded.deinit(); try std.testing.expectEqual(header, Header.init(&decoded)); try decoded.verifyIntegrity(std.testing.allocator); var style_it = decoded.styles.iterator(decoded.memory); - const style_a = style_it.next().?; - try std.testing.expectEqual(@as(TerminalStyleId, 1), style_a.id); + const decoded_style_a = style_it.next().?; + try std.testing.expectEqual(@as(TerminalStyleId, 1), decoded_style_a.id); try std.testing.expect((TerminalStyle{ - .fg_color = .{ .palette = 42 }, .flags = .{ .bold = true }, - }).eql(style_a.value_ptr.*)); - const style_b = style_it.next().?; - try std.testing.expectEqual(@as(TerminalStyleId, 2), style_b.id); + }).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 = .{ .rgb = .{ - .r = 0xaa, - .g = 0xbb, - .b = 0xcc, - } }, - .flags = .{ .underline = .double }, - }).eql(style_b.value_ptr.*)); + .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 hyperlink_a = pageHyperlink( - &decoded, - hyperlink_it.next().?.value_ptr, - ); - try std.testing.expectEqual( - @as(u32, 0x01020304), - hyperlink_a.id.implicit, - ); - try std.testing.expectEqualStrings("uri", hyperlink_a.uri); - const hyperlink_b = pageHyperlink( - &decoded, - hyperlink_it.next().?.value_ptr, - ); - try std.testing.expectEqualStrings("id", hyperlink_b.id.explicit); - try std.testing.expectEqualStrings("url", hyperlink_b.uri); + 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()); - var reencoded: [fixture.len]u8 = undefined; - var writer: std.Io.Writer = .fixed(&reencoded); - try encode(&decoded, &writer); - try std.testing.expectEqualStrings(fixture, writer.buffered()); + const decoded_first = decoded.getRowAndCell(0, 0); + try std.testing.expectEqual(@as(u21, 'A'), decoded_first.cell.codepoint()); + try std.testing.expectEqual(TerminalCell.Wide.wide, decoded_first.cell.wide); + try std.testing.expect(decoded_first.cell.protected); + try std.testing.expectEqual( + TerminalCell.SemanticContent.prompt, + decoded_first.cell.semantic_content, + ); + 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(TerminalCell.Wide.spacer_tail, decoded_second.cell.wide); + try std.testing.expectEqual(@as(TerminalStyleId, 2), decoded_second.cell.style_id); + try std.testing.expectEqual( + @as(TerminalHyperlinkId, 2), + decoded.lookupHyperlink(decoded_second.cell).?, + ); + + const decoded_grapheme = decoded.getRowAndCell(0, 1); + try std.testing.expectEqualSlices( + u21, + &.{ 0x0301, 0x0302 }, + decoded.lookupGrapheme(decoded_grapheme.cell).?, + ); + + const decoded_rgb = decoded.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, + ); + + const decoded_spacer_head = decoded.getRowAndCell(2, 1); + try std.testing.expectEqual( + TerminalCell.Wide.spacer_head, + decoded_spacer_head.cell.wide, + ); + try std.testing.expect(decoded_spacer_head.row.wrap); + try std.testing.expect(decoded_spacer_head.row.wrap_continuation); + try std.testing.expectEqual( + TerminalRow.SemanticPrompt.prompt_continuation, + decoded_spacer_head.row.semantic_prompt, + ); + + var reencoded: [512]u8 = undefined; + var rewriter: std.Io.Writer = .fixed(&reencoded); + try encode(&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 decode( + &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 encode(&decoded_again, &rewriter_again); + try std.testing.expectEqualStrings( + rewriter.buffered(), + rewriter_again.buffered(), + ); +} + +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, + decode(&reader, std.testing.allocator), + ); + } +} + +test "decode accepts unordered sparse style IDs and rejects 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 + + grid.CellHeader.len + ]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 grid.CellHeader.encode(.{}, &descending_writer); + + var descending_reader: std.Io.Reader = .fixed( + descending_writer.buffered(), + ); + var decoded_descending = try decode( + &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]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_reader: std.Io.Reader = .fixed(zero_writer.buffered()); + try std.testing.expectError( + error.InvalidStyleId, + decode(&zero_reader, std.testing.allocator), + ); +} + +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 = 0, + }; + + const first: TerminalHyperlink = .{ + .id = .{ .implicit = 1 }, + .uri = "", + }; + const second: TerminalHyperlink = .{ + .id = .{ .implicit = 2 }, + .uri = "", + }; + + var encoded: [ + Header.len + + 2 * 11 + + 1 + + grid.CellHeader.len + ]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 grid.CellHeader.encode(.{}, &writer); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + var decoded = try decode( + &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" { + 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 decode( + &style_reader, + std.testing.allocator, + ); + defer style_page.deinit(); + try std.testing.expectEqual( + @as(TerminalStyleId, 0), + style_page.getRowAndCell(0, 0).cell.style_id, + ); + + 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 decode( + &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 rejects undefined row and cell values" { + const header: Header = .{ + .columns = 1, + .rows = 1, + .style_count = 0, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, + }; + + var invalid_row: [Header.len + 1]u8 = undefined; + var row_writer: std.Io.Writer = .fixed(&invalid_row); + try header.encode(&row_writer); + try row_writer.writeByte(0x10); + + var row_reader: std.Io.Reader = .fixed(row_writer.buffered()); + try std.testing.expectError( + error.InvalidRow, + decode(&row_reader, std.testing.allocator), + ); + + var invalid_cell: [Header.len + 2]u8 = undefined; + var cell_writer: std.Io.Writer = .fixed(&invalid_cell); + try header.encode(&cell_writer); + try cell_writer.writeByte(0); + try cell_writer.writeByte(3); + + var cell_reader: std.Io.Reader = .fixed(cell_writer.buffered()); + try std.testing.expectError( + error.InvalidCell, + decode(&cell_reader, std.testing.allocator), + ); + + var invalid_codepoint: [Header.len + 1 + 16]u8 = undefined; + var codepoint_writer: std.Io.Writer = .fixed(&invalid_codepoint); + try header.encode(&codepoint_writer); + try codepoint_writer.writeByte(0); + try codepoint_writer.writeAll(&.{ 0, 0, 0, 0 }); + try io.writeInt(&codepoint_writer, TerminalStyleId, 0); + try io.writeInt(&codepoint_writer, TerminalHyperlinkId, 0); + try io.writeInt(&codepoint_writer, u32, 0xD800); + try io.writeInt(&codepoint_writer, u32, 0); + + var codepoint_reader: std.Io.Reader = .fixed( + codepoint_writer.buffered(), + ); + try std.testing.expectError( + error.InvalidCodepoint, + decode(&codepoint_reader, std.testing.allocator), + ); + + var invalid_color: [Header.len + 1 + 16]u8 = undefined; + var color_writer: std.Io.Writer = .fixed(&invalid_color); + try header.encode(&color_writer); + try color_writer.writeByte(0); + try color_writer.writeAll(&.{ 1, 0, 0, 0 }); + try io.writeInt(&color_writer, TerminalStyleId, 0); + try io.writeInt(&color_writer, TerminalHyperlinkId, 0); + try io.writeInt(&color_writer, u32, 0x100); + try io.writeInt(&color_writer, u32, 0); + + var color_reader: std.Io.Reader = .fixed(color_writer.buffered()); + try std.testing.expectError( + error.InvalidColor, + decode(&color_reader, std.testing.allocator), + ); } test "decode validates dimensions and native table capacities" { @@ -623,7 +1047,10 @@ test "decode validates dimensions and native table capacities" { try case.header.encode(&writer); var reader: std.Io.Reader = .fixed(writer.buffered()); - try std.testing.expectError(case.expected, decode(&reader)); + try std.testing.expectError( + case.expected, + decode(&reader, std.testing.allocator), + ); } } @@ -642,14 +1069,19 @@ test "decode rejects duplicate and default style entries" { .flags = .{ .bold = true }, }; - var encoded: [Header.len + 2 * style.len]u8 = undefined; + var encoded: [Header.len + 2 * (2 + style.len)]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try header.encode(&writer); + try io.writeInt(&writer, TerminalStyleId, 1); try style.encode(duplicate_style, &writer); + try io.writeInt(&writer, TerminalStyleId, 3); try style.encode(duplicate_style, &writer); var reader: std.Io.Reader = .fixed(writer.buffered()); - try std.testing.expectError(error.DuplicateStyle, decode(&reader)); + try std.testing.expectError( + error.DuplicateStyle, + decode(&reader, std.testing.allocator), + ); const default_header: Header = .{ .columns = 80, @@ -661,13 +1093,17 @@ test "decode rejects duplicate and default style entries" { .grapheme_capacity_bytes = 0, .string_capacity_bytes = 0, }; - var default_encoded: [Header.len + style.len]u8 = undefined; + var default_encoded: [Header.len + 2 + style.len]u8 = undefined; var default_writer: std.Io.Writer = .fixed(&default_encoded); try default_header.encode(&default_writer); + try io.writeInt(&default_writer, TerminalStyleId, 1); try style.encode(.{}, &default_writer); var default_reader: std.Io.Reader = .fixed(default_writer.buffered()); - try std.testing.expectError(error.DefaultStyle, decode(&default_reader)); + try std.testing.expectError( + error.DefaultStyle, + decode(&default_reader, std.testing.allocator), + ); } test "decode rejects duplicate hyperlinks with empty strings" { @@ -686,14 +1122,19 @@ test "decode rejects duplicate hyperlinks with empty strings" { .uri = "", }; - var encoded: [Header.len + 18]u8 = undefined; + var encoded: [Header.len + 22]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try header.encode(&writer); + try io.writeInt(&writer, TerminalHyperlinkId, 1); try hyperlink.encode(duplicate, &writer); + try io.writeInt(&writer, TerminalHyperlinkId, 3); try hyperlink.encode(duplicate, &writer); var reader: std.Io.Reader = .fixed(writer.buffered()); - try std.testing.expectError(error.DuplicateHyperlink, decode(&reader)); + try std.testing.expectError( + error.DuplicateHyperlink, + decode(&reader, std.testing.allocator), + ); } test "decode reads hyperlink strings into page storage" { @@ -716,14 +1157,15 @@ test "decode reads hyperlink strings into page storage" { .string_capacity_bytes = 0, }; - var encoded: [Header.len + 14]u8 = undefined; + var encoded: [Header.len + 16]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try header.encode(&writer); + try io.writeInt(&writer, TerminalHyperlinkId, 1); try hyperlink.encode(link, &writer); var reader: std.Io.Reader = .fixed(writer.buffered()); try std.testing.expectError( error.InvalidStringCapacity, - decode(&reader), + decode(&reader, std.testing.allocator), ); } From 83ffa74e2b9057614a1f204747faa4d54a742012 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 28 Jul 2026 06:27:45 -0700 Subject: [PATCH 07/35] terminal/snapshot: page records --- src/terminal/snapshot/main.zig | 24 +++ src/terminal/snapshot/page.zig | 275 +++++++++++++++++++++++++++---- src/terminal/snapshot/record.zig | 241 +++++++++++++++++++-------- 3 files changed, 440 insertions(+), 100 deletions(-) diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index fedcc4618..20bc2d41c 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -42,6 +42,30 @@ //! Records have a strict order: TERMINAL, the primary SCREEN and its //! PAGE records, an optional alternate SCREEN and its PAGE records, //! CONTINUATION, READY, and FINISH. +//! +//! ## Encoding +//! +//! Encode the envelope once, then append records in the required order: +//! +//! ```zig +//! var output: std.Io.Writer.Allocating = .init(alloc); +//! defer output.deinit(); +//! +//! try envelope.encode(&output.writer); +//! try page.encode(&terminal_page, &output); +//! +//! const snapshot = output.written(); +//! ``` +//! +//! Record codecs reserve and backpatch their own framing, so callers do not +//! calculate payload lengths or checksums. `written` borrows the completed +//! bytes from the allocating writer. Use `toOwnedSlice` instead when ownership +//! must be transferred to the caller. +//! +//! `page.encode` currently appends one complete framed PAGE record. Its +//! lower-level payload codec is intentionally private. `page.decode` consumes +//! and validates one complete PAGE record, including its payload boundary, +//! checksum, and restored page integrity. pub const envelope = @import("envelope.zig"); pub const grid = @import("grid.zig"); diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 1e18ced22..b6324860d 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -94,9 +94,11 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const envelope = @import("envelope.zig"); const grid = @import("grid.zig"); const hyperlink = @import("hyperlink.zig"); const io = @import("io.zig"); +const record = @import("record.zig"); const style = @import("style.zig"); const terminal_hyperlink = @import("../hyperlink.zig"); const terminal_page = @import("../page.zig"); @@ -115,11 +117,9 @@ const TerminalStyle = terminal_style.Style; const TerminalStyleId = terminal_style.Id; const TerminalStyleSet = terminal_style.Set; -/// Errors possible while encoding a native PAGE. -pub const EncodeError = hyperlink.EncodeError || grid.EncodeError; +const PayloadEncodeError = hyperlink.EncodeError || grid.EncodeError; -/// Errors possible while decoding a native PAGE. -pub const DecodeError = style.DecodeError || +const PayloadDecodeError = style.DecodeError || grid.DecodeError || Header.CapacityError || error{ @@ -146,11 +146,59 @@ pub const DecodeError = style.DecodeError || OutOfMemory, }; -/// Encode a complete PAGE directly from a native page. +/// 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 record is appended to `destination`. Its header is reserved before the +/// payload is encoded, then backpatched with the payload length and CRC32C. If +/// encoding fails, the partial record is removed while earlier bytes remain. pub fn encode( page: *const TerminalPage, - writer: *std.Io.Writer, + destination: *std.Io.Writer.Allocating, ) EncodeError!void { + var record_writer = try record.Writer.init(destination, .page); + errdefer record_writer.cancel(); + try encodePayload(page, record_writer.payloadWriter()); + try record_writer.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 record_reader: record.Reader = undefined; + try record_reader.init(reader); + if (record_reader.header.tag != .page) return error.UnexpectedRecordTag; + + var page = try decodePayload(record_reader.payloadReader(), alloc); + errdefer page.deinit(); + try record_reader.finish(); + try page.verifyIntegrity(alloc); + return page; +} + +/// 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); @@ -172,14 +220,14 @@ pub fn encode( try grid.encode(page, writer); } -/// Decode a complete PAGE directly into a fresh native page. +/// 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. -pub fn decode( +fn decodePayload( reader: *std.Io.Reader, alloc: Allocator, -) DecodeError!TerminalPage { +) PayloadDecodeError!TerminalPage { // Decode the header, validate capacities, init page const header = try Header.decode(reader); const capacity = try header.pageCapacity(); @@ -445,6 +493,18 @@ const test_page_fixture = "\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ++ "\x00\x00"; +const test_empty_framed_page_fixture = + // Record header: PAGE, 37-byte payload, CRC32C. + "\x03\x00\x25\x00\x00\x00\x8c\x05\xd6\xd3" ++ + // PAGE header: one column, one row, and zero counts/capacities. + "\x01\x00\x01\x00\x00\x00\x00\x00" ++ + "\x00\x00\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x00\x00" ++ + // One default row and one empty cell. + "\x00\x00\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x00\x00\x00\x00\x00\x00" ++ + "\x00"; + test "golden encoding" { const header: Header = .{ .columns = 0x0102, @@ -504,7 +564,7 @@ test "reject every truncation" { } } -test "encode and decode a sparse native page" { +test "framed PAGE encode and decode a sparse native page" { const capacity: TerminalPageCapacity = .{ .cols = 3, .rows = 2, @@ -607,11 +667,11 @@ test "encode and decode a sparse native page" { try std.testing.expectEqual(header, Header.init(&page)); var counter: std.Io.Writer.Discarding = .init(&.{}); - try encode(&page, &counter.writer); + try encodePayload(&page, &counter.writer); var encoded: [512]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); - try encode(&page, &writer); + try encodePayload(&page, &writer); const fixture = test_page_fixture; try std.testing.expectEqualStrings(fixture, writer.buffered()); @@ -620,7 +680,10 @@ test "encode and decode a sparse native page" { var source: std.Io.Reader = .fixed(writer.buffered()); var read_buf: [1]u8 = undefined; var limited = source.limited(.unlimited, &read_buf); - var decoded = try decode(&limited.interface, std.testing.allocator); + var decoded = try decodePayload( + &limited.interface, + std.testing.allocator, + ); defer decoded.deinit(); try std.testing.expectEqual(header, Header.init(&decoded)); @@ -702,7 +765,7 @@ test "encode and decode a sparse native page" { var reencoded: [512]u8 = undefined; var rewriter: std.Io.Writer = .fixed(&reencoded); - try encode(&decoded, &rewriter); + try encodePayload(&decoded, &rewriter); try std.testing.expect(!std.mem.eql( u8, writer.buffered(), @@ -710,7 +773,7 @@ test "encode and decode a sparse native page" { )); var reencoded_reader: std.Io.Reader = .fixed(rewriter.buffered()); - var decoded_again = try decode( + var decoded_again = try decodePayload( &reencoded_reader, std.testing.allocator, ); @@ -719,11 +782,161 @@ test "encode and decode a sparse native page" { var reencoded_again: [512]u8 = undefined; var rewriter_again: std.Io.Writer = .fixed(&reencoded_again); - try encode(&decoded_again, &rewriter_again); + try encodePayload(&decoded_again, &rewriter_again); try std.testing.expectEqualStrings( rewriter.buffered(), rewriter_again.buffered(), ); + + // The public codec appends one complete PAGE record after the envelope. + var snapshot: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer snapshot.deinit(); + try envelope.encode(&snapshot.writer); + try encode(&page, &snapshot); + + const snapshot_bytes = snapshot.written(); + try std.testing.expectEqualStrings( + test_page_fixture, + snapshot_bytes[envelope.encoded_len + record.Header.len ..], + ); + + var snapshot_reader: std.Io.Reader = .fixed(snapshot_bytes); + try envelope.decode(&snapshot_reader); + var framed_page = try decode( + &snapshot_reader, + std.testing.allocator, + ); + defer framed_page.deinit(); + + try std.testing.expectEqual(header, Header.init(&framed_page)); + const framed_first = framed_page.getRowAndCell(0, 0); + try std.testing.expectEqual(@as(u21, 'A'), framed_first.cell.codepoint()); + try std.testing.expectEqual( + TerminalCell.SemanticContent.prompt, + framed_first.cell.semantic_content, + ); + try std.testing.expectEqualSlices( + u21, + &.{ 0x0301, 0x0302 }, + framed_page.lookupGrapheme( + framed_page.getRowAndCell(0, 1).cell, + ).?, + ); +} + +test "framed PAGE golden empty record" { + 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(); + try encode(&page, &destination); + try std.testing.expectEqualStrings( + test_empty_framed_page_fixture, + destination.written(), + ); + + var source: std.Io.Reader = .fixed(destination.written()); + var decoded = try decode(&source, std.testing.allocator); + defer decoded.deinit(); + try std.testing.expectEqual( + Header.init(&page), + Header.init(&decoded), + ); +} + +test "framed PAGE validates tag length checksum and exhaustion" { + { + 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), + ); + } + + { + var invalid_checksum = test_empty_framed_page_fixture.*; + invalid_checksum[6] ^= 1; + var reader: std.Io.Reader = .fixed(&invalid_checksum); + try std.testing.expectError( + error.InvalidChecksum, + decode(&reader, std.testing.allocator), + ); + } + + { + var invalid_payload = test_empty_framed_page_fixture.*; + const value_offset = record.Header.len + + Header.len + + 1 + + 8; + invalid_payload[value_offset] = 'A'; + var reader: std.Io.Reader = .fixed(&invalid_payload); + try std.testing.expectError( + error.InvalidChecksum, + decode(&reader, std.testing.allocator), + ); + } + + { + var short_payload = test_empty_framed_page_fixture.*; + std.mem.writeInt(u32, short_payload[2..6], 36, .little); + var reader: std.Io.Reader = .fixed(&short_payload); + try std.testing.expectError( + error.EndOfStream, + decode(&reader, std.testing.allocator), + ); + } + + { + var trailing: [test_empty_framed_page_fixture.len + 1]u8 = undefined; + @memcpy( + trailing[0..test_empty_framed_page_fixture.len], + test_empty_framed_page_fixture, + ); + trailing[trailing.len - 1] = 0; + std.mem.writeInt(u32, trailing[2..6], 38, .little); + + var checksum: record.Checksum = .init(.page, 38); + try checksum.writer().writeAll(trailing[record.Header.len..]); + std.mem.writeInt(u32, trailing[6..10], checksum.final(), .little); + + var reader: std.Io.Reader = .fixed(&trailing); + try std.testing.expectError( + error.PayloadNotExhausted, + decode(&reader, std.testing.allocator), + ); + } +} + +test "framed PAGE rejects every truncation and preserves following bytes" { + for (0..test_empty_framed_page_fixture.len) |len| { + var reader: std.Io.Reader = .fixed( + test_empty_framed_page_fixture[0..len], + ); + if (decode(&reader, std.testing.allocator)) |decoded_value| { + var unexpected = decoded_value; + unexpected.deinit(); + return error.ExpectedDecodeFailure; + } else |_| {} + } + + 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" { @@ -731,7 +944,7 @@ test "decode sparse page rejects every truncation" { var reader: std.Io.Reader = .fixed(test_page_fixture[0..len]); try std.testing.expectError( error.EndOfStream, - decode(&reader, std.testing.allocator), + decodePayload(&reader, std.testing.allocator), ); } } @@ -766,7 +979,7 @@ test "decode accepts unordered sparse style IDs and rejects zero" { var descending_reader: std.Io.Reader = .fixed( descending_writer.buffered(), ); - var decoded_descending = try decode( + var decoded_descending = try decodePayload( &descending_reader, std.testing.allocator, ); @@ -793,7 +1006,7 @@ test "decode accepts unordered sparse style IDs and rejects zero" { var zero_reader: std.Io.Reader = .fixed(zero_writer.buffered()); try std.testing.expectError( error.InvalidStyleId, - decode(&zero_reader, std.testing.allocator), + decodePayload(&zero_reader, std.testing.allocator), ); } @@ -834,7 +1047,7 @@ test "decode accepts unordered sparse hyperlink IDs" { try grid.CellHeader.encode(.{}, &writer); var reader: std.Io.Reader = .fixed(writer.buffered()); - var decoded = try decode( + var decoded = try decodePayload( &reader, std.testing.allocator, ); @@ -868,7 +1081,7 @@ test "decode defaults missing sparse cell references" { try io.writeInt(&style_writer, u32, 0); var style_reader: std.Io.Reader = .fixed(style_writer.buffered()); - var style_page = try decode( + var style_page = try decodePayload( &style_reader, std.testing.allocator, ); @@ -902,7 +1115,7 @@ test "decode defaults missing sparse cell references" { var hyperlink_reader: std.Io.Reader = .fixed( hyperlink_writer.buffered(), ); - var hyperlink_page = try decode( + var hyperlink_page = try decodePayload( &hyperlink_reader, std.testing.allocator, ); @@ -935,7 +1148,7 @@ test "decode rejects undefined row and cell values" { var row_reader: std.Io.Reader = .fixed(row_writer.buffered()); try std.testing.expectError( error.InvalidRow, - decode(&row_reader, std.testing.allocator), + decodePayload(&row_reader, std.testing.allocator), ); var invalid_cell: [Header.len + 2]u8 = undefined; @@ -947,7 +1160,7 @@ test "decode rejects undefined row and cell values" { var cell_reader: std.Io.Reader = .fixed(cell_writer.buffered()); try std.testing.expectError( error.InvalidCell, - decode(&cell_reader, std.testing.allocator), + decodePayload(&cell_reader, std.testing.allocator), ); var invalid_codepoint: [Header.len + 1 + 16]u8 = undefined; @@ -965,7 +1178,7 @@ test "decode rejects undefined row and cell values" { ); try std.testing.expectError( error.InvalidCodepoint, - decode(&codepoint_reader, std.testing.allocator), + decodePayload(&codepoint_reader, std.testing.allocator), ); var invalid_color: [Header.len + 1 + 16]u8 = undefined; @@ -981,7 +1194,7 @@ test "decode rejects undefined row and cell values" { var color_reader: std.Io.Reader = .fixed(color_writer.buffered()); try std.testing.expectError( error.InvalidColor, - decode(&color_reader, std.testing.allocator), + decodePayload(&color_reader, std.testing.allocator), ); } @@ -1049,7 +1262,7 @@ test "decode validates dimensions and native table capacities" { var reader: std.Io.Reader = .fixed(writer.buffered()); try std.testing.expectError( case.expected, - decode(&reader, std.testing.allocator), + decodePayload(&reader, std.testing.allocator), ); } } @@ -1080,7 +1293,7 @@ test "decode rejects duplicate and default style entries" { var reader: std.Io.Reader = .fixed(writer.buffered()); try std.testing.expectError( error.DuplicateStyle, - decode(&reader, std.testing.allocator), + decodePayload(&reader, std.testing.allocator), ); const default_header: Header = .{ @@ -1102,7 +1315,7 @@ test "decode rejects duplicate and default style entries" { var default_reader: std.Io.Reader = .fixed(default_writer.buffered()); try std.testing.expectError( error.DefaultStyle, - decode(&default_reader, std.testing.allocator), + decodePayload(&default_reader, std.testing.allocator), ); } @@ -1133,7 +1346,7 @@ test "decode rejects duplicate hyperlinks with empty strings" { var reader: std.Io.Reader = .fixed(writer.buffered()); try std.testing.expectError( error.DuplicateHyperlink, - decode(&reader, std.testing.allocator), + decodePayload(&reader, std.testing.allocator), ); } @@ -1166,6 +1379,6 @@ test "decode reads hyperlink strings into page storage" { var reader: std.Io.Reader = .fixed(writer.buffered()); try std.testing.expectError( error.InvalidStringCapacity, - decode(&reader, std.testing.allocator), + decodePayload(&reader, std.testing.allocator), ); } diff --git a/src/terminal/snapshot/record.zig b/src/terminal/snapshot/record.zig index 7ef9e5a7a..5a99f8607 100644 --- a/src/terminal/snapshot/record.zig +++ b/src/terminal/snapshot/record.zig @@ -141,24 +141,94 @@ pub const Checksum = struct { } }; -/// A checksum-verifying reader limited to one record payload. +/// Builds one complete record. /// -/// Initialize this only after decoding a `Header`, decode the payload through -/// `reader`, and then call `finish`. The caller owns both buffers and chooses -/// their sizes. They only need to remain valid until `finish` returns. -pub const PayloadReader = struct { +/// This Writer requires an Allocating std.Io.Writer because the record +/// format requires reading the full payload and rewinding in order to +/// write the length + CRC without encoding twice. +pub const Writer = struct { + destination: *std.Io.Writer.Allocating, + tag: Tag, + record_start: usize, + + /// Reserve space for a record at the current end of `destination`. + /// Once this is called, callers MUST NOT write anything else to + /// the writer until `finish` or `cancel` is called. + pub fn init( + destination: *std.Io.Writer.Allocating, + tag: Tag, + ) std.Io.Writer.Error!Writer { + const record_start = destination.written().len; + errdefer destination.shrinkRetainingCapacity(record_start); + try destination.writer.splatByteAll(0, Header.len); + return .{ + .destination = destination, + .tag = tag, + .record_start = record_start, + }; + } + + /// Return the writer through which the payload is encoded exactly once. + pub fn payloadWriter(self: *Writer) *std.Io.Writer { + return &self.destination.writer; + } + + pub const FinishError = error{ + /// The payload cannot be represented by the record's `u32` length. + PayloadTooLarge, + }; + + /// Marked the completed record with the payload length and CRC32C. + pub fn finish(self: *Writer) FinishError!void { + const bytes = self.destination.written(); + const payload_start = self.record_start + Header.len; + const payload_len = std.math.cast( + u32, + bytes.len - payload_start, + ) orelse return error.PayloadTooLarge; + const payload = bytes[payload_start..]; + + // Calculate our CRC + var checksum: Checksum = .init(self.tag, payload_len); + checksum.writer().writeAll(payload) catch unreachable; + + // Build the header and encode it directly into the header + const header: Header = .{ + .tag = self.tag, + .payload_len = payload_len, + .crc32c = checksum.final(), + }; + var header_writer: std.Io.Writer = .fixed(bytes[self.record_start..payload_start]); + header.encode(&header_writer) catch unreachable; + } + + /// Discard this record. This makes it safe to use the underlying + /// alloating writer again as if nothing happened. + pub fn cancel(self: *Writer) void { + self.destination.shrinkRetainingCapacity(self.record_start); + } +}; + +/// 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), - /// Caller-owned storage for the reader wrappers. - pub const Buffers = struct { - /// Buffer used to enforce the payload-length boundary. - limited: []u8, - - /// Buffer used while updating CRC32C over consumed payload bytes. - hashing: []u8, - }; + pub const InitError = Header.DecodeError; /// Errors detected after a payload decoder returns. pub const FinishError = error{ @@ -169,39 +239,40 @@ pub const PayloadReader = struct { PayloadNotExhausted, }; - /// Initialize a reader for the payload described by `header`. + /// Decode a record header and initialize its payload reader. /// - /// `self` and both buffers must remain at stable addresses until `finish` - /// returns. Decode only through the reader returned by `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: *PayloadReader, + self: *Reader, source: *std.Io.Reader, - header: Header, - buffers: Buffers, - ) void { + ) InitError!void { self.* = undefined; - self.header = header; + self.header = try Header.decode(source); self.limited = .init( source, - .limited(header.payload_len), - buffers.limited, + .limited(self.header.payload_len), + &self.limited_buffer, ); - const checksum: Checksum = .init(header.tag, header.payload_len); + const checksum: Checksum = .init( + self.header.tag, + self.header.payload_len, + ); self.hashing = .init( &self.limited.interface, checksum.hashing.hasher, - buffers.hashing, + &self.hashing_buffer, ); } /// Return the length-limited, checksum-updating payload reader. - pub fn reader(self: *PayloadReader) *std.Io.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: *PayloadReader) FinishError!void { + pub fn finish(self: *Reader) FinishError!void { if (self.hashing.reader.bufferedLen() != 0 or self.limited.remaining != .nothing) { @@ -282,7 +353,7 @@ test "reject every header truncation" { } } -test "payload reader verifies exhaustion and checksum" { +test "record reader verifies exhaustion and checksum" { const payload = "snapshot payload"; var checksum: Checksum = .init(.page, payload.len); try checksum.writer().writeAll(payload); @@ -292,23 +363,22 @@ test "payload reader verifies exhaustion and checksum" { .crc32c = checksum.final(), }; - var source: std.Io.Reader = .fixed(payload ++ "next"); - var payload_reader: PayloadReader = undefined; - var limited_buf: [1]u8 = undefined; - var hashing_buf: [1]u8 = undefined; - payload_reader.init(&source, header, .{ - .limited = &limited_buf, - .hashing = &hashing_buf, - }); + 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 payload_reader.reader().readSliceAll(&decoded); - try payload_reader.finish(); + 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 "payload reader rejects remaining bytes and invalid checksum" { +test "record reader rejects remaining bytes and invalid checksum" { const payload = "payload"; const header: Header = .{ .tag = .page, @@ -316,35 +386,30 @@ test "payload reader rejects remaining bytes and invalid checksum" { .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(payload); - var payload_reader: PayloadReader = undefined; - var limited_buf: [1]u8 = undefined; - var hashing_buf: [1]u8 = undefined; - payload_reader.init(&source, header, .{ - .limited = &limited_buf, - .hashing = &hashing_buf, - }); - _ = try payload_reader.reader().takeByte(); + 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, - payload_reader.finish(), + record_reader.finish(), ); } { - var source: std.Io.Reader = .fixed(payload); - var payload_reader: PayloadReader = undefined; - var limited_buf: [1]u8 = undefined; - var hashing_buf: [1]u8 = undefined; - payload_reader.init(&source, header, .{ - .limited = &limited_buf, - .hashing = &hashing_buf, - }); - try payload_reader.reader().discardAll(payload.len); + 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, - payload_reader.finish(), + record_reader.finish(), ); } } @@ -359,20 +424,58 @@ test "payload limit does not consume the next record" { .crc32c = checksum.final(), }; - var source: std.Io.Reader = .fixed(payload ++ "next"); - var payload_reader: PayloadReader = undefined; - var limited_buf: [1]u8 = undefined; - var hashing_buf: [1]u8 = undefined; - payload_reader.init(&source, header, .{ - .limited = &limited_buf, - .hashing = &hashing_buf, - }); + 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, - payload_reader.reader().readSliceAll(&too_long), + record_reader.payloadReader().readSliceAll(&too_long), ); - try payload_reader.finish(); + try record_reader.finish(); try std.testing.expectEqualStrings("next", try source.take(4)); } + +test "record writer appends and backpatches framing" { + var destination: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer destination.deinit(); + try destination.writer.writeAll("prefix"); + + var record_writer = try Writer.init(&destination, .page); + try record_writer.payloadWriter().writeAll("payload"); + try record_writer.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 record_writer = try Writer.init(&destination, .page); + try record_writer.payloadWriter().writeAll("partial"); + record_writer.cancel(); + + try std.testing.expectEqualStrings("prefix", destination.written()); +} From e8e56e782c7cfa277839b489d5b13396f8bbe24b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 28 Jul 2026 07:28:44 -0700 Subject: [PATCH 08/35] terminal/snapshot: small edits --- src/terminal/snapshot/envelope.zig | 29 ++++++++----- src/terminal/snapshot/grid.zig | 32 +++++++------- src/terminal/snapshot/hyperlink.zig | 38 ++++------------- src/terminal/snapshot/io.zig | 2 + src/terminal/snapshot/main.zig | 2 +- src/terminal/snapshot/page.zig | 65 ++++++++++++----------------- src/terminal/snapshot/record.zig | 18 +------- src/terminal/snapshot/style.zig | 8 ++-- 8 files changed, 77 insertions(+), 117 deletions(-) diff --git a/src/terminal/snapshot/envelope.zig b/src/terminal/snapshot/envelope.zig index 7a3496676..726167391 100644 --- a/src/terminal/snapshot/envelope.zig +++ b/src/terminal/snapshot/envelope.zig @@ -13,22 +13,22 @@ //! //! | Offset | Size | Field | //! | -----: | ---: | :------------------- | -//! | 0 | 8 | Magic (`BOOSNAP\0`) | +//! | 0 | 8 | Magic (`GHOSTSNP`) | //! | 8 | 2 | Version (`u16`) | const std = @import("std"); const io = @import("io.zig"); /// Identifies a Ghostty terminal snapshot and rejects unrelated input before -/// any record decoding begins. The trailing NUL is part of the wire value. -pub const magic = "BOOSNAP\x00"; +/// 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 0 readers require this value to match exactly. -pub const version: u16 = 0; +/// 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 = magic.len + @sizeOf(@TypeOf(version)); +pub const encoded_len = computeLen(); comptime { // We expect this so if it changes we should think carefully. @@ -56,22 +56,31 @@ pub fn decode(reader: *std.Io.Reader) DecodeError!void { 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; + } +} + test "golden encoding" { var buf: [encoded_len]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); try encode(&writer); try std.testing.expectEqualStrings( - "BOOSNAP\x00\x00\x00", + "GHOSTSNP\x01\x00", writer.buffered(), ); } test "reject invalid magic and version" { - var invalid_magic: std.Io.Reader = .fixed("BOOSNAX\x00\x00\x00"); + 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("BOOSNAP\x00\x01\x00"); + var invalid_version: std.Io.Reader = .fixed("GHOSTSNP\x00\x00"); try std.testing.expectError( error.UnsupportedVersion, decode(&invalid_version), @@ -79,7 +88,7 @@ test "reject invalid magic and version" { } test "reject every truncation" { - const fixture = "BOOSNAP\x00\x00\x00"; + const fixture = "GHOSTSNP\x01\x00"; for (0..encoded_len) |len| { var reader: std.Io.Reader = .fixed(fixture[0..len]); try std.testing.expectError(error.EndOfStream, decode(&reader)); diff --git a/src/terminal/snapshot/grid.zig b/src/terminal/snapshot/grid.zig index 18d45b838..4f5604fe2 100644 --- a/src/terminal/snapshot/grid.zig +++ b/src/terminal/snapshot/grid.zig @@ -38,7 +38,7 @@ //! | 1 | Prompt | //! | 2 | Prompt continuation | //! -//! Value 3 is invalid in snapshot version 0. +//! Value 3 is invalid in snapshot version 1. //! //! ## Cell //! @@ -94,7 +94,7 @@ //! | 1 | Input | //! | 2 | Prompt | //! -//! Value 3 is invalid in snapshot version 0. +//! Value 3 is invalid in snapshot version 1. //! //! 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 @@ -122,20 +122,14 @@ const TerminalStyleId = terminal_style.Id; /// 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, -); +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 HyperlinkRemap = std.AutoHashMap(TerminalHyperlinkId, TerminalHyperlinkId); pub const EncodeError = std.Io.Writer.Error; @@ -306,7 +300,11 @@ pub fn decode( .codepoint => { const cp = std.math.cast(u21, header.value.codepoint) orelse return error.InvalidCodepoint; - if (!validCodepoint(cp)) return error.InvalidCodepoint; + if (cp > 0x10FFFF or + (cp >= 0xD800 and cp <= 0xDFFF)) + { + return error.InvalidCodepoint; + } if (cp == kitty.graphics.unicode.placeholder) { return error.UnsupportedKittyGraphics; } @@ -367,7 +365,11 @@ pub fn decode( const suffix_raw = try io.readInt(reader, u32); const suffix = std.math.cast(u21, suffix_raw) orelse return error.InvalidCodepoint; - if (!validCodepoint(suffix)) return error.InvalidCodepoint; + if (suffix > 0x10FFFF or + (suffix >= 0xD800 and suffix <= 0xDFFF)) + { + return error.InvalidCodepoint; + } if (suffix == kitty.graphics.unicode.placeholder) { return error.UnsupportedKittyGraphics; } @@ -406,7 +408,7 @@ const RowHeader = packed struct(u8) { }; /// The fixed fields that precede a cell's grapheme suffix codepoints. -pub const CellHeader = struct { +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(); @@ -556,7 +558,3 @@ pub const CellHeader = struct { }; }; }; - -fn validCodepoint(cp: u21) bool { - return cp <= 0x10FFFF and (cp < 0xD800 or cp > 0xDFFF); -} diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig index 5f2503c54..71b27558c 100644 --- a/src/terminal/snapshot/hyperlink.zig +++ b/src/terminal/snapshot/hyperlink.zig @@ -54,7 +54,7 @@ pub const EncodeError = std.Io.Writer.Error; /// 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 0. + /// The hyperlink kind is not defined by snapshot version 1. InvalidKind, }; @@ -62,7 +62,7 @@ pub const DecodeError = std.Io.Reader.Error || Allocator.Error || error{ pub const DecodePageError = std.Io.Reader.Error || terminal_page.Page.InsertHyperlinkError || error{ - /// The hyperlink kind is not defined by snapshot version 0. + /// The hyperlink kind is not defined by snapshot version 1. InvalidKind, /// The hyperlink value already exists in the page. @@ -268,18 +268,18 @@ test "golden explicit encoding" { } test "empty strings round trip" { - const values = .{ - terminal_hyperlink.Hyperlink{ + const values: [2]terminal_hyperlink.Hyperlink = .{ + .{ .id = .{ .implicit = 0 }, .uri = "", }, - terminal_hyperlink.Hyperlink{ + .{ .id = .{ .explicit = "" }, .uri = "", }, }; - inline for (values) |value| { + for (values) |value| { var encoded: [9]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try encode(value, &writer); @@ -301,28 +301,6 @@ test "empty strings round trip" { } } -test "decode with a one-byte reader buffer" { - const fixture = - "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri" ++ - "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri"; - var source: std.Io.Reader = .fixed(fixture); - var read_buf: [1]u8 = undefined; - var limited = source.limited(.unlimited, &read_buf); - - const implicit = try decode(&limited.interface, std.testing.allocator); - defer implicit.deinit(std.testing.allocator); - try std.testing.expectEqualStrings("uri", implicit.uri); - try std.testing.expectEqual( - @as(u32, 0x01020304), - implicit.id.implicit, - ); - - const explicit = try decode(&limited.interface, std.testing.allocator); - defer explicit.deinit(std.testing.allocator); - try std.testing.expectEqualStrings("id", explicit.id.explicit); - try std.testing.expectEqualStrings("uri", explicit.uri); -} - test "reject invalid kinds" { inline for (.{ 0, 3, std.math.maxInt(u8) }) |kind| { var fixture: [1]u8 = .{kind}; @@ -365,12 +343,12 @@ test "decode allocation failure" { } test "reject every truncation" { - const fixtures = .{ + const fixtures: [2][]const u8 = .{ "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", }; - inline for (fixtures) |fixture| { + for (fixtures) |fixture| { for (0..fixture.len) |fixture_len| { var reader: std.Io.Reader = .fixed(fixture[0..fixture_len]); try std.testing.expectError( diff --git a/src/terminal/snapshot/io.zig b/src/terminal/snapshot/io.zig index 77c1c5926..d47f96ccb 100644 --- a/src/terminal/snapshot/io.zig +++ b/src/terminal/snapshot/io.zig @@ -38,6 +38,8 @@ test "integer round trip with a one-byte reader buffer" { 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); diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index 20bc2d41c..93230ef09 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -15,7 +15,7 @@ //! //! ## Snapshot Format //! -//! This documents snapshot format 0. Version 0 is the work-in-progress +//! 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. //! diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index b6324860d..73e865caa 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -123,7 +123,7 @@ const PayloadDecodeError = style.DecodeError || grid.DecodeError || Header.CapacityError || error{ - /// The hyperlink kind is not defined by snapshot version 0. + /// The hyperlink kind is not defined by snapshot version 1. InvalidKind, /// The advertised string capacity cannot hold the encoded hyperlinks. @@ -478,6 +478,9 @@ fn pageHyperlink( }; } +// Regenerate these after a wire-format change from `writer.buffered()` in the +// sparse-page test and `destination.written()` in the empty-record test below. +// Format those byte slices as Zig-escaped strings before replacing the literals. const test_page_fixture = "\x03\x00\x02\x00\x02\x00\x02\x00\x08\x00\x00\x02\x80\x00\x00\x00" ++ "\x00\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ++ @@ -576,6 +579,8 @@ test "framed PAGE encode and decode a sparse native page" { 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 }, }); @@ -619,6 +624,8 @@ test "framed PAGE encode and decode a sparse native page" { 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( @@ -666,6 +673,8 @@ test "framed PAGE encode and decode a sparse native page" { }; 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); @@ -677,6 +686,7 @@ test "framed PAGE encode and decode a sparse native page" { try std.testing.expectEqualStrings(fixture, writer.buffered()); try std.testing.expectEqual(@as(u64, fixture.len), counter.count); + // A one-byte backing buffer exercises streaming reads across every field. var source: std.Io.Reader = .fixed(writer.buffered()); var read_buf: [1]u8 = undefined; var limited = source.limited(.unlimited, &read_buf); @@ -689,6 +699,8 @@ test "framed PAGE encode and decode a sparse native page" { 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); @@ -763,6 +775,8 @@ test "framed PAGE encode and decode a sparse native page" { decoded_spacer_head.row.semantic_prompt, ); + // 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); @@ -855,6 +869,7 @@ test "framed PAGE golden empty record" { test "framed PAGE validates tag length checksum and exhaustion" { { + // A valid non-PAGE tag is rejected before payload decoding. var wrong_tag = test_empty_framed_page_fixture.*; std.mem.writeInt(u16, wrong_tag[0..2], @intFromEnum(record.Tag.screen), .little); var reader: std.Io.Reader = .fixed(&wrong_tag); @@ -865,6 +880,7 @@ test "framed PAGE validates tag length checksum and exhaustion" { } { + // Corrupt only the stored checksum. var invalid_checksum = test_empty_framed_page_fixture.*; invalid_checksum[6] ^= 1; var reader: std.Io.Reader = .fixed(&invalid_checksum); @@ -875,6 +891,7 @@ test "framed PAGE validates tag length checksum and exhaustion" { } { + // Mutate a cell without updating the checksum. var invalid_payload = test_empty_framed_page_fixture.*; const value_offset = record.Header.len + Header.len + @@ -889,6 +906,7 @@ test "framed PAGE validates tag length checksum and exhaustion" { } { + // Advertise one byte less than the PAGE decoder requires. var short_payload = test_empty_framed_page_fixture.*; std.mem.writeInt(u32, short_payload[2..6], 36, .little); var reader: std.Io.Reader = .fixed(&short_payload); @@ -899,6 +917,8 @@ test "framed PAGE validates tag length checksum and exhaustion" { } { + // Add a payload byte and recompute the checksum so exhaustion, rather + // than checksum validation, is what fails. var trailing: [test_empty_framed_page_fixture.len + 1]u8 = undefined; @memcpy( trailing[0..test_empty_framed_page_fixture.len], @@ -965,7 +985,7 @@ test "decode accepts unordered sparse style IDs and rejects zero" { Header.len + 2 * (2 + style.len) + 1 + - grid.CellHeader.len + 16 ]u8 = undefined; var descending_writer: std.Io.Writer = .fixed(&descending); try header.encode(&descending_writer); @@ -974,7 +994,7 @@ test "decode accepts unordered sparse style IDs and rejects zero" { try io.writeInt(&descending_writer, TerminalStyleId, 2); try style.encode(.{ .flags = .{ .italic = true } }, &descending_writer); try descending_writer.writeByte(0); - try grid.CellHeader.encode(.{}, &descending_writer); + try descending_writer.splatByteAll(0, 16); var descending_reader: std.Io.Reader = .fixed( descending_writer.buffered(), @@ -1035,7 +1055,7 @@ test "decode accepts unordered sparse hyperlink IDs" { Header.len + 2 * 11 + 1 + - grid.CellHeader.len + 16 ]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try header.encode(&writer); @@ -1044,7 +1064,7 @@ test "decode accepts unordered sparse hyperlink IDs" { try io.writeInt(&writer, TerminalHyperlinkId, 2); try hyperlink.encode(second, &writer); try writer.writeByte(0); - try grid.CellHeader.encode(.{}, &writer); + try writer.splatByteAll(0, 16); var reader: std.Io.Reader = .fixed(writer.buffered()); var decoded = try decodePayload( @@ -1059,6 +1079,7 @@ test "decode accepts unordered sparse hyperlink IDs" { } 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, @@ -1091,6 +1112,7 @@ test "decode defaults missing sparse cell references" { 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, @@ -1349,36 +1371,3 @@ test "decode rejects duplicate hyperlinks with empty strings" { decodePayload(&reader, std.testing.allocator), ); } - -test "decode reads hyperlink strings into page storage" { - const link: TerminalHyperlink = .{ - .id = .{ .explicit = "id" }, - .uri = "uri", - }; - const hyperlink_capacity: u16 = @intCast( - TerminalHyperlinkSet.capacityForCount(1) * - @sizeOf(TerminalHyperlinkSet.Item), - ); - const header: Header = .{ - .columns = 1, - .rows = 1, - .style_count = 0, - .hyperlink_count = 1, - .style_capacity = 0, - .hyperlink_capacity_bytes = hyperlink_capacity, - .grapheme_capacity_bytes = 0, - .string_capacity_bytes = 0, - }; - - var encoded: [Header.len + 16]u8 = undefined; - var writer: std.Io.Writer = .fixed(&encoded); - try header.encode(&writer); - try io.writeInt(&writer, TerminalHyperlinkId, 1); - try hyperlink.encode(link, &writer); - - var reader: std.Io.Reader = .fixed(writer.buffered()); - try std.testing.expectError( - error.InvalidStringCapacity, - decodePayload(&reader, std.testing.allocator), - ); -} diff --git a/src/terminal/snapshot/record.zig b/src/terminal/snapshot/record.zig index 5a99f8607..3694173f1 100644 --- a/src/terminal/snapshot/record.zig +++ b/src/terminal/snapshot/record.zig @@ -320,24 +320,8 @@ test "golden PAGE record header and checksum" { ); } -test "decode header with a one-byte reader buffer" { - const fixture = "\x03\x00\x18\x00\x00\x00\x1b\x44\x78\x71"; - var source: std.Io.Reader = .fixed(fixture); - var buf: [1]u8 = undefined; - var limited = source.limited(.unlimited, &buf); - - try std.testing.expectEqual( - Header{ - .tag = .page, - .payload_len = 24, - .crc32c = 0x7178441b, - }, - try Header.decode(&limited.interface), - ); -} - test "reject invalid tags" { - inline for (.{ 0, 7, std.math.maxInt(u16) }) |tag| { + 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); diff --git a/src/terminal/snapshot/style.zig b/src/terminal/snapshot/style.zig index 87bc3f5be..78c905dff 100644 --- a/src/terminal/snapshot/style.zig +++ b/src/terminal/snapshot/style.zig @@ -43,7 +43,7 @@ //! | 4 | Dotted | //! | 5 | Dashed | //! -//! Underline values 6 and 7 are invalid in snapshot version 0. +//! Underline values 6 and 7 are invalid in snapshot version 1. const std = @import("std"); const io = @import("io.zig"); @@ -81,10 +81,10 @@ const ColorKind = enum(u8) { /// Errors possible while decoding one style entry. pub const DecodeError = std.Io.Reader.Error || error{ - /// A color kind is not defined by snapshot version 0. + /// A color kind is not defined by snapshot version 1. InvalidColorKind, - /// The encoded underline kind is not defined by snapshot version 0. + /// The encoded underline kind is not defined by snapshot version 1. InvalidUnderline, /// One or more reserved style flag bits are set. @@ -279,7 +279,7 @@ test "flag bit layout" { } } -test "decode with a one-byte reader buffer" { +test "decoding" { const fixture = "\x00\x00\x00\x00" ++ "\x01\x7f\x00\x00" ++ From 6508cbbb49616a1b12f80f225f87240e10c2db49 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 28 Jul 2026 20:35:40 -0700 Subject: [PATCH 09/35] terminal/snapshot: screen record --- src/terminal/snapshot/main.zig | 1 + src/terminal/snapshot/screen.zig | 1703 ++++++++++++++++++++++++++++++ 2 files changed, 1704 insertions(+) create mode 100644 src/terminal/snapshot/screen.zig diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index 93230ef09..9bb3f7243 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -72,6 +72,7 @@ pub const grid = @import("grid.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"); test { diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig new file mode 100644 index 000000000..d1e9482bd --- /dev/null +++ b/src/terminal/snapshot/screen.zig @@ -0,0 +1,1703 @@ +//! 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. A client may expose or ignore those resident rows, but must not +//! require them or infer the complete history extent from them. +//! +//! Screen dimensions are terminal-wide state and are not repeated here. Page +//! capacities, native page IDs and pointers, authoritative history extent and +//! availability, scrollbar state, 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 +//! ``` +//! +//! A later history-capable snapshot version provides the authoritative history +//! manifest. It counts any incidental prefix above as already resident and +//! describes older rows that may be loaded after the terminal becomes ready. +//! +//! The SCREEN payload begins with a fixed header. When the header says there is +//! no saved cursor, the payload is: +//! +//! ```text +//! 0 +----------------------+ +//! | Header | +//! 45 +----------------------+ +//! | Cursor hyperlink | +//! | variable | +//! end +----------------------+ +//! ``` +//! +//! When a saved cursor is present, it is inserted before the cursor hyperlink: +//! +//! ```text +//! 0 +----------------------+ +//! | Header | +//! 45 +----------------------+ +//! | Saved cursor | +//! 68 +----------------------+ +//! | 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 +----------------------------------+ +//! | Cursor x (u16) | +//! 6 +----------------------------------+ +//! | Cursor y (u16) | +//! 8 +----------------------------------+ +//! | Cursor visual style (u8) | +//! 9 +----------------------------------+ +//! | Cursor flags (u8) | +//! 10 +----------------------------------+ +//! | Concrete cursor pen (Style) | +//! 26 +----------------------------------+ +//! | Hyperlink implicit counter (u32) | +//! 30 +----------------------------------+ +//! | Current charset (CharsetState) | +//! 32 +----------------------------------+ +//! | Protected mode (u8) | +//! 33 +----------------------------------+ +//! | Kitty keyboard stack index (u8) | +//! 34 +----------------------------------+ +//! | Kitty keyboard stack flags | +//! | 8 * u8 | +//! 42 +----------------------------------+ +//! | Semantic-click kind (u8) | +//! 43 +----------------------------------+ +//! | Semantic-click value (u8) | +//! 44 +----------------------------------+ +//! | Saved-cursor-present (u8) | +//! 45 +----------------------------------+ +//! ``` +//! +//! 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 +---------------------------+ +//! ``` +//! +//! All enum values and bit assignments below are snapshot-version registries, +//! not native enum ordinals. Changing any of them requires a snapshot version +//! bump. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const hyperlink = @import("hyperlink.zig"); +const io = @import("io.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_page = @import("../page.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 = std.Io.Writer.Error || error{ + InvalidCursorFlags, + InvalidSemanticContent, + InvalidCharsetState, + InvalidKittyKeyboardIndex, + InvalidKittyKeyboardFlags, + InvalidSavedCursorFlags, +}; + +/// Errors possible while encoding a complete SCREEN record. +pub const EncodeError = PayloadEncodeError || record.Writer.FinishError; + +/// Encode one complete SCREEN record from a native screen. +/// +/// The caller writes the `page_count` PAGE records immediately afterward. +/// If encoding fails, the partial SCREEN record is removed while earlier +/// destination bytes remain. +pub fn encode( + screen: *const TerminalScreen, + key: TerminalScreenKey, + page_count: u16, + destination: *std.Io.Writer.Allocating, +) EncodeError!void { + var record_writer = try record.Writer.init(destination, .screen); + errdefer record_writer.cancel(); + try encodePayload( + screen, + key, + page_count, + record_writer.payloadWriter(), + ); + try record_writer.finish(); +} + +/// Errors possible while decoding fixed SCREEN payload fields. +pub const DecodeError = style.DecodeError || error{ + InvalidKey, + InvalidCursorStyle, + InvalidCursorFlags, + InvalidSemanticContent, + InvalidCharsetState, + InvalidProtectedMode, + InvalidKittyKeyboardIndex, + InvalidKittyKeyboardFlags, + InvalidSemanticClick, + InvalidSavedCursorPresent, + InvalidSavedCursorFlags, +}; + +/// Identifies which terminal screen the record restores. +pub const Key = enum(u16) { + primary = 0, + alternate = 1, + + fn init(value: TerminalScreenKey) Key { + return switch (value) { + .primary => .primary, + .alternate => .alternate, + }; + } +}; + +/// Snapshot registry for the cursor's visual shape. +pub const CursorStyle = enum(u8) { + bar = 0, + block = 1, + underline = 2, + block_hollow = 3, + + fn init(value: TerminalScreen.CursorStyle) CursorStyle { + return switch (value) { + .bar => .bar, + .block => .block, + .underline => .underline, + .block_hollow => .block_hollow, + }; + } +}; + +/// Flags encoded after the cursor's visual shape. +pub const CursorFlags = packed struct(u8) { + pending_wrap: bool = false, + protected: bool = false, + semantic_content: SemanticContent = .output, + semantic_content_clear_eol: bool = false, + _padding: u3 = 0, + + /// Snapshot registry for the cursor's semantic content. + pub const SemanticContent = enum(u2) { + output = 0, + input = 1, + prompt = 2, + invalid = 3, + }; + + fn init(cursor: *const TerminalScreen.Cursor) CursorFlags { + return .{ + .pending_wrap = cursor.pending_wrap, + .protected = cursor.protected, + .semantic_content = switch (cursor.semantic_content) { + .output => .output, + .input => .input, + .prompt => .prompt, + }, + .semantic_content_clear_eol = cursor + .semantic_content_clear_eol, + }; + } + + /// Decode and validate the cursor flag registry. + pub fn decode(reader: *std.Io.Reader) DecodeError!CursorFlags { + const result: CursorFlags = @bitCast(try reader.takeByte()); + if (result._padding != 0) return error.InvalidCursorFlags; + if (result.semantic_content == .invalid) { + return error.InvalidSemanticContent; + } + return result; + } +}; + +/// The packed charset state used by current and saved cursors. +pub const CharsetState = packed struct(u16) { + g0: Charset = .utf8, + g1: Charset = .utf8, + g2: Charset = .utf8, + g3: Charset = .utf8, + gl: Slot = .g0, + gr: Slot = .g2, + single_shift: SingleShift = .none, + _padding: u1 = 0, + + /// Snapshot registry for one graphical character set. + pub const Charset = enum(u2) { + utf8 = 0, + ascii = 1, + british = 2, + dec_special = 3, + }; + + /// Snapshot registry for a G0 through G3 charset slot. + pub const Slot = enum(u2) { + g0 = 0, + g1 = 1, + g2 = 2, + g3 = 3, + }; + + /// Snapshot registry for the single-shift charset slot. + pub const SingleShift = enum(u3) { + none = 0, + g0 = 1, + g1 = 2, + g2 = 3, + g3 = 4, + _, + }; + + fn init(value: TerminalScreen.CharsetState) CharsetState { + return .{ + .g0 = charset(value.charsets.get(.G0)), + .g1 = charset(value.charsets.get(.G1)), + .g2 = charset(value.charsets.get(.G2)), + .g3 = charset(value.charsets.get(.G3)), + .gl = slot(value.gl), + .gr = slot(value.gr), + .single_shift = if (value.single_shift) |value_slot| + switch (value_slot) { + .G0 => .g0, + .G1 => .g1, + .G2 => .g2, + .G3 => .g3, + } + else + .none, + }; + } + + fn charset(value: terminal_charsets.Charset) Charset { + return switch (value) { + .utf8 => .utf8, + .ascii => .ascii, + .british => .british, + .dec_special => .dec_special, + }; + } + + fn slot(value: terminal_charsets.Slots) Slot { + return switch (value) { + .G0 => .g0, + .G1 => .g1, + .G2 => .g2, + .G3 => .g3, + }; + } + + /// Decode and validate the packed charset registry. + pub fn decode(reader: *std.Io.Reader) DecodeError!CharsetState { + const result: CharsetState = @bitCast(try io.readInt(reader, u16)); + if (result._padding != 0) return error.InvalidCharsetState; + switch (result.single_shift) { + .none, .g0, .g1, .g2, .g3 => {}, + _ => return error.InvalidCharsetState, + } + return result; + } +}; + +/// Snapshot registry for selective-erase protection behavior. +pub const ProtectedMode = enum(u8) { + off = 0, + iso = 1, + dec = 2, + + fn init(value: terminal_ansi.ProtectedMode) ProtectedMode { + return switch (value) { + .off => .off, + .iso => .iso, + .dec => .dec, + }; + } +}; + +/// 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 and validate one Kitty keyboard flag byte. + pub fn decode(reader: *std.Io.Reader) DecodeError!Flags { + const result: Flags = @bitCast(try reader.takeByte()); + if (result._padding != 0) { + return error.InvalidKittyKeyboardFlags; + } + 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; + } + + /// Decode and validate the complete fixed Kitty keyboard stack. + pub fn decode(reader: *std.Io.Reader) DecodeError!KittyKeyboard { + const index = try reader.takeByte(); + if (index >= 8) return error.InvalidKittyKeyboardIndex; + + var flags: [8]Flags = undefined; + for (&flags) |*entry| entry.* = try Flags.decode(reader); + return .{ .index = index, .flags = flags }; + } +}; + +/// Selects the interpretation of the semantic-click value byte. +pub const SemanticClickKind = enum(u8) { + none = 0, + click_events = 1, + cl = 2, +}; + +/// Snapshot registry for the OSC 133 `click_events` option. +pub const ClickEvents = enum(u8) { + absolute = 0, + relative = 1, +}; + +/// Snapshot registry for the OSC 133 `cl` option. +pub const Click = enum(u8) { + line = 0, + multiple = 1, + conservative_vertical = 2, + smart_vertical = 3, +}; + +/// The typed semantic-click kind and value from the fixed header. +pub const SemanticClick = union(SemanticClickKind) { + none, + click_events: ClickEvents, + cl: Click, + + fn init( + value: TerminalScreen.SemanticPrompt.SemanticClick, + ) SemanticClick { + return switch (value) { + .none => .none, + .click_events => |click_events| .{ + .click_events = switch (click_events) { + .absolute => .absolute, + .relative => .relative, + }, + }, + .cl => |click| .{ + .cl = switch (click) { + .line => .line, + .multiple => .multiple, + .conservative_vertical => .conservative_vertical, + .smart_vertical => .smart_vertical, + }, + }, + }; + } + + /// Decode and validate the semantic-click kind and value bytes. + pub fn decode(reader: *std.Io.Reader) DecodeError!SemanticClick { + const kind_raw = try reader.takeByte(); + const value_raw = try reader.takeByte(); + const kind = std.enums.fromInt( + SemanticClickKind, + kind_raw, + ) orelse return error.InvalidSemanticClick; + + return switch (kind) { + .none => if (value_raw == 0) + .none + else + error.InvalidSemanticClick, + .click_events => .{ .click_events = std.enums.fromInt( + ClickEvents, + value_raw, + ) orelse return error.InvalidSemanticClick }, + .cl => .{ .cl = std.enums.fromInt( + Click, + value_raw, + ) orelse return error.InvalidSemanticClick }, + }; + } +}; + +/// 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: 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 and validate saved cursor flags. + pub fn decode(reader: *std.Io.Reader) DecodeError!Flags { + const result: Flags = @bitCast(try reader.takeByte()); + if (result._padding != 0) return error.InvalidSavedCursorFlags; + 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 = .init(value.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; + } + if (self.charset._padding != 0) return error.InvalidCharsetState; + switch (self.charset.single_shift) { + .none, .g0, .g1, .g2, .g3 => {}, + _ => return error.InvalidCharsetState, + } + + 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, @bitCast(self.charset)); + } + + /// Decode and validate one optional saved cursor value. + pub fn decode(reader: *std.Io.Reader) DecodeError!SavedCursor { + return .{ + .x = try io.readInt(reader, u16), + .y = try io.readInt(reader, u16), + .pen = try style.decode(reader), + .flags = try Flags.decode(reader), + .charset = try CharsetState.decode(reader), + }; + } + + 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 == 45); + } + + key: Key, + /// Complete pages in the minimal suffix covering the active area. + page_count: u16, + cursor_x: u16, + cursor_y: u16, + cursor_style: CursorStyle, + cursor_flags: CursorFlags, + cursor_pen: TerminalStyle, + hyperlink_implicit_id: u32, + charset: CharsetState, + protected_mode: ProtectedMode, + kitty_keyboard: KittyKeyboard, + semantic_click: 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 = .init(key), + .page_count = page_count, + .cursor_x = screen.cursor.x, + .cursor_y = screen.cursor.y, + .cursor_style = .init(screen.cursor.cursor_style), + .cursor_flags = .init(&screen.cursor), + .cursor_pen = screen.cursor.style, + .hyperlink_implicit_id = screen.cursor.hyperlink_implicit_id, + .charset = .init(screen.charset), + .protected_mode = .init(screen.protected_mode), + .kitty_keyboard = .init(screen.kitty_keyboard), + .semantic_click = .init(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; + } + if (self.cursor_flags.semantic_content == .invalid) { + return error.InvalidSemanticContent; + } + + // Validate packed charset state. + if (self.charset._padding != 0) return error.InvalidCharsetState; + switch (self.charset.single_shift) { + .none, .g0, .g1, .g2, .g3 => {}, + _ => return error.InvalidCharsetState, + } + + // 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 and cursor position. + try io.writeInt(writer, u16, @intFromEnum(self.key)); + try io.writeInt(writer, u16, self.page_count); + try io.writeInt(writer, u16, self.cursor_x); + try io.writeInt(writer, u16, self.cursor_y); + + // Current cursor rendering state. + try writer.writeByte(@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, @bitCast(self.charset)); + try writer.writeByte(@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)); + } + switch (self.semantic_click) { + .none => { + try writer.writeByte(@intFromEnum(SemanticClickKind.none)); + try writer.writeByte(0); + }, + .click_events => |value| { + try writer.writeByte( + @intFromEnum(SemanticClickKind.click_events), + ); + try writer.writeByte(@intFromEnum(value)); + }, + .cl => |value| { + try writer.writeByte(@intFromEnum(SemanticClickKind.cl)); + try writer.writeByte(@intFromEnum(value)); + }, + } + + // Presence of the optional saved-cursor suffix. + try writer.writeByte(@intFromBool(self.saved_cursor_present)); + } + + /// Decode and validate the fixed SCREEN payload header. + pub fn decode(reader: *std.Io.Reader) DecodeError!Header { + // Screen identity and cursor position. + const key = std.enums.fromInt( + Key, + try io.readInt(reader, u16), + ) orelse return error.InvalidKey; + const page_count = try io.readInt(reader, u16); + const cursor_x = try io.readInt(reader, u16); + const cursor_y = try io.readInt(reader, u16); + + // Current cursor rendering state. + const cursor_style = std.enums.fromInt( + CursorStyle, + try reader.takeByte(), + ) orelse return error.InvalidCursorStyle; + const cursor_flags = try CursorFlags.decode(reader); + const cursor_pen = try style.decode(reader); + const hyperlink_implicit_id = try io.readInt(reader, u32); + + // Charset and selective-erase state. + const charset = try CharsetState.decode(reader); + const protected_mode = std.enums.fromInt( + ProtectedMode, + try reader.takeByte(), + ) orelse return error.InvalidProtectedMode; + + // Keyboard modes and semantic-click state. + const kitty_keyboard = try KittyKeyboard.decode(reader); + const semantic_click = try SemanticClick.decode(reader); + + // Presence of the optional saved-cursor suffix. + const saved_cursor_present = switch (try reader.takeByte()) { + 0 => false, + 1 => true, + else => return error.InvalidSavedCursorPresent, + }; + + return .{ + .key = key, + .page_count = page_count, + .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, + .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, +) hyperlink.DecodeError!CursorHyperlink { + if (try reader.peekByte() == 0) { + _ = try reader.takeByte(); + return null; + } + return try hyperlink.decode(reader, alloc); +} + +const test_header_fixture = + "\x01\x00\x03\x02\x05\x04\x07\x06\x03\x19" ++ + "\x00\x00\x00\x00\x01\x7f\x00\x00" ++ + "\x02\x12\x34\x56\xff\x03\x00\x00" ++ + "\x0d\x0c\x0b\x0a\xe4\x3d\x02\x07" ++ + "\x01\x02\x04\x08\x10\x1f\x00\x11" ++ + "\x02\x03\x01"; + +const test_saved_cursor_fixture = + "\x02\x01\x04\x03" ++ + "\x00\x00\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x00\x00\x00\x00\x00\x00" ++ + "\x07\xe4\x3d"; + +fn testCharsetState() CharsetState { + return .{ + .g0 = .utf8, + .g1 = .ascii, + .g2 = .british, + .g3 = .dec_special, + .gl = .g1, + .gr = .g3, + .single_shift = .g2, + }; +} + +fn testHeader() Header { + return .{ + .key = .alternate, + .page_count = 0x0203, + .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(), + }; +} + +fn expectHeaderRoundTrip(expected: Header) !void { + var encoded: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try expected.encode(&writer); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + const actual = try Header.decode(&reader); + try std.testing.expectEqualDeep(expected, actual); +} + +fn expectHyperlinkEqual( + expected: TerminalHyperlink, + actual: TerminalHyperlink, +) !void { + try std.testing.expectEqualStrings(expected.uri, actual.uri); + try std.testing.expectEqual( + std.meta.activeTag(expected.id), + std.meta.activeTag(actual.id), + ); + switch (expected.id) { + .implicit => |expected_id| try std.testing.expectEqual( + expected_id, + actual.id.implicit, + ), + .explicit => |expected_id| try std.testing.expectEqualStrings( + expected_id, + actual.id.explicit, + ), + } +} + +test "header golden encoding" { + try std.testing.expectEqual(Header.len, test_header_fixture.len); + + var encoded: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try testHeader().encode(&writer); + + try std.testing.expectEqualStrings( + test_header_fixture, + writer.buffered(), + ); +} + +test "header decoding with a one-byte reader buffer" { + var source: std.Io.Reader = .fixed(test_header_fixture); + var buffer: [1]u8 = undefined; + var limited = source.limited(.unlimited, &buffer); + + try std.testing.expectEqualDeep( + testHeader(), + try Header.decode(&limited.interface), + ); +} + +test "header registry values round trip" { + const keys = [_]Key{ .primary, .alternate }; + for (keys) |value| { + var header = testHeader(); + header.key = value; + try expectHeaderRoundTrip(header); + } + + const cursor_styles = [_]CursorStyle{ + .bar, + .block, + .underline, + .block_hollow, + }; + for (cursor_styles) |value| { + var header = testHeader(); + header.cursor_style = value; + try expectHeaderRoundTrip(header); + } + + const semantic_contents = [_]CursorFlags.SemanticContent{ + .output, + .input, + .prompt, + }; + for (semantic_contents) |value| { + var header = testHeader(); + header.cursor_flags.semantic_content = value; + try expectHeaderRoundTrip(header); + } + + const charsets = [_]CharsetState.Charset{ + .utf8, + .ascii, + .british, + .dec_special, + }; + for (charsets) |value| { + var header = testHeader(); + header.charset.g0 = value; + header.charset.g1 = value; + header.charset.g2 = value; + header.charset.g3 = value; + try expectHeaderRoundTrip(header); + } + + const slots = [_]CharsetState.Slot{ .g0, .g1, .g2, .g3 }; + for (slots) |value| { + var header = testHeader(); + header.charset.gl = value; + header.charset.gr = value; + try expectHeaderRoundTrip(header); + } + + const single_shifts = [_]CharsetState.SingleShift{ + .none, + .g0, + .g1, + .g2, + .g3, + }; + for (single_shifts) |value| { + var header = testHeader(); + header.charset.single_shift = value; + try expectHeaderRoundTrip(header); + } + + const protected_modes = [_]ProtectedMode{ .off, .iso, .dec }; + for (protected_modes) |value| { + var header = testHeader(); + header.protected_mode = value; + try expectHeaderRoundTrip(header); + } + + const semantic_clicks = [_]SemanticClick{ + .none, + .{ .click_events = .absolute }, + .{ .click_events = .relative }, + .{ .cl = .line }, + .{ .cl = .multiple }, + .{ .cl = .conservative_vertical }, + .{ .cl = .smart_vertical }, + }; + for (semantic_clicks) |value| { + var header = testHeader(); + header.semantic_click = value; + try expectHeaderRoundTrip(header); + } +} + +test "cursor, Kitty, and saved cursor flag bit layouts" { + const cursor_cases = .{ + .{ CursorFlags{ .pending_wrap = true }, @as(u8, 1 << 0) }, + .{ CursorFlags{ .protected = true }, @as(u8, 1 << 1) }, + .{ + CursorFlags{ .semantic_content = .input }, + @as(u8, 1 << 2), + }, + .{ + CursorFlags{ .semantic_content = .prompt }, + @as(u8, 2 << 2), + }, + .{ + CursorFlags{ .semantic_content_clear_eol = true }, + @as(u8, 1 << 4), + }, + }; + inline for (cursor_cases) |case| { + try std.testing.expectEqual(case[1], @as(u8, @bitCast(case[0]))); + } + + const kitty_cases = .{ + .{ KittyKeyboard.Flags{ .disambiguate = true }, @as(u8, 1 << 0) }, + .{ KittyKeyboard.Flags{ .report_events = true }, @as(u8, 1 << 1) }, + .{ + KittyKeyboard.Flags{ .report_alternates = true }, + @as(u8, 1 << 2), + }, + .{ KittyKeyboard.Flags{ .report_all = true }, @as(u8, 1 << 3) }, + .{ + KittyKeyboard.Flags{ .report_associated = true }, + @as(u8, 1 << 4), + }, + }; + inline for (kitty_cases) |case| { + try std.testing.expectEqual(case[1], @as(u8, @bitCast(case[0]))); + } + + const saved_cases = .{ + .{ SavedCursor.Flags{ .protected = true }, @as(u8, 1 << 0) }, + .{ SavedCursor.Flags{ .pending_wrap = true }, @as(u8, 1 << 1) }, + .{ SavedCursor.Flags{ .origin = true }, @as(u8, 1 << 2) }, + }; + inline for (saved_cases) |case| { + try std.testing.expectEqual(case[1], @as(u8, @bitCast(case[0]))); + } +} + +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_semantic_content = testHeader(); + invalid_semantic_content.cursor_flags.semantic_content = .invalid; + var semantic_content_writer: std.Io.Writer = .fixed(&encoded); + try std.testing.expectError( + error.InvalidSemanticContent, + invalid_semantic_content.encode(&semantic_content_writer), + ); + + var invalid_charset = testHeader(); + invalid_charset.charset._padding = 1; + var charset_writer: std.Io.Writer = .fixed(&encoded); + try std.testing.expectError( + error.InvalidCharsetState, + invalid_charset.encode(&charset_writer), + ); + + var invalid_single_shift = testHeader(); + invalid_single_shift.charset.single_shift = @enumFromInt(5); + var single_shift_writer: std.Io.Writer = .fixed(&encoded); + try std.testing.expectError( + error.InvalidCharsetState, + invalid_single_shift.encode(&single_shift_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 invalid 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)); + + var invalid_cursor_style = valid; + invalid_cursor_style[8] = 4; + var cursor_style_reader: std.Io.Reader = .fixed(&invalid_cursor_style); + try std.testing.expectError( + error.InvalidCursorStyle, + Header.decode(&cursor_style_reader), + ); + + var invalid_cursor_flags = valid; + invalid_cursor_flags[9] = 1 << 5; + var cursor_flags_reader: std.Io.Reader = .fixed(&invalid_cursor_flags); + try std.testing.expectError( + error.InvalidCursorFlags, + Header.decode(&cursor_flags_reader), + ); + + var invalid_semantic_content = valid; + invalid_semantic_content[9] = 3 << 2; + var semantic_content_reader: std.Io.Reader = .fixed( + &invalid_semantic_content, + ); + try std.testing.expectError( + error.InvalidSemanticContent, + Header.decode(&semantic_content_reader), + ); + + var invalid_style = valid; + invalid_style[10] = 3; + var style_reader: std.Io.Reader = .fixed(&invalid_style); + try std.testing.expectError( + error.InvalidColorKind, + Header.decode(&style_reader), + ); + + var invalid_charset_reserved = valid; + invalid_charset_reserved[31] = 1 << 7; + var charset_reserved_reader: std.Io.Reader = .fixed( + &invalid_charset_reserved, + ); + try std.testing.expectError( + error.InvalidCharsetState, + Header.decode(&charset_reserved_reader), + ); + + var invalid_single_shift = valid; + invalid_single_shift[31] = 5 << 4; + var single_shift_reader: std.Io.Reader = .fixed(&invalid_single_shift); + try std.testing.expectError( + error.InvalidCharsetState, + Header.decode(&single_shift_reader), + ); + + var invalid_protected_mode = valid; + invalid_protected_mode[32] = 3; + var protected_mode_reader: std.Io.Reader = .fixed( + &invalid_protected_mode, + ); + try std.testing.expectError( + error.InvalidProtectedMode, + Header.decode(&protected_mode_reader), + ); + + var invalid_kitty_index = valid; + invalid_kitty_index[33] = 8; + var kitty_index_reader: std.Io.Reader = .fixed(&invalid_kitty_index); + try std.testing.expectError( + error.InvalidKittyKeyboardIndex, + Header.decode(&kitty_index_reader), + ); + + for (34..42) |offset| { + var invalid_kitty_flags = valid; + invalid_kitty_flags[offset] = 1 << 5; + var reader: std.Io.Reader = .fixed(&invalid_kitty_flags); + try std.testing.expectError( + error.InvalidKittyKeyboardFlags, + Header.decode(&reader), + ); + } + + var invalid_semantic_click_kind = valid; + invalid_semantic_click_kind[42] = 3; + var semantic_click_kind_reader: std.Io.Reader = .fixed( + &invalid_semantic_click_kind, + ); + try std.testing.expectError( + error.InvalidSemanticClick, + Header.decode(&semantic_click_kind_reader), + ); + + const invalid_semantic_clicks = .{ + .{ @as(u8, 0), @as(u8, 1) }, + .{ @as(u8, 1), @as(u8, 2) }, + .{ @as(u8, 2), @as(u8, 4) }, + }; + inline for (invalid_semantic_clicks) |invalid| { + var fixture = valid; + fixture[42] = invalid[0]; + fixture[43] = invalid[1]; + var reader: std.Io.Reader = .fixed(&fixture); + try std.testing.expectError( + error.InvalidSemanticClick, + Header.decode(&reader), + ); + } + + var invalid_saved_cursor_present = valid; + invalid_saved_cursor_present[44] = 2; + var saved_cursor_present_reader: std.Io.Reader = .fixed( + &invalid_saved_cursor_present, + ); + try std.testing.expectError( + error.InvalidSavedCursorPresent, + Header.decode(&saved_cursor_present_reader), + ); +} + +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(Key.primary, header.key); + try std.testing.expectEqual(@as(u16, 1), header.page_count); + 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" { + try std.testing.expectEqual( + SavedCursor.len, + test_saved_cursor_fixture.len, + ); + + var encoded: [SavedCursor.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try testSavedCursor().encode(&writer); + try std.testing.expectEqualStrings( + test_saved_cursor_fixture, + writer.buffered(), + ); + + 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 rejects 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_charset = testSavedCursor(); + invalid_charset.charset._padding = 1; + var charset_writer: std.Io.Writer = .fixed(&encoded); + try std.testing.expectError( + error.InvalidCharsetState, + invalid_charset.encode(&charset_writer), + ); + + var invalid_single_shift_value = testSavedCursor(); + invalid_single_shift_value.charset.single_shift = @enumFromInt(5); + var single_shift_value_writer: std.Io.Writer = .fixed(&encoded); + try std.testing.expectError( + error.InvalidCharsetState, + invalid_single_shift_value.encode(&single_shift_value_writer), + ); + + const valid = [_]u8{0} ** SavedCursor.len; + + var invalid_flags_bytes = valid; + invalid_flags_bytes[20] = 1 << 3; + var flags_reader: std.Io.Reader = .fixed(&invalid_flags_bytes); + try std.testing.expectError( + error.InvalidSavedCursorFlags, + SavedCursor.decode(&flags_reader), + ); + + var invalid_charset_reserved = valid; + invalid_charset_reserved[22] = 1 << 7; + var charset_reserved_reader: std.Io.Reader = .fixed( + &invalid_charset_reserved, + ); + try std.testing.expectError( + error.InvalidCharsetState, + SavedCursor.decode(&charset_reserved_reader), + ); + + var invalid_single_shift = valid; + invalid_single_shift[22] = 5 << 4; + var single_shift_reader: std.Io.Reader = .fixed(&invalid_single_shift); + try std.testing.expectError( + error.InvalidCharsetState, + SavedCursor.decode(&single_shift_reader), + ); +} + +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 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), + ); + + const values = [_]TerminalHyperlink{ + .{ + .id = .{ .implicit = 0x01020304 }, + .uri = "implicit", + }, + .{ + .id = .{ .explicit = "id" }, + .uri = "explicit", + }, + }; + + for (values) |expected| { + var encoded: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try encodeCursorHyperlink(expected, &writer); + + var reader: std.Io.Reader = .fixed(writer.buffered()); + var actual = (try decodeCursorHyperlink( + &reader, + std.testing.allocator, + )).?; + defer actual.deinit(std.testing.allocator); + try expectHyperlinkEqual(expected, actual); + } +} + +test "framed native SCREEN record" { + 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(5, 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", "cursor-id"); + + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + try destination.writer.writeAll("prefix"); + try encode(&screen, .alternate, 0x0203, &destination); + + try std.testing.expectEqualStrings( + "prefix", + destination.written()[0..6], + ); + + var source: std.Io.Reader = .fixed(destination.written()[6..]); + var record_reader: record.Reader = undefined; + try record_reader.init(&source); + try std.testing.expectEqual(record.Tag.screen, record_reader.header.tag); + + const payload_reader = record_reader.payloadReader(); + var expected_header = testHeader(); + expected_header.cursor_x = 5; + expected_header.cursor_y = 6; + try std.testing.expectEqualDeep( + expected_header, + try Header.decode(payload_reader), + ); + try std.testing.expectEqualDeep( + testSavedCursor(), + try SavedCursor.decode(payload_reader), + ); + + const expected_hyperlink: TerminalHyperlink = .{ + .id = .{ .explicit = "cursor-id" }, + .uri = "cursor-uri", + }; + var actual_hyperlink = (try decodeCursorHyperlink( + payload_reader, + std.testing.allocator, + )).?; + defer actual_hyperlink.deinit(std.testing.allocator); + try expectHyperlinkEqual(expected_hyperlink, actual_hyperlink); + try record_reader.finish(); +} + +test "framed SCREEN encoding 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 failing = std.testing.FailingAllocator.init( + std.testing.allocator, + .{}, + ); + var destination = try std.Io.Writer.Allocating.initCapacity( + failing.allocator(), + 6 + record.Header.len, + ); + defer destination.deinit(); + try destination.writer.writeAll("prefix"); + + failing.fail_index = failing.alloc_index; + try std.testing.expectError( + error.WriteFailed, + encode(&screen, .primary, 1, &destination), + ); + try std.testing.expectEqualStrings("prefix", destination.written()); +} + +test "cursor hyperlink rejects invalid kind and every truncation" { + var invalid_kind_reader: std.Io.Reader = .fixed("\x03"); + try std.testing.expectError( + error.InvalidKind, + decodeCursorHyperlink( + &invalid_kind_reader, + std.testing.allocator, + ), + ); + + const fixtures = .{ + "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", + "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", + }; + inline for (fixtures) |fixture| { + for (0..fixture.len) |fixture_len| { + var reader: std.Io.Reader = .fixed(fixture[0..fixture_len]); + try std.testing.expectError( + error.EndOfStream, + decodeCursorHyperlink( + &reader, + std.testing.allocator, + ), + ); + } + } +} From f50bdfab200b97142f4cf97feb3b7a456552f389 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 29 Jul 2026 13:28:08 -0700 Subject: [PATCH 10/35] terminal/snapshot: screen plus active encoding --- src/terminal/snapshot/main.zig | 15 ++- src/terminal/snapshot/screen.zig | 158 +++++++++++++++++++++++++++---- 2 files changed, 143 insertions(+), 30 deletions(-) diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index 9bb3f7243..8d0658cbd 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -52,20 +52,17 @@ //! defer output.deinit(); //! //! try envelope.encode(&output.writer); -//! try page.encode(&terminal_page, &output); +//! try screen.encode(&terminal_screen, .primary, &output); //! //! const snapshot = output.written(); //! ``` //! -//! Record codecs reserve and backpatch their own framing, so callers do not -//! calculate payload lengths or checksums. `written` borrows the completed -//! bytes from the allocating writer. Use `toOwnedSlice` instead when ownership -//! must be transferred to the caller. +//! We have to use an allocating writer because record formats require +//! encoding the length and CRC in the header, so we need a seekable +//! format. //! -//! `page.encode` currently appends one complete framed PAGE record. Its -//! lower-level payload codec is intentionally private. `page.decode` consumes -//! and validates one complete PAGE record, including its payload boundary, -//! checksum, and restored page integrity. +//! Each record type usually exposes an `encode` function that encodes +//! a complete record, such as `screen.encode`. pub const envelope = @import("envelope.zig"); pub const grid = @import("grid.zig"); diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index d1e9482bd..e0df17170 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -200,13 +200,13 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const hyperlink = @import("hyperlink.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_page = @import("../page.zig"); const TerminalScreen = @import("../Screen.zig"); const TerminalScreenKey = @import("../ScreenSet.zig").Key; const terminal_style = @import("../style.zig"); @@ -224,29 +224,58 @@ const PayloadEncodeError = std.Io.Writer.Error || error{ InvalidSavedCursorFlags, }; -/// Errors possible while encoding a complete SCREEN record. -pub const EncodeError = PayloadEncodeError || record.Writer.FinishError; +/// 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 complete SCREEN record from a native screen. +/// Encode one SCREEN and its minimal suffix of complete native pages. /// -/// The caller writes the `page_count` PAGE records immediately afterward. -/// If encoding fails, the partial SCREEN record is removed while earlier -/// destination bytes remain. +/// The suffix begins with the page containing the active area's first row and +/// ends with the newest page. If encoding any record fails, the entire sequence +/// is removed while earlier destination bytes remain. pub fn encode( screen: *const TerminalScreen, key: TerminalScreenKey, - page_count: u16, destination: *std.Io.Writer.Allocating, ) EncodeError!void { - var record_writer = try record.Writer.init(destination, .screen); - errdefer record_writer.cancel(); - try encodePayload( - screen, - key, + const sequence_start = destination.written().len; + errdefer destination.shrinkRetainingCapacity(sequence_start); + + // 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, - record_writer.payloadWriter(), - ); - try record_writer.finish(); + ) orelse return error.PageCountOverflow; + + // SCREEN declares exactly how many immediately following PAGE records + // belong to it. + { + var record_writer = try record.Writer.init(destination, .screen); + errdefer record_writer.cancel(); + try encodePayload( + screen, + key, + encoded_page_count, + record_writer.payloadWriter(), + ); + try record_writer.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 decoding fixed SCREEN payload fields. @@ -1535,7 +1564,7 @@ test "cursor hyperlink encoding and decoding" { } } -test "framed native SCREEN record" { +test "framed native SCREEN and PAGE sequence" { var screen = try TerminalScreen.init( std.testing.io, std.testing.allocator, @@ -1609,7 +1638,7 @@ test "framed native SCREEN record" { ); defer destination.deinit(); try destination.writer.writeAll("prefix"); - try encode(&screen, .alternate, 0x0203, &destination); + try encode(&screen, .alternate, &destination); try std.testing.expectEqualStrings( "prefix", @@ -1623,6 +1652,7 @@ test "framed native SCREEN record" { const payload_reader = record_reader.payloadReader(); var expected_header = testHeader(); + expected_header.page_count = 1; expected_header.cursor_x = 5; expected_header.cursor_y = 6; try std.testing.expectEqualDeep( @@ -1645,9 +1675,95 @@ test "framed native SCREEN record" { defer actual_hyperlink.deinit(std.testing.allocator); try expectHyperlinkEqual(expected_hyperlink, actual_hyperlink); try record_reader.finish(); + + var decoded_page = try page.decode(&source, std.testing.allocator); + defer decoded_page.deinit(); + try std.testing.expectEqual(@as(u16, 8), decoded_page.size.cols); + try std.testing.expectEqual(@as(u16, 8), decoded_page.size.rows); + try std.testing.expectError(error.EndOfStream, source.takeByte()); } -test "framed SCREEN encoding failure preserves preceding bytes" { +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(); + try encode(&screen, .primary, &destination); + + 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(Key.primary, header.key); + try std.testing.expectEqual(@as(u16, 2), header.page_count); + 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()); +} + +test "SCREEN sequence failure preserves preceding bytes" { var screen = try TerminalScreen.init( std.testing.io, std.testing.allocator, @@ -1661,7 +1777,7 @@ test "framed SCREEN encoding failure preserves preceding bytes" { ); var destination = try std.Io.Writer.Allocating.initCapacity( failing.allocator(), - 6 + record.Header.len, + 6 + record.Header.len + Header.len + 1, ); defer destination.deinit(); try destination.writer.writeAll("prefix"); @@ -1669,7 +1785,7 @@ test "framed SCREEN encoding failure preserves preceding bytes" { failing.fail_index = failing.alloc_index; try std.testing.expectError( error.WriteFailed, - encode(&screen, .primary, 1, &destination), + encode(&screen, .primary, &destination), ); try std.testing.expectEqualStrings("prefix", destination.written()); } From d34fd0593eb241b7f76e7ab9e01cb079e4bc92c0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 29 Jul 2026 14:17:32 -0700 Subject: [PATCH 11/35] terminal/snapshot: screen decoding --- src/terminal/snapshot/page.zig | 87 ++++- src/terminal/snapshot/screen.zig | 587 ++++++++++++++++++++++++++++++- 2 files changed, 650 insertions(+), 24 deletions(-) diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 73e865caa..f61d1a472 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -144,6 +144,9 @@ const PayloadDecodeError = style.DecodeError || /// 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. @@ -183,17 +186,70 @@ pub fn decode( reader: *std.Io.Reader, alloc: Allocator, ) DecodeError!TerminalPage { - var record_reader: record.Reader = undefined; - try record_reader.init(reader); - if (record_reader.header.tag != .page) return error.UnexpectedRecordTag; + var decoder: Decoder = undefined; + try decoder.init(reader); - var page = try decodePayload(record_reader.payloadReader(), alloc); + var page = TerminalPage.init(decoder.capacity()) catch + return error.OutOfMemory; errdefer page.deinit(); - try record_reader.finish(); - try page.verifyIntegrity(alloc); + 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, @@ -228,12 +284,23 @@ fn decodePayload( reader: *std.Io.Reader, alloc: Allocator, ) PayloadDecodeError!TerminalPage { - // Decode the header, validate capacities, init page 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); @@ -293,7 +360,7 @@ fn decodePayload( } const decoded_id = hyperlink.decodePage( - &page, + page, reader, ) catch |err| switch (err) { error.StringsOutOfMemory => return error.InvalidStringCapacity, @@ -314,13 +381,11 @@ fn decodePayload( // Rows and cells try grid.decode( - &page, + page, reader, &style_remap, &hyperlink_remap, ); - - return page; } /// The fixed logical dimensions, table counts, and allocation hints at the diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index e0df17170..bb61af5b8 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -197,6 +197,7 @@ //! bump. const std = @import("std"); +const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; const hyperlink = @import("hyperlink.zig"); const io = @import("io.zig"); @@ -207,6 +208,8 @@ 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_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"); @@ -278,8 +281,233 @@ pub fn encode( } } +/// Errors possible while restoring a SCREEN and its declared PAGE sequence. +pub const DecodeError = PayloadDecodeError || + hyperlink.DecodeError || + 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, + + /// The SCREEN key does not match the caller-selected native screen. + UnexpectedScreenKey, + + /// A SCREEN must declare at least one PAGE. + InvalidPageCount, + + /// The cursor is outside the restored active area. + InvalidCursorPosition, + }; + +/// 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. +pub fn decode( + source: *std.Io.Reader, + io_: std.Io, + alloc: Allocator, + expected_key: TerminalScreenKey, + options: TerminalScreen.Options, +) DecodeError!TerminalScreen { + // 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(); + + // Reject a structurally valid SCREEN which does not describe the native + // screen selected by the caller. + if (header.key.terminal() != expected_key) { + return error.UnexpectedScreenKey; + } + 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; + } + if (header.cursor_x >= options.cols or + header.cursor_y >= options.rows or + (header.cursor_flags.pending_wrap and + header.cursor_x != options.cols - 1)) + { + return error.InvalidCursorPosition; + } + + // 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 (expected_key == .alternate) + 0 + else + options.max_scrollback_bytes, + .max_lines = if (expected_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_pin_value = pages.pin(.{ .active = .{ + .x = header.cursor_x, + .y = header.cursor_y, + } }) orelse return error.InvalidCursorPosition; + const cursor_pin = try pages.trackPin(cursor_pin_value); + const cursor_rac = cursor_pin.rowAndCell(); + + // 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 = expected_key == .alternate or + options.max_scrollback_bytes == 0, + .cursor = .{ + .x = header.cursor_x, + .y = header.cursor_y, + .cursor_style = header.cursor_style.terminal(), + .pending_wrap = header.cursor_flags.pending_wrap, + .protected = header.cursor_flags.protected, + .style = header.cursor_pen, + .hyperlink_implicit_id = header.hyperlink_implicit_id, + .semantic_content = header.cursor_flags.semantic_content + .terminal(), + .semantic_content_clear_eol = header.cursor_flags + .semantic_content_clear_eol, + .page_pin = cursor_pin, + .page_row = cursor_rac.row, + .page_cell = cursor_rac.cell, + }, + .saved_cursor = if (saved_cursor) |value| + value.terminal() + else + null, + .charset = header.charset.terminal(), + .protected_mode = header.protected_mode.terminal(), + .kitty_keyboard = header.kitty_keyboard.terminal(), + .semantic_prompt = .{ + .seen = false, + .click = header.semantic_click.terminal(), + }, + }; + }; + 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 result; +} + /// Errors possible while decoding fixed SCREEN payload fields. -pub const DecodeError = style.DecodeError || error{ +const PayloadDecodeError = style.DecodeError || error{ InvalidKey, InvalidCursorStyle, InvalidCursorFlags, @@ -304,6 +532,13 @@ pub const Key = enum(u16) { .alternate => .alternate, }; } + + fn terminal(self: Key) TerminalScreenKey { + return switch (self) { + .primary => .primary, + .alternate => .alternate, + }; + } }; /// Snapshot registry for the cursor's visual shape. @@ -321,6 +556,15 @@ pub const CursorStyle = enum(u8) { .block_hollow => .block_hollow, }; } + + fn terminal(self: CursorStyle) TerminalScreen.CursorStyle { + return switch (self) { + .bar => .bar, + .block => .block, + .underline => .underline, + .block_hollow => .block_hollow, + }; + } }; /// Flags encoded after the cursor's visual shape. @@ -337,6 +581,15 @@ pub const CursorFlags = packed struct(u8) { input = 1, prompt = 2, invalid = 3, + + fn terminal(self: SemanticContent) terminal_page.Cell.SemanticContent { + return switch (self) { + .output => .output, + .input => .input, + .prompt => .prompt, + .invalid => unreachable, + }; + } }; fn init(cursor: *const TerminalScreen.Cursor) CursorFlags { @@ -354,7 +607,7 @@ pub const CursorFlags = packed struct(u8) { } /// Decode and validate the cursor flag registry. - pub fn decode(reader: *std.Io.Reader) DecodeError!CursorFlags { + pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!CursorFlags { const result: CursorFlags = @bitCast(try reader.takeByte()); if (result._padding != 0) return error.InvalidCursorFlags; if (result.semantic_content == .invalid) { @@ -381,6 +634,15 @@ pub const CharsetState = packed struct(u16) { ascii = 1, british = 2, dec_special = 3, + + fn terminal(self: Charset) terminal_charsets.Charset { + return switch (self) { + .utf8 => .utf8, + .ascii => .ascii, + .british => .british, + .dec_special => .dec_special, + }; + } }; /// Snapshot registry for a G0 through G3 charset slot. @@ -389,6 +651,15 @@ pub const CharsetState = packed struct(u16) { g1 = 1, g2 = 2, g3 = 3, + + fn terminal(self: Slot) terminal_charsets.Slots { + return switch (self) { + .g0 => .G0, + .g1 => .G1, + .g2 => .G2, + .g3 => .G3, + }; + } }; /// Snapshot registry for the single-shift charset slot. @@ -399,6 +670,17 @@ pub const CharsetState = packed struct(u16) { g2 = 3, g3 = 4, _, + + fn terminal(self: SingleShift) ?terminal_charsets.Slots { + return switch (self) { + .none => null, + .g0 => .G0, + .g1 => .G1, + .g2 => .G2, + .g3 => .G3, + _ => unreachable, + }; + } }; fn init(value: TerminalScreen.CharsetState) CharsetState { @@ -421,6 +703,19 @@ pub const CharsetState = packed struct(u16) { }; } + fn terminal(self: CharsetState) TerminalScreen.CharsetState { + var result: TerminalScreen.CharsetState = .{ + .gl = self.gl.terminal(), + .gr = self.gr.terminal(), + .single_shift = self.single_shift.terminal(), + }; + result.charsets.set(.G0, self.g0.terminal()); + result.charsets.set(.G1, self.g1.terminal()); + result.charsets.set(.G2, self.g2.terminal()); + result.charsets.set(.G3, self.g3.terminal()); + return result; + } + fn charset(value: terminal_charsets.Charset) Charset { return switch (value) { .utf8 => .utf8, @@ -440,7 +735,7 @@ pub const CharsetState = packed struct(u16) { } /// Decode and validate the packed charset registry. - pub fn decode(reader: *std.Io.Reader) DecodeError!CharsetState { + pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!CharsetState { const result: CharsetState = @bitCast(try io.readInt(reader, u16)); if (result._padding != 0) return error.InvalidCharsetState; switch (result.single_shift) { @@ -464,6 +759,14 @@ pub const ProtectedMode = enum(u8) { .dec => .dec, }; } + + fn terminal(self: ProtectedMode) terminal_ansi.ProtectedMode { + return switch (self) { + .off => .off, + .iso => .iso, + .dec => .dec, + }; + } }; /// The complete fixed-size Kitty keyboard stack. @@ -481,7 +784,7 @@ pub const KittyKeyboard = struct { _padding: u3 = 0, /// Decode and validate one Kitty keyboard flag byte. - pub fn decode(reader: *std.Io.Reader) DecodeError!Flags { + pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!Flags { const result: Flags = @bitCast(try reader.takeByte()); if (result._padding != 0) { return error.InvalidKittyKeyboardFlags; @@ -504,8 +807,24 @@ pub const KittyKeyboard = struct { 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 and validate the complete fixed Kitty keyboard stack. - pub fn decode(reader: *std.Io.Reader) DecodeError!KittyKeyboard { + pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!KittyKeyboard { const index = try reader.takeByte(); if (index >= 8) return error.InvalidKittyKeyboardIndex; @@ -564,8 +883,26 @@ pub const SemanticClick = union(SemanticClickKind) { }; } + fn terminal( + self: SemanticClick, + ) TerminalScreen.SemanticPrompt.SemanticClick { + return switch (self) { + .none => .none, + .click_events => |value| .{ .click_events = switch (value) { + .absolute => .absolute, + .relative => .relative, + } }, + .cl => |value| .{ .cl = switch (value) { + .line => .line, + .multiple => .multiple, + .conservative_vertical => .conservative_vertical, + .smart_vertical => .smart_vertical, + } }, + }; + } + /// Decode and validate the semantic-click kind and value bytes. - pub fn decode(reader: *std.Io.Reader) DecodeError!SemanticClick { + pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!SemanticClick { const kind_raw = try reader.takeByte(); const value_raw = try reader.takeByte(); const kind = std.enums.fromInt( @@ -613,7 +950,7 @@ pub const SavedCursor = struct { _padding: u5 = 0, /// Decode and validate saved cursor flags. - pub fn decode(reader: *std.Io.Reader) DecodeError!Flags { + pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!Flags { const result: Flags = @bitCast(try reader.takeByte()); if (result._padding != 0) return error.InvalidSavedCursorFlags; return result; @@ -634,6 +971,18 @@ pub const SavedCursor = struct { }; } + 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.terminal(), + }; + } + /// Encode one optional saved cursor value. pub fn encode( self: SavedCursor, @@ -656,7 +1005,7 @@ pub const SavedCursor = struct { } /// Decode and validate one optional saved cursor value. - pub fn decode(reader: *std.Io.Reader) DecodeError!SavedCursor { + pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!SavedCursor { return .{ .x = try io.readInt(reader, u16), .y = try io.readInt(reader, u16), @@ -803,7 +1152,7 @@ pub const Header = struct { } /// Decode and validate the fixed SCREEN payload header. - pub fn decode(reader: *std.Io.Reader) DecodeError!Header { + pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!Header { // Screen identity and cursor position. const key = std.enums.fromInt( Key, @@ -1573,7 +1922,7 @@ test "framed native SCREEN and PAGE sequence" { defer screen.deinit(); // Configure the live cursor state represented by the fixed header. - screen.cursorAbsolute(5, 6); + screen.cursorAbsolute(7, 6); screen.cursor.cursor_style = .block_hollow; screen.cursor.pending_wrap = true; screen.cursor.protected = false; @@ -1631,7 +1980,7 @@ test "framed native SCREEN and PAGE sequence" { .origin = true, .charset = charset, }; - try screen.startHyperlink("cursor-uri", "cursor-id"); + try screen.startHyperlink("cursor-uri", null); var destination: std.Io.Writer.Allocating = .init( std.testing.allocator, @@ -1653,8 +2002,9 @@ test "framed native SCREEN and PAGE sequence" { const payload_reader = record_reader.payloadReader(); var expected_header = testHeader(); expected_header.page_count = 1; - expected_header.cursor_x = 5; + expected_header.cursor_x = 7; expected_header.cursor_y = 6; + expected_header.hyperlink_implicit_id = 0x0a0b0c0e; try std.testing.expectEqualDeep( expected_header, try Header.decode(payload_reader), @@ -1665,7 +2015,7 @@ test "framed native SCREEN and PAGE sequence" { ); const expected_hyperlink: TerminalHyperlink = .{ - .id = .{ .explicit = "cursor-id" }, + .id = .{ .implicit = 0x0a0b0c0d }, .uri = "cursor-uri", }; var actual_hyperlink = (try decodeCursorHyperlink( @@ -1681,6 +2031,87 @@ test "framed native SCREEN and PAGE sequence" { try std.testing.expectEqual(@as(u16, 8), decoded_page.size.cols); try std.testing.expectEqual(@as(u16, 8), decoded_page.size.rows); try std.testing.expectError(error.EndOfStream, source.takeByte()); + + // The complete sequence restores directly into native Screen/PageList + // state, including page-local cursor references. + var restore_source: std.Io.Reader = .fixed(destination.written()[6..]); + var restored = try decode( + &restore_source, + std.testing.io, + std.testing.allocator, + .alternate, + .{ .cols = 8, .rows = 8, .max_scrollback_bytes = 0 }, + ); + defer restored.deinit(); + + 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, + ); + try expectHyperlinkEqual( + expected_hyperlink, + restored.cursor.hyperlink.?.*, + ); + + 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" { @@ -1761,6 +2192,136 @@ test "SCREEN encodes the minimal complete-page active suffix" { 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 restored = try decode( + &restore_source, + std.testing.io, + std.testing.allocator, + .primary, + .{ + .cols = 80, + .rows = screen_rows, + .max_scrollback_bytes = null, + }, + ); + defer restored.deinit(); + + 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(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 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(); + try encode(&screen, .primary, &destination); + + // The caller selects which native screen receives the record. + { + var source: std.Io.Reader = .fixed(destination.written()); + try std.testing.expectError( + error.UnexpectedScreenKey, + decode( + &source, + std.testing.io, + std.testing.allocator, + .alternate, + .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, + ), + ); + } + + // 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, + .primary, + .{ .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(), + .primary, + .{ .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 record_writer = try record.Writer.init( + &empty_sequence, + .screen, + ); + try Header.init(&screen, .primary, 0).encode( + record_writer.payloadWriter(), + ); + try record_writer.payloadWriter().writeByte(0); + try record_writer.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, + .primary, + .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, + ), + ); } test "SCREEN sequence failure preserves preceding bytes" { From 7d91b8776664208a1582846f1286396edbce04da Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Jul 2026 08:20:26 -0700 Subject: [PATCH 12/35] terminal/snapshot: history record --- src/terminal/snapshot/history.zig | 730 ++++++++++++++++++++++++++++++ src/terminal/snapshot/main.zig | 48 +- src/terminal/snapshot/record.zig | 4 +- src/terminal/snapshot/screen.zig | 7 +- 4 files changed, 776 insertions(+), 13 deletions(-) create mode 100644 src/terminal/snapshot/history.zig diff --git a/src/terminal/snapshot/history.zig b/src/terminal/snapshot/history.zig new file mode 100644 index 000000000..d52f980fa --- /dev/null +++ b/src/terminal/snapshot/history.zig @@ -0,0 +1,730 @@ +//! 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 +//! history uses zero for all three count fields and has no following PAGE +//! records. +//! +//! The first SCREEN page may begin above the active area. Those incidental +//! history rows are counted by `screen_overlap_rows`; they are not repeated +//! after HISTORY. `total_rows` is the authoritative number of logical rows +//! before the active area, including both the SCREEN overlap and the rows in +//! 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 +--------------------------------+ +//! | Total logical history rows | +//! | u64 | +//! 14 +--------------------------------+ +//! | SCREEN overlap rows (u16) | +//! 16 +--------------------------------+ +//! ``` +//! +//! The sum of `screen_overlap_rows` and the logical row counts of all +//! following PAGE records must equal `total_rows`. 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 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 == 16); + } + + /// Identifies the previously encoded screen that owns this history. + key: screen.Key, + + /// Number of complete PAGE records immediately following this record. + page_count: u32, + + /// Authoritative number of logical rows before the active area. + total_rows: u64, + + /// History rows already included at the start of the first SCREEN page. + screen_overlap_rows: u16, + + /// Encode the fixed HISTORY payload. + pub fn encode( + self: Header, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try io.writeInt(writer, u16, @intFromEnum(self.key)); + try io.writeInt(writer, u32, self.page_count); + try io.writeInt(writer, u64, self.total_rows); + try io.writeInt(writer, u16, self.screen_overlap_rows); + } + + 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 key = std.enums.fromInt( + screen.Key, + try io.readInt(reader, u16), + ) orelse return error.InvalidKey; + return .{ + .key = key, + .page_count = try io.readInt(reader, u32), + .total_rows = try io.readInt(reader, u64), + .screen_overlap_rows = try io.readInt(reader, u16), + }; + } + + fn computeLen() usize { + comptime { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + const value: Header = .{ + .key = .primary, + .page_count = 0, + .total_rows = 0, + .screen_overlap_rows = 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, + + /// The complete historical prefix has more rows than the header fits. + RowCountOverflow, + }; + +/// 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. On failure, the entire sequence is +/// removed while earlier destination bytes remain. +pub fn encode( + terminal_screen: *const TerminalScreen, + key: TerminalScreenKey, + destination: *std.Io.Writer.Allocating, +) EncodeError!void { + const sequence_start = destination.written().len; + errdefer destination.shrinkRetainingCapacity(sequence_start); + + // 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 active_top = terminal_screen.pages.getTopLeft(.active); + const page_count: usize, const total_rows: u64 = count: { + var page_count: usize = 0; + var total_rows: u64 = active_top.y; + var node = active_top.node.prev; + while (node) |current| : (node = current.prev) { + page_count += 1; + total_rows = std.math.add( + u64, + total_rows, + current.rows(), + ) catch return error.RowCountOverflow; + } + + break :count .{ page_count, total_rows }; + }; + + const header: Header = .{ + .key = switch (key) { + .primary => .primary, + .alternate => .alternate, + }, + .page_count = std.math.cast( + u32, + page_count, + ) orelse return error.PageCountOverflow, + .total_rows = total_rows, + .screen_overlap_rows = active_top.y, + }; + + // HISTORY declares exactly how many PAGE records follow and how their rows + // combine with the overlap already carried by SCREEN. + { + var record_writer = try record.Writer.init(destination, .history); + errdefer record_writer.cancel(); + try header.encode(record_writer.payloadWriter()); + try record_writer.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 = active_top.node.prev; + 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 = Allocator.Error || + Header.DecodeError || + page.DecodeError || + record.Reader.InitError || + record.Reader.FinishError || + TerminalPageList.PageAllocation.FinalizeError || + error{ + /// The next record is valid but is not a HISTORY. + UnexpectedRecordTag, + + /// The HISTORY key does not match the caller-selected screen. + UnexpectedScreenKey, + + /// The Screen already contains complete pages before its active page. + ExistingHistory, + + /// The claimed overlap does not match the restored SCREEN sequence. + InvalidScreenOverlap, + + /// The declared total does not match the restored historical rows. + InvalidHistoryRows, + }; + +/// 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 { + // Decode and finish the self-contained HISTORY manifest before consuming + // any of its following PAGE records. + const header: Header = header: { + 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(); + break :header header; + }; + + const decoded_key: TerminalScreenKey = switch (header.key) { + .primary => .primary, + .alternate => .alternate, + }; + if (decoded_key != expected_key) return error.UnexpectedScreenKey; + + // 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; + } + if (header.screen_overlap_rows != active_top.y) { + return error.InvalidScreenOverlap; + } + if (header.total_rows < header.screen_overlap_rows) { + return error.InvalidHistoryRows; + } + + var restored_rows: u64 = header.screen_overlap_rows; + for (0..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(source); + var allocation = try terminal_screen.pages.allocatePage( + decoder.capacity(), + ); + defer allocation.deinit(); + try decoder.decode(allocation.page(), alloc); + + // Validate the authoritative row total before publishing this page. + const page_rows = allocation.page().size.rows; + restored_rows = std.math.add( + u64, + restored_rows, + page_rows, + ) catch return error.InvalidHistoryRows; + if (restored_rows > header.total_rows) { + return error.InvalidHistoryRows; + } + + const contains_prompt = hasSemanticPrompt(allocation.page()); + try allocation.finalize(.prepend); + if (contains_prompt) terminal_screen.semantic_prompt.seen = true; + } + + if (restored_rows != header.total_rows) { + return error.InvalidHistoryRows; + } + + terminal_screen.pages.assertIntegrity(); + terminal_screen.assertIntegrity(); +} + +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; +} + +test "HISTORY header golden encoding and decoding" { + const expected: Header = .{ + .key = .alternate, + .page_count = 0x01020304, + .total_rows = 0x0102030405060708, + .screen_overlap_rows = 0x090a, + }; + const fixture = + "\x01\x00\x04\x03\x02\x01" ++ + "\x08\x07\x06\x05\x04\x03\x02\x01\x0a\x09"; + + try std.testing.expectEqual(Header.len, fixture.len); + var encoded: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try expected.encode(&writer); + try std.testing.expectEqualStrings(fixture, writer.buffered()); + + var reader: std.Io.Reader = .fixed(fixture); + try std.testing.expectEqualDeep(expected, try Header.decode(&reader)); + for (0..Header.len) |fixture_len| { + var truncated: std.Io.Reader = .fixed(fixture[0..fixture_len]); + try std.testing.expectError( + error.EndOfStream, + Header.decode(&truncated), + ); + } +} + +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(); + try screen.encode(&source_screen, .primary, &destination); + 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, &destination); + 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(screen.Key.primary, header.key); + try std.testing.expectEqual(@as(u32, 2), header.page_count); + try std.testing.expectEqual( + @as(u16, active_top.y), + header.screen_overlap_rows, + ); + try std.testing.expectEqual( + @as(u64, active_top.y) + + oldest_history.rows() + + newest_history.rows(), + header.total_rows, + ); + + 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 restored = try screen.decode( + &restore_source, + std.testing.io, + std.testing.allocator, + .primary, + .{ + .cols = 80, + .rows = screen_rows, + .max_scrollback_bytes = null, + }, + ); + defer restored.deinit(); + 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 truncated = try screen.decode( + &truncated_source, + std.testing.io, + std.testing.allocator, + .primary, + .{ + .cols = 80, + .rows = screen_rows, + .max_scrollback_bytes = null, + }, + ); + defer truncated.deinit(); + 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 partial = try screen.decode( + &partial_source, + std.testing.io, + std.testing.allocator, + .primary, + .{ + .cols = 80, + .rows = screen_rows, + .max_scrollback_bytes = null, + }, + ); + defer partial.deinit(); + 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(); + try encode(&terminal_screen, .primary, &destination); + + 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.expectEqual(@as(u64, 0), header.total_rows); + try std.testing.expectEqual(@as(u16, 0), header.screen_overlap_rows); + 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 manifest mismatches" { + var terminal_screen = try TerminalScreen.init( + std.testing.io, + std.testing.allocator, + .{ + .cols = 2, + .rows = 2, + .max_scrollback_bytes = null, + }, + ); + defer terminal_screen.deinit(); + + // Encode a manifest without PAGE records and vary only the fixed fields so + // each structural validation boundary is exercised in isolation. + const cases = .{ + .{ + Header{ + .key = .alternate, + .page_count = 0, + .total_rows = 0, + .screen_overlap_rows = 0, + }, + error.UnexpectedScreenKey, + }, + .{ + Header{ + .key = .primary, + .page_count = 0, + .total_rows = 1, + .screen_overlap_rows = 1, + }, + error.InvalidScreenOverlap, + }, + .{ + Header{ + .key = .primary, + .page_count = 0, + .total_rows = 1, + .screen_overlap_rows = 0, + }, + error.InvalidHistoryRows, + }, + .{ + Header{ + .key = .primary, + .page_count = 1, + .total_rows = 1, + .screen_overlap_rows = 0, + }, + error.EndOfStream, + }, + }; + inline for (cases) |case| { + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + var record_writer = try record.Writer.init(&destination, .history); + try case[0].encode(record_writer.payloadWriter()); + try record_writer.finish(); + + var source: std.Io.Reader = .fixed(destination.written()); + try std.testing.expectError( + case[1], + decode( + &source, + std.testing.allocator, + .primary, + &terminal_screen, + ), + ); + terminal_screen.pages.assertIntegrity(); + terminal_screen.assertIntegrity(); + } +} diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index 8d0658cbd..d7f3f7e34 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -7,11 +7,16 @@ //! //! 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 down the terminal state, viewport, etc. followed -//! by a "READY" event. At the READY state, the terminal is functional and -//! could in theory begin processing pty bytes. After the READY state the -//! binary format continues transmitting history and extra assets such as -//! images and so on. +//! 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 //! @@ -39,9 +44,35 @@ //! +------------------+ //! ``` //! -//! Records have a strict order: TERMINAL, the primary SCREEN and its -//! PAGE records, an optional alternate SCREEN and its PAGE records, -//! CONTINUATION, READY, and FINISH. +//! Records have a strict order: +//! +//! ```text +//! +----------------------------------------+ +//! | TERMINAL | +//! +----------------------------------------+ +//! | SCREEN (primary) | +//! | PAGE * screen.page_count | +//! +----------------------------------------+ +//! | SCREEN (alternate, when present) | +//! | PAGE * screen.page_count | +//! +----------------------------------------+ +//! | READY | +//! +----------------------------------------+ +//! | HISTORY (primary) | +//! | PAGE * history.page_count | +//! +----------------------------------------+ +//! | HISTORY (alternate, when present) | +//! | PAGE * history.page_count | +//! +----------------------------------------+ +//! | FINISH | +//! +----------------------------------------+ +//! ``` +//! +//! The SCREEN sequences contain the complete pages needed to restore each +//! active area. A HISTORY sequence 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 is followed by end-of-file. //! //! ## Encoding //! @@ -66,6 +97,7 @@ 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"); diff --git a/src/terminal/snapshot/record.zig b/src/terminal/snapshot/record.zig index 3694173f1..6a5a20e70 100644 --- a/src/terminal/snapshot/record.zig +++ b/src/terminal/snapshot/record.zig @@ -37,8 +37,8 @@ pub const Tag = enum(u16) { /// One complete logical terminal page. page = 3, - /// Unfinished UTF-8 and terminal parser input. - continuation = 4, + /// One screen's complete history manifest and page sequence. + history = 4, /// Digest marking the validated terminal-state prefix. ready = 5, diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index bb61af5b8..5b9d23bc5 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -37,9 +37,10 @@ //! n = page_count //! ``` //! -//! A later history-capable snapshot version provides the authoritative history -//! manifest. It counts any incidental prefix above as already resident and -//! describes older rows that may be loaded after the terminal becomes ready. +//! The HISTORY record for this screen provides the authoritative history +//! manifest. It counts any incidental prefix above as the SCREEN overlap, then +//! sends the older complete pages after the terminal becomes ready. The prefix +//! is not sent again. //! //! The SCREEN payload begins with a fixed header. When the header says there is //! no saved cursor, the payload is: From 0288bec3cf314af00fb1b8425b32fc578fb23019 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Jul 2026 09:25:33 -0700 Subject: [PATCH 13/35] terminal/snapshot: terminal record --- src/terminal/snapshot/main.zig | 1 + src/terminal/snapshot/terminal.zig | 1806 ++++++++++++++++++++++++++++ 2 files changed, 1807 insertions(+) create mode 100644 src/terminal/snapshot/terminal.zig diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index d7f3f7e34..8816c73ab 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -103,6 +103,7 @@ 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"); test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/snapshot/terminal.zig b/src/terminal/snapshot/terminal.zig new file mode 100644 index 000000000..4e52f9295 --- /dev/null +++ b/src/terminal/snapshot/terminal.zig @@ -0,0 +1,1806 @@ +//! 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 are ordered by +//! key: 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. +//! +//! 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 zero. +//! +//! ### 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. +//! +//! ### 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. +//! +//! ## 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, virtual placeholders, and glyph glossary registrations +//! are unsupported by this snapshot version and are ignored during capture. +//! 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 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 || + ValidationError || + error{ + InvalidStatusDisplay, + InvalidCursorIsDefault, + InvalidCursorStyle, + InvalidCursorBlink, + InvalidShellRedraw, + InvalidModifyOtherKeys, + InvalidMouseEvent, + InvalidMouseFormat, + InvalidMouseShiftCapture, + InvalidMouseShape, + InvalidPasswordInput, + InvalidModes, + InvalidDynamicRGB, + InvalidTabStops, + }; + +/// 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 and validate the fixed TERMINAL payload header. + 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 return error.InvalidStatusDisplay; + const active_screen_key = enumFromInt( + TerminalScreenKey, + try io.readInt(reader, u16), + ) orelse return error.InvalidActiveScreenKey; + const screen_count = try io.readInt(reader, u16); + const previous_raw = try io.readInt(reader, u32); + const previous_codepoint: ?u21 = + if (previous_raw == std.math.maxInt(u32)) + null + else + std.math.cast(u21, previous_raw) orelse + return error.InvalidPreviousCodepoint; + + // Cursor presentation defaults shared by the screens. + const cursor_is_default = switch (try reader.takeByte()) { + 0 => false, + 1 => true, + else => return error.InvalidCursorIsDefault, + }; + const cursor_default_style = enumFromInt( + TerminalScreen.CursorStyle, + try reader.takeByte(), + ) orelse return error.InvalidCursorStyle; + const cursor_default_blink: ?bool = switch (try reader.takeByte()) { + 0 => null, + 1 => false, + 2 => true, + else => return error.InvalidCursorBlink, + }; + + // Terminal input, semantic redraw, and pointer behavior. + const shell_redraw = enumFromInt( + terminal_osc.semantic_prompt.Redraw, + try reader.takeByte(), + ) orelse return error.InvalidShellRedraw; + const modify_other_keys_2 = switch (try reader.takeByte()) { + 0 => false, + 1 => true, + else => return error.InvalidModifyOtherKeys, + }; + const mouse_event = enumFromInt( + terminal_mouse.Event, + try reader.takeByte(), + ) orelse return error.InvalidMouseEvent; + const mouse_format = enumFromInt( + terminal_mouse.Format, + try reader.takeByte(), + ) orelse return error.InvalidMouseFormat; + const mouse_shift_capture: ?bool = switch (try reader.takeByte()) { + 0 => null, + 1 => false, + 2 => true, + else => return error.InvalidMouseShiftCapture, + }; + const mouse_shape = enumFromInt( + terminal_mouse.Shape, + try reader.takeByte(), + ) orelse return error.InvalidMouseShape; + const password_input = switch (try reader.takeByte()) { + 0 => false, + 1 => true, + else => return error.InvalidPasswordInput, + }; + + // 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 = std.math.cast(ModeBits, raw) orelse + return error.InvalidModes; + 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), + ); + + const result: Header = .{ + .columns = columns, + .rows = rows, + .width_px = width_px, + .height_px = height_px, + + .scrolling_region_top = scrolling_region_top, + .scrolling_region_bottom = scrolling_region_bottom, + .scrolling_region_left = scrolling_region_left, + .scrolling_region_right = scrolling_region_right, + + .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, + }; + try result.validate(); + 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) { + if (raw & mask != 0) return error.InvalidTabStops; + } else if (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. Any failure +/// removes the incomplete record while preserving bytes that were already +/// present in `destination`. +pub fn encode( + terminal: *const Terminal, + destination: *std.Io.Writer.Allocating, +) EncodeError!void { + const header = try Header.init(terminal); + + var record_writer = try record.Writer.init(destination, .terminal); + errdefer record_writer.cancel(); + try encodePayload( + header, + &terminal.tabstops, + &terminal.colors.palette, + terminal.getPwd() orelse "", + terminal.getTitle() orelse "", + record_writer.payloadWriter(), + ); + try record_writer.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, + + /// A wire scrollback policy does not fit the native platform. + ScrollbackLimitOverflow, + }; + +/// 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 = try nativeScrollbackLimit( + header.max_scrollback_bytes, + ); + const max_scrollback_lines = try 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 || error{InvalidDynamicRGB})!DynamicRGB { + const default_present = try reader.takeByte(); + const default_rgb = try decodeRGB(reader); + const default: ?RGB = switch (default_present) { + 0 => if (default_rgb.eql(.{})) + null + else + return error.InvalidDynamicRGB, + 1 => default_rgb, + else => return error.InvalidDynamicRGB, + }; + + const override_present = try reader.takeByte(); + const override_rgb = try decodeRGB(reader); + const override: ?RGB = switch (override_present) { + 0 => if (override_rgb.eql(.{})) + null + else + return error.InvalidDynamicRGB, + 1 => override_rgb, + else => return error.InvalidDynamicRGB, + }; + + 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, +) error{ScrollbackLimitOverflow}!?usize { + const present = value orelse return null; + return std.math.cast(usize, present) orelse + error.ScrollbackLimitOverflow; +} + +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 = + "\x02\x01\x04\x03\x08\x07\x06\x05\x0c\x0b\x0a\x09" ++ + "\x01\x00\x02\x00\x03\x00\x04\x00" ++ + "\x01\x01\x00\x02\x00\x41\x00\x00\x00" ++ + "\x01\x03\x02" ++ + "\x02\x01\x04\x04\x01\x21\x01" ++ + "\x01\x00\x00\x00\x00\x00\x00\x00" ++ + "\x00\x00\x00\x00\x00\x02\x00\x00" ++ + "\x01\x00\x00\x00\x00\x02\x00\x00" ++ + "\x01\x01\x02\x03\x00\x00\x00\x00" ++ + "\x00\x00\x00\x00\x01\x04\x05\x06" ++ + "\x01\x07\x08\x09\x01\x0a\x0b\x0c" ++ + "\xff\xff\xff\xff\xff\xff\xff\xff" ++ + "\x08\x07\x06\x05\x04\x03\x02\x01"; + +test "TERMINAL payload type registries" { + try std.testing.expectEqual(@as(usize, 103), Header.len); + try std.testing.expectEqual( + @as(usize, 42), + @bitSizeOf(terminal_modes.ModePacked), + ); + try std.testing.expectEqual( + @as(usize, 8), + @sizeOf(terminal_modes.ModePacked), + ); + + var first: terminal_modes.ModePacked = std.mem.zeroes( + terminal_modes.ModePacked, + ); + 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)), + ); + + try std.testing.expectEqual( + @as(u1, 0), + @intFromEnum(terminal_ansi.StatusDisplay.main), + ); + try std.testing.expectEqual( + @as(c_int, 33), + @intFromEnum(terminal_mouse.Shape.zoom_out), + ); +} + +test "TERMINAL header golden encoding and decoding" { + const testing = std.testing; + try testing.expectEqual(Header.len, test_header_fixture.len); + + var encoded: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try test_header.encode(&writer); + try testing.expectEqualStrings(test_header_fixture, writer.buffered()); + + // 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 rejects invalid 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); + + // Single-byte registries and boolean encodings reject every value just + // beyond their current snapshot range. + const byte_cases = .{ + .{ @as(usize, 20), @as(u8, 2), error.InvalidStatusDisplay }, + .{ @as(usize, 21), @as(u8, 2), error.InvalidActiveScreenKey }, + .{ @as(usize, 29), @as(u8, 2), error.InvalidCursorIsDefault }, + .{ @as(usize, 30), @as(u8, 4), error.InvalidCursorStyle }, + .{ @as(usize, 31), @as(u8, 3), error.InvalidCursorBlink }, + .{ @as(usize, 32), @as(u8, 3), error.InvalidShellRedraw }, + .{ @as(usize, 33), @as(u8, 2), error.InvalidModifyOtherKeys }, + .{ @as(usize, 34), @as(u8, 5), error.InvalidMouseEvent }, + .{ @as(usize, 35), @as(u8, 5), error.InvalidMouseFormat }, + .{ @as(usize, 36), @as(u8, 3), error.InvalidMouseShiftCapture }, + .{ @as(usize, 37), @as(u8, 34), error.InvalidMouseShape }, + .{ @as(usize, 38), @as(u8, 2), error.InvalidPasswordInput }, + .{ @as(usize, 44), @as(u8, 4), error.InvalidModes }, + .{ @as(usize, 63), @as(u8, 2), error.InvalidDynamicRGB }, + .{ @as(usize, 68), @as(u8, 1), error.InvalidDynamicRGB }, + }; + inline for (byte_cases) |case| { + var fixture = test_header_fixture.*; + fixture[case[0]] = case[1]; + var reader: std.Io.Reader = .fixed(&fixture); + try testing.expectError(case[2], Header.decode(&reader)); + } + + // Cross-field and multi-byte invariants are validated after decoding. + 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), + ); + + 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.expectError( + error.InvalidActiveScreenKey, + Header.decode(&active_screen_reader), + ); + + var invalid_scrolling_region = test_header_fixture.*; + invalid_scrolling_region[14] = 0x04; + invalid_scrolling_region[15] = 0x03; + var scrolling_region_reader: std.Io.Reader = .fixed( + &invalid_scrolling_region, + ); + try testing.expectError( + error.InvalidScrollingRegion, + Header.decode(&scrolling_region_reader), + ); + + var invalid_codepoint = test_header_fixture.*; + invalid_codepoint[25] = 0x00; + invalid_codepoint[26] = 0xd8; + invalid_codepoint[27] = 0x00; + invalid_codepoint[28] = 0x00; + var codepoint_reader: std.Io.Reader = .fixed(&invalid_codepoint); + try testing.expectError( + error.InvalidPreviousCodepoint, + Header.decode(&codepoint_reader), + ); +} + +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 rejects padding and every truncation" { + 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); + try testing.expectError( + error.InvalidTabStops, + decodePayload(&padding_reader, testing.allocator), + ); + + for (0..destination.written().len) |fixture_len| { + var reader: std.Io.Reader = .fixed( + destination.written()[0..fixture_len], + ); + try testing.expectError( + error.EndOfStream, + decodePayload(&reader, testing.allocator), + ); + } +} + +test "TERMINAL record encodes native terminal state" { + 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"); + + // Decode the generated framing and payload independently. + var destination: std.Io.Writer.Allocating = .init(testing.allocator); + defer destination.deinit(); + try encode(&terminal, &destination); + + var source: std.Io.Reader = .fixed(destination.written()); + var record_reader: record.Reader = undefined; + try record_reader.init(&source); + try testing.expectEqual(record.Tag.terminal, record_reader.header.tag); + var decoded = try decodePayload( + record_reader.payloadReader(), + testing.allocator, + ); + defer decoded.deinit(testing.allocator); + try record_reader.finish(); + + // Native screen routing and policy are derived rather than caller-supplied. + try testing.expectEqual(@as(u16, 10), decoded.header.columns); + try testing.expectEqual(@as(u16, 3), decoded.header.rows); + try testing.expectEqual(@as(u32, 800), decoded.header.width_px); + try testing.expectEqual(@as(u32, 600), decoded.header.height_px); + try testing.expectEqual( + terminal.scrolling_region, + Terminal.ScrollingRegion{ + .top = decoded.header.scrolling_region_top, + .bottom = decoded.header.scrolling_region_bottom, + .left = decoded.header.scrolling_region_left, + .right = decoded.header.scrolling_region_right, + }, + ); + try testing.expectEqual(terminal.status_display, decoded.header.status_display); + try testing.expectEqual(.primary, decoded.header.active_screen_key); + try testing.expectEqual(@as(u16, 1), decoded.header.screen_count); + try testing.expectEqual(@as(?u21, 'X'), decoded.header.previous_codepoint); + try testing.expectEqual(terminal.cursor.is_default, decoded.header.cursor_is_default); + try testing.expectEqual( + terminal.cursor.default_style, + decoded.header.cursor_default_style, + ); + try testing.expectEqual( + terminal.cursor.default_blink, + decoded.header.cursor_default_blink, + ); + try testing.expectEqual( + terminal.flags.shell_redraws_prompt, + decoded.header.shell_redraw, + ); + try testing.expectEqual( + terminal.flags.modify_other_keys_2, + decoded.header.modify_other_keys_2, + ); + try testing.expectEqual(terminal.flags.mouse_event, decoded.header.mouse_event); + try testing.expectEqual( + terminal.flags.mouse_format, + decoded.header.mouse_format, + ); + try testing.expectEqual(@as(?bool, true), decoded.header.mouse_shift_capture); + try testing.expectEqual(terminal.mouse_shape, decoded.header.mouse_shape); + try testing.expectEqual( + terminal.flags.password_input, + decoded.header.password_input, + ); + try testing.expectEqualDeep(terminal.modes.values, decoded.header.current_modes); + try testing.expectEqualDeep(terminal.modes.saved, decoded.header.saved_modes); + try testing.expectEqualDeep(terminal.modes.default, decoded.header.default_modes); + try testing.expectEqualDeep( + terminal.colors.background, + decoded.header.background, + ); + try testing.expectEqualDeep(terminal.colors.palette, decoded.palette); + try testing.expectEqual(@as(?u64, 12_345), decoded.header.max_scrollback_bytes); + try testing.expectEqual(@as(?u64, 67), decoded.header.max_scrollback_rows); + try testing.expect(decoded.tabstops.get(1)); + try testing.expect(decoded.tabstops.get(9)); + try testing.expectEqualStrings("file:///tmp/native", decoded.pwd); + try testing.expectEqualStrings("native title", decoded.title); + + // The public decoder installs the same payload into native ownership. + var native_source: std.Io.Reader = .fixed(destination.written()); + var restored = try decode( + &native_source, + 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.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(); + try encode(&terminal, &destination); + + 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 record encoding is transactional" { + 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"); + + // Payload validation occurs after framing is reserved, so this covers the + // record writer's rollback path. + terminal.colors.palette.current[7] = .{ .r = 1, .g = 2, .b = 3 }; + try testing.expectError( + error.InvalidPalette, + encode(&terminal, &destination), + ); + 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 record_writer = try record.Writer.init(&wrong_tag, .screen); + try record_writer.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(); + try encode(&terminal, &encoded); + + 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()), + ); +} From 83e482700b7e647bc9c6da985493f7b2ae991e92 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Jul 2026 09:45:55 -0700 Subject: [PATCH 14/35] terminal/snapshot: ready/finish checkpoints --- src/terminal/snapshot/checkpoint.zig | 251 +++++++++++++++++++++++++++ src/terminal/snapshot/main.zig | 6 + 2 files changed, 257 insertions(+) create mode 100644 src/terminal/snapshot/checkpoint.zig diff --git a/src/terminal/snapshot/checkpoint.zig b/src/terminal/snapshot/checkpoint.zig new file mode 100644 index 000000000..3b76df432 --- /dev/null +++ b/src/terminal/snapshot/checkpoint.zig @@ -0,0 +1,251 @@ +//! 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 must be followed +//! immediately by end-of-file; READY may be followed by history. +//! +//! 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 record = @import("record.zig"); + +const Blake3 = std.crypto.hash.Blake3; + +/// The prefix digest exchanged by checkpoint codecs and the snapshot driver. +pub const Digest = [Blake3.digest_length]u8; + +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 in `destination`. +/// +/// If writing fails, the incomplete checkpoint is removed while all covered +/// prefix bytes remain unchanged. +pub fn encode( + kind: Kind, + destination: *std.Io.Writer.Allocating, +) EncodeError!void { + var digest: Digest = undefined; + Blake3.hash(destination.written(), &digest, .{}); + + var record_writer = try record.Writer.init(destination, kind.tag()); + errdefer record_writer.cancel(); + try record_writer.payloadWriter().writeAll(&digest); + try record_writer.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, + + /// FINISH was followed by additional bytes. + TrailingData, + }; + +/// Decode and validate one checkpoint against its preceding-byte digest. +/// +/// `expected` must be finalized before any byte of this record is included in +/// the caller's running digest. FINISH additionally requires end-of-file. +pub fn decode( + kind: Kind, + expected: Digest, + source: *std.Io.Reader, +) DecodeError!void { + 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; + + if (kind == .finish) { + _ = source.peekByte() catch |err| switch (err) { + error.EndOfStream => return, + else => return err, + }; + return error.TrailingData; + } +} + +test "checkpoint BLAKE3-256 registry" { + const expected = [_]u8{ + 0x64, 0x37, 0xb3, 0xac, 0x38, 0x46, 0x51, 0x33, + 0xff, 0xb6, 0x3b, 0x75, 0x27, 0x3a, 0x8d, 0xb5, + 0x48, 0xc5, 0x58, 0x46, 0x5d, 0x79, 0xdb, 0x03, + 0xfd, 0x35, 0x9c, 0x6c, 0xd5, 0xbd, 0x9d, 0x85, + }; + var actual: Digest = undefined; + Blake3.hash("abc", &actual, .{}); + try std.testing.expectEqual(expected, actual); + + var hasher = Blake3.init(.{}); + hasher.update("a"); + hasher.update("bc"); + hasher.final(&actual); + try std.testing.expectEqual(expected, actual); + hasher.final(&actual); + try std.testing.expectEqual(expected, actual); +} + +test "READY and FINISH checkpoint coverage" { + const testing = std.testing; + + var snapshot: std.Io.Writer.Allocating = .init(testing.allocator); + defer snapshot.deinit(); + try snapshot.writer.writeAll("prefix"); + + // READY covers only the bytes that precede its own record. + const ready_offset = snapshot.written().len; + var ready_digest: Digest = undefined; + Blake3.hash(snapshot.written(), &ready_digest, .{}); + try encode(.ready, &snapshot); + const ready_end = snapshot.written().len; + + // History follows READY and is included by FINISH. + try snapshot.writer.writeAll("history"); + const finish_offset = snapshot.written().len; + var finish_digest: Digest = undefined; + Blake3.hash(snapshot.written(), &finish_digest, .{}); + try encode(.finish, &snapshot); + + var ready_source: std.Io.Reader = .fixed( + snapshot.written()[ready_offset..], + ); + try decode(.ready, ready_digest, &ready_source); + + var finish_source: std.Io.Reader = .fixed( + snapshot.written()[finish_offset..], + ); + try decode(.finish, finish_digest, &finish_source); + + // Including READY changes the FINISH digest even before history is added. + var ready_record_digest: Digest = undefined; + Blake3.hash( + snapshot.written()[0..ready_end], + &ready_record_digest, + .{}, + ); + try testing.expect(!std.mem.eql( + u8, + &ready_digest, + &ready_record_digest, + )); +} + +test "checkpoint rejects wrong tags, digests, and FINISH trailing data" { + const testing = std.testing; + + var snapshot: std.Io.Writer.Allocating = .init(testing.allocator); + defer snapshot.deinit(); + try snapshot.writer.writeAll("prefix"); + const checkpoint_offset = snapshot.written().len; + var digest: Digest = undefined; + Blake3.hash(snapshot.written(), &digest, .{}); + try encode(.ready, &snapshot); + + var wrong_tag: std.Io.Reader = .fixed( + snapshot.written()[checkpoint_offset..], + ); + try testing.expectError( + error.UnexpectedRecordTag, + decode(.finish, digest, &wrong_tag), + ); + + var invalid_digest = digest; + invalid_digest[0] ^= 1; + var invalid_digest_source: std.Io.Reader = .fixed( + snapshot.written()[checkpoint_offset..], + ); + try testing.expectError( + error.InvalidDigest, + decode(.ready, invalid_digest, &invalid_digest_source), + ); + + var finished: std.Io.Writer.Allocating = .init(testing.allocator); + defer finished.deinit(); + try finished.writer.writeAll("prefix"); + const finish_offset = finished.written().len; + try encode(.finish, &finished); + try finished.writer.writeByte(0); + + var trailing_source: std.Io.Reader = .fixed( + finished.written()[finish_offset..], + ); + try testing.expectError( + error.TrailingData, + decode(.finish, digest, &trailing_source), + ); +} + +test "checkpoint rejects corruption and every truncation" { + const testing = std.testing; + + var snapshot: std.Io.Writer.Allocating = .init(testing.allocator); + defer snapshot.deinit(); + try snapshot.writer.writeAll("prefix"); + const checkpoint_offset = snapshot.written().len; + var digest: Digest = undefined; + Blake3.hash(snapshot.written(), &digest, .{}); + try encode(.ready, &snapshot); + const fixture = snapshot.written()[checkpoint_offset..]; + + const corrupt = try testing.allocator.dupe(u8, fixture); + defer testing.allocator.free(corrupt); + corrupt[record.Header.len] ^= 1; + var corrupt_source: std.Io.Reader = .fixed(corrupt); + try testing.expectError( + error.InvalidChecksum, + decode(.ready, digest, &corrupt_source), + ); + + for (0..fixture.len) |fixture_len| { + var source: std.Io.Reader = .fixed(fixture[0..fixture_len]); + try testing.expectError( + error.EndOfStream, + decode(.ready, digest, &source), + ); + } +} diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index 8816c73ab..46775409b 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -74,6 +74,11 @@ //! Every SCREEN has one corresponding HISTORY, even when its history page count //! is zero. FINISH is followed by end-of-file. //! +//! 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. +//! //! ## Encoding //! //! Encode the envelope once, then append records in the required order: @@ -95,6 +100,7 @@ //! Each record type usually exposes an `encode` function that encodes //! a complete record, such as `screen.encode`. +pub const checkpoint = @import("checkpoint.zig"); pub const envelope = @import("envelope.zig"); pub const grid = @import("grid.zig"); pub const history = @import("history.zig"); From 86ec1463348441fa0b914dbde8b657eb96148775 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Jul 2026 10:38:00 -0700 Subject: [PATCH 15/35] terminal/snapshot: full encode/decode --- src/terminal/ScreenSet.zig | 6 +- src/terminal/snapshot/history.zig | 28 +- src/terminal/snapshot/main.zig | 29 +- src/terminal/snapshot/screen.zig | 65 ++--- src/terminal/snapshot/snapshot.zig | 413 +++++++++++++++++++++++++++++ src/terminal/snapshot/terminal.zig | 5 +- 6 files changed, 476 insertions(+), 70 deletions(-) create mode 100644 src/terminal/snapshot/snapshot.zig 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/snapshot/history.zig b/src/terminal/snapshot/history.zig index d52f980fa..52d527cfd 100644 --- a/src/terminal/snapshot/history.zig +++ b/src/terminal/snapshot/history.zig @@ -472,24 +472,28 @@ test "HISTORY encodes newest first and restores complete history" { // Restore SCREEN first, then prepend HISTORY directly into that PageList. var restore_source: std.Io.Reader = .fixed(destination.written()); - var restored = try screen.decode( + var decoded_screen = try screen.decode( &restore_source, std.testing.io, std.testing.allocator, - .primary, .{ .cols = 80, .rows = screen_rows, .max_scrollback_bytes = null, }, ); - defer restored.deinit(); + defer decoded_screen.deinit(); + try std.testing.expectEqual( + TerminalScreenKey.primary, + decoded_screen.key, + ); + const restored = &decoded_screen.screen; try std.testing.expect(!restored.semantic_prompt.seen); try decode( &restore_source, std.testing.allocator, .primary, - &restored, + restored, ); try std.testing.expectEqual( @@ -530,18 +534,18 @@ test "HISTORY encodes newest first and restores complete history" { var truncated_source: std.Io.Reader = .fixed( encoded[0 .. second_page_offset - 1], ); - var truncated = try screen.decode( + var decoded_truncated = try screen.decode( &truncated_source, std.testing.io, std.testing.allocator, - .primary, .{ .cols = 80, .rows = screen_rows, .max_scrollback_bytes = null, }, ); - defer truncated.deinit(); + 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( @@ -550,7 +554,7 @@ test "HISTORY encodes newest first and restores complete history" { &truncated_source, std.testing.allocator, .primary, - &truncated, + truncated, ), ); try std.testing.expectEqual( @@ -574,18 +578,18 @@ test "HISTORY encodes newest first and restores complete history" { ); var partial_source: std.Io.Reader = .fixed(encoded); - var partial = try screen.decode( + var decoded_partial = try screen.decode( &partial_source, std.testing.io, std.testing.allocator, - .primary, .{ .cols = 80, .rows = screen_rows, .max_scrollback_bytes = null, }, ); - defer partial.deinit(); + defer decoded_partial.deinit(); + const partial = &decoded_partial.screen; const screen_page_count = partial.pages.totalPages(); try std.testing.expectError( error.UnexpectedRecordTag, @@ -593,7 +597,7 @@ test "HISTORY encodes newest first and restores complete history" { &partial_source, std.testing.allocator, .primary, - &partial, + partial, ), ); try std.testing.expectEqual( diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index 46775409b..c08c437fd 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -44,17 +44,14 @@ //! +------------------+ //! ``` //! -//! Records have a strict order: +//! Record groups have a strict order: //! //! ```text //! +----------------------------------------+ //! | TERMINAL | //! +----------------------------------------+ -//! | SCREEN (primary) | -//! | PAGE * screen.page_count | -//! +----------------------------------------+ -//! | SCREEN (alternate, when present) | -//! | PAGE * screen.page_count | +//! | SCREEN * terminal.screen_count | +//! | PAGE * each screen.page_count | //! +----------------------------------------+ //! | READY | //! +----------------------------------------+ @@ -68,11 +65,13 @@ //! +----------------------------------------+ //! ``` //! -//! The SCREEN sequences contain the complete pages needed to restore each -//! active area. A HISTORY sequence 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 is followed by end-of-file. +//! The SCREEN sequences may appear in any key order. Each key must be unique +//! and must identify one of the screens declared by TERMINAL. They contain the +//! complete pages needed to restore each active area. A HISTORY sequence +//! 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 is followed by +//! end-of-file. //! //! READY and FINISH contain BLAKE3-256 digests of all preceding snapshot bytes. //! READY therefore validates the renderable active-state prefix. FINISH covers @@ -81,16 +80,15 @@ //! //! ## Encoding //! -//! Encode the envelope once, then append records in the required order: +//! Encode a complete snapshot into an empty allocating writer: //! //! ```zig //! var output: std.Io.Writer.Allocating = .init(alloc); //! defer output.deinit(); //! -//! try envelope.encode(&output.writer); -//! try screen.encode(&terminal_screen, .primary, &output); +//! try snapshot.encode(&terminal, &output); //! -//! const snapshot = output.written(); +//! const bytes = output.written(); //! ``` //! //! We have to use an allocating writer because record formats require @@ -108,6 +106,7 @@ 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 snapshot = @import("snapshot.zig"); pub const style = @import("style.zig"); pub const terminal = @import("terminal.zig"); diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index 5b9d23bc5..cd4d272e1 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -294,9 +294,6 @@ pub const DecodeError = PayloadDecodeError || /// The next record is valid but is not a SCREEN. UnexpectedRecordTag, - /// The SCREEN key does not match the caller-selected native screen. - UnexpectedScreenKey, - /// A SCREEN must declare at least one PAGE. InvalidPageCount, @@ -304,18 +301,30 @@ pub const DecodeError = PayloadDecodeError || InvalidCursorPosition, }; +/// One decoded SCREEN sequence and the native screen identified by its header. +pub const Decoded = struct { + key: TerminalScreenKey, + 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, - expected_key: TerminalScreenKey, options: TerminalScreen.Options, -) DecodeError!TerminalScreen { +) 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; @@ -340,11 +349,7 @@ pub fn decode( defer if (cursor_hyperlink) |*value| value.deinit(alloc); try record_reader.finish(); - // Reject a structurally valid SCREEN which does not describe the native - // screen selected by the caller. - if (header.key.terminal() != expected_key) { - return error.UnexpectedScreenKey; - } + const key = header.key.terminal(); if (header.page_count == 0) return error.InvalidPageCount; // Dimensions are terminal-wide state supplied by the enclosing snapshot. @@ -367,11 +372,11 @@ pub fn decode( var builder = try TerminalPageList.Builder.init(alloc, .{ .cols = options.cols, .rows = options.rows, - .max_size = if (expected_key == .alternate) + .max_size = if (key == .alternate) 0 else options.max_scrollback_bytes, - .max_lines = if (expected_key == .alternate) + .max_lines = if (key == .alternate) 0 else options.max_scrollback_lines, @@ -407,7 +412,7 @@ pub fn decode( .io = io_, .alloc = alloc, .pages = pages, - .no_scrollback = expected_key == .alternate or + .no_scrollback = key == .alternate or options.max_scrollback_bytes == 0, .cursor = .{ .x = header.cursor_x, @@ -504,7 +509,7 @@ pub fn decode( // before transferring ownership to the caller. result.pages.assertIntegrity(); result.assertIntegrity(); - return result; + return .{ .key = key, .screen = result }; } /// Errors possible while decoding fixed SCREEN payload fields. @@ -2036,14 +2041,15 @@ test "framed native SCREEN and PAGE sequence" { // 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 restored = try decode( + var decoded = try decode( &restore_source, std.testing.io, std.testing.allocator, - .alternate, .{ .cols = 8, .rows = 8, .max_scrollback_bytes = 0 }, ); - defer restored.deinit(); + 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); @@ -2197,18 +2203,19 @@ test "SCREEN encodes the minimal complete-page active suffix" { // 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 restored = try decode( + var decoded = try decode( &restore_source, std.testing.io, std.testing.allocator, - .primary, .{ .cols = 80, .rows = screen_rows, .max_scrollback_bytes = null, }, ); - defer restored.deinit(); + defer decoded.deinit(); + try std.testing.expectEqual(TerminalScreenKey.primary, decoded.key); + const restored = &decoded.screen; try std.testing.expectEqual(@as(usize, 2), restored.pages.totalPages()); const restored_top = restored.pages.getTopLeft(.active); @@ -2245,21 +2252,6 @@ test "SCREEN restoration rejects invalid and incomplete sequences" { defer destination.deinit(); try encode(&screen, .primary, &destination); - // The caller selects which native screen receives the record. - { - var source: std.Io.Reader = .fixed(destination.written()); - try std.testing.expectError( - error.UnexpectedScreenKey, - decode( - &source, - std.testing.io, - std.testing.allocator, - .alternate, - .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, - ), - ); - } - // Every truncation must fail without leaking a partially restored // PageList, cursor pin, or cursor-owned state. for (0..destination.written().len) |fixture_len| { @@ -2270,7 +2262,6 @@ test "SCREEN restoration rejects invalid and incomplete sequences" { &source, std.testing.io, std.testing.allocator, - .primary, .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, ) catch continue; restored.deinit(); @@ -2290,7 +2281,6 @@ test "SCREEN restoration rejects invalid and incomplete sequences" { &source, std.testing.io, failing.allocator(), - .primary, .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, ), ); @@ -2319,7 +2309,6 @@ test "SCREEN restoration rejects invalid and incomplete sequences" { &empty_source, std.testing.io, std.testing.allocator, - .primary, .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }, ), ); diff --git a/src/terminal/snapshot/snapshot.zig b/src/terminal/snapshot/snapshot.zig new file mode 100644 index 000000000..48494a5fe --- /dev/null +++ b/src/terminal/snapshot/snapshot.zig @@ -0,0 +1,413 @@ +//! 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 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 TerminalScreen = @import("../Screen.zig"); +const TerminalScreenKey = @import("../ScreenSet.zig").Key; + +/// Errors possible while encoding one complete terminal snapshot. +pub const EncodeError = terminal.EncodeError || + screen.EncodeError || + history.EncodeError || + checkpoint.EncodeError || + error{ + /// A snapshot envelope must begin at byte zero. + DestinationNotEmpty, + }; + +/// Encode one complete terminal snapshot. +/// +/// `destination` must be empty because checkpoint digests cover every byte +/// from the snapshot envelope onward. The operation is transactional: any +/// failure restores the destination to empty. +pub fn encode( + t: *const Terminal, + destination: *std.Io.Writer.Allocating, +) EncodeError!void { + // We require empty for checkpoint digests + if (destination.written().len != 0) return error.DestinationNotEmpty; + errdefer destination.shrinkRetainingCapacity(0); + + // 1. Envelope + try envelope.encode(&destination.writer); + + // 2. Terminal + try terminal.encode(t, destination); + + // 3. Primary and alt screen + try screen.encode( + t.screens.get(.primary).?, + .primary, + destination, + ); + if (t.screens.get(.alternate)) |alternate| try screen.encode( + alternate, + .alternate, + destination, + ); + + // 4. Ready checkpoint. In the future we'll put our continuation + // state before this so pty bytes can also flow. + try checkpoint.encode(.ready, destination); + + // 5. History + try history.encode( + t.screens.get(.primary).?, + .primary, + destination, + ); + if (t.screens.get(.alternate)) |alternate| try history.encode( + alternate, + .alternate, + destination, + ); + + // 6. Finish + try checkpoint.encode(.finish, destination); +} + +/// 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, + }; + +/// Restore one complete snapshot into a native terminal. +/// +/// The reader must contain exactly one snapshot through FINISH. Restoration is +/// transactional: the returned terminal is either complete and ready or +/// not (error return). +pub fn decode( + source: *std.Io.Reader, + io_: std.Io, + alloc: Allocator, +) DecodeError!Terminal { + // Keep this reader unbuffered so the hasher never reads past a checkpoint + // boundary. Record readers provide their own bounded buffers. + const Blake3 = std.crypto.hash.Blake3; + var hashing = source.hashed(Blake3.init(.{}), &.{}); + const reader = &hashing.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. + var digest: checkpoint.Digest = undefined; + hashing.hasher.final(&digest); + try checkpoint.decode(.ready, digest, reader); + + // HISTORY mutates only the restored terminal owned by this function. + // Although an individual history decoder may publish recent pages as they + // validate, any later full-snapshot failure deinitializes the whole result. + const keys = [_]TerminalScreenKey{ .primary, .alternate }; + for (keys) |key| { + const restored = result.screens.get(key) orelse continue; + try history.decode(reader, alloc, key, restored); + } + + // FINISH authenticates READY and all history. Decode it directly from the + // underlying reader so the digest does not include FINISH itself. + hashing.hasher.final(&digest); + try checkpoint.decode(.finish, digest, source); + + if (comptime build_options.slow_runtime_safety) { + for (keys) |key| { + const restored = result.screens.get(key) orelse continue; + restored.pages.assertIntegrity(); + restored.assertIntegrity(); + } + } + return result; +} + +test "complete snapshot round trip with history and alternate screen" { + const testing = std.testing; + + var t = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 80, + .rows = 3, + .max_scrollback_bytes = null, + .max_scrollback_lines = null, + }); + defer t.deinit(testing.allocator); + + // Exercise terminal-wide state and grow the primary screen until its + // active area is preceded by multiple complete history pages. + 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).?; + while (primary.pages.totalPages() < 4) { + try t.printString("primary history\n"); + } + 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(&t, &encoded); + try testing.expectEqualDeep(source_memory, primary.pages.memoryStats()); + + var encoded_source: std.Io.Reader = .fixed(encoded.written()); + var source_buffer: [1]u8 = undefined; + var limited = encoded_source.limited(.unlimited, &source_buffer); + var restored = try decode( + &limited.interface, + testing.io, + testing.allocator, + ); + 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(&restored, &reencoded); + try testing.expectEqualStrings(encoded.written(), reencoded.written()); + + // SCREEN keys make their order independent. Keep HISTORY canonical here; + // only the active-state screen sequences are intentionally reversed. + var reversed: std.Io.Writer.Allocating = .init(testing.allocator); + defer reversed.deinit(); + try envelope.encode(&reversed.writer); + try terminal.encode(&t, &reversed); + try screen.encode(t.screens.get(.alternate).?, .alternate, &reversed); + try screen.encode(primary, .primary, &reversed); + try checkpoint.encode(.ready, &reversed); + try history.encode(primary, .primary, &reversed); + try history.encode( + t.screens.get(.alternate).?, + .alternate, + &reversed, + ); + try checkpoint.encode(.finish, &reversed); + + var reversed_source: std.Io.Reader = .fixed(reversed.written()); + var reversed_restored = try decode( + &reversed_source, + testing.io, + testing.allocator, + ); + defer reversed_restored.deinit(testing.allocator); + try testing.expectEqual( + TerminalScreenKey.alternate, + reversed_restored.screens.active_key, + ); +} + +test "complete snapshot encoding is transactional" { + const testing = std.testing; + + var t = try Terminal.init(testing.io, testing.allocator, .{ + .cols = 2, + .rows = 1, + }); + defer t.deinit(testing.allocator); + + // A complete snapshot cannot be appended after unrelated bytes because + // its envelope and checkpoint coverage both begin at byte zero. + var nonempty: std.Io.Writer.Allocating = .init(testing.allocator); + defer nonempty.deinit(); + try nonempty.writer.writeAll("prefix"); + try testing.expectError( + error.DestinationNotEmpty, + encode(&t, &nonempty), + ); + try testing.expectEqualStrings("prefix", nonempty.written()); + + // A failure after validation enters a record codec still rolls back every + // preceding record in this complete snapshot operation. + t.colors.palette.current[7] = .{ .r = 1, .g = 2, .b = 3 }; + var destination: std.Io.Writer.Allocating = .init(testing.allocator); + defer destination.deinit(); + try testing.expectError( + error.InvalidPalette, + encode(&t, &destination), + ); + try testing.expectEqual(@as(usize, 0), destination.written().len); +} + +test "complete snapshot rejects ordering checkpoints and trailing data" { + 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(); + try envelope.encode(&reordered.writer); + try terminal.encode(&t, &reordered); + try history.encode(primary, .primary, &reordered); + var reordered_source: std.Io.Reader = .fixed(reordered.written()); + try testing.expectError( + error.UnexpectedRecordTag, + decode(&reordered_source, testing.io, testing.allocator), + ); + + // 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(); + try envelope.encode(&invalid_ready.writer); + try terminal.encode(&t, &invalid_ready); + try screen.encode(primary, .primary, &invalid_ready); + var record_writer = try record.Writer.init(&invalid_ready, .ready); + try record_writer.payloadWriter().splatByteAll( + 0, + @sizeOf(checkpoint.Digest), + ); + try record_writer.finish(); + var invalid_ready_source: std.Io.Reader = .fixed( + invalid_ready.written(), + ); + try testing.expectError( + error.InvalidDigest, + decode(&invalid_ready_source, testing.io, testing.allocator), + ); + + // FINISH is the exact end of a complete snapshot. + var trailing: std.Io.Writer.Allocating = .init(testing.allocator); + defer trailing.deinit(); + try encode(&t, &trailing); + try trailing.writer.writeByte(0); + var trailing_source: std.Io.Reader = .fixed(trailing.written()); + try testing.expectError( + error.TrailingData, + decode(&trailing_source, testing.io, testing.allocator), + ); + + // A SCREEN key must name one of the slots declared by TERMINAL. + var undeclared: std.Io.Writer.Allocating = .init(testing.allocator); + defer undeclared.deinit(); + try envelope.encode(&undeclared.writer); + try terminal.encode(&t, &undeclared); + try screen.encode(primary, .alternate, &undeclared); + var undeclared_source: std.Io.Reader = .fixed(undeclared.written()); + try testing.expectError( + error.UnexpectedScreenKey, + decode(&undeclared_source, testing.io, testing.allocator), + ); + + // 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(); + try envelope.encode(&duplicate.writer); + try terminal.encode(&t, &duplicate); + try screen.encode(primary, .primary, &duplicate); + try screen.encode(primary, .primary, &duplicate); + var duplicate_source: std.Io.Reader = .fixed(duplicate.written()); + try testing.expectError( + error.DuplicateScreen, + decode(&duplicate_source, testing.io, testing.allocator), + ); +} diff --git a/src/terminal/snapshot/terminal.zig b/src/terminal/snapshot/terminal.zig index 4e52f9295..64b2052b8 100644 --- a/src/terminal/snapshot/terminal.zig +++ b/src/terminal/snapshot/terminal.zig @@ -6,8 +6,9 @@ //! `history.zig`. //! //! Snapshot version 1 supports the primary screen and an optional alternate -//! screen. `screen_count` is therefore one or two. SCREEN records are ordered by -//! key: primary first, then alternate when present. `active_screen_key` must +//! 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. From b867a0f59e3ceee335828cc79e2c7bdb2a69c467 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Jul 2026 12:58:02 -0700 Subject: [PATCH 16/35] terminal/snapshot: use lib.Enum enums where possible --- src/terminal/Screen.zig | 8 +- src/terminal/ansi.zig | 13 +- src/terminal/cursor.zig | 32 +- src/terminal/osc/parsers/semantic_prompt.zig | 80 ++- src/terminal/snapshot/grid.zig | 73 +- src/terminal/snapshot/history.zig | 25 +- src/terminal/snapshot/screen.zig | 699 +++++++------------ 7 files changed, 370 insertions(+), 560 deletions(-) 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/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..8495f3043 100644 --- a/src/terminal/cursor.zig +++ b/src/terminal/cursor.zig @@ -1,15 +1,25 @@ +const lib = @import("lib.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(lib.target, &.{ + // 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/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/grid.zig b/src/terminal/snapshot/grid.zig index 4f5604fe2..f9aa1d2b1 100644 --- a/src/terminal/snapshot/grid.zig +++ b/src/terminal/snapshot/grid.zig @@ -149,11 +149,7 @@ pub fn encode( const row_header: RowHeader = .{ .wrap = row.wrap, .wrap_continuation = row.wrap_continuation, - .semantic_prompt = switch (row.semantic_prompt) { - .none => .none, - .prompt => .prompt, - .prompt_continuation => .prompt_continuation, - }, + .semantic_prompt = row.semantic_prompt, }; try writer.writeByte(@bitCast(row_header)); @@ -262,20 +258,24 @@ pub fn decode( hyperlink_remap: *const HyperlinkRemap, ) DecodeError!void { for (0..page.size.rows) |y| { - const row_header: RowHeader = @bitCast(try reader.takeByte()); + const row_raw = try reader.takeByte(); + + // Validate the enum field before bitcasting into the packed native + // representation. The remaining value is checked through its named + // padding field below. + const semantic_prompt_raw: u2 = @truncate(row_raw >> 2); + _ = std.enums.fromInt( + TerminalRow.SemanticPrompt, + semantic_prompt_raw, + ) orelse return error.InvalidRow; + + const row_header: RowHeader = @bitCast(row_raw); if (row_header._padding != 0) return error.InvalidRow; - const semantic_prompt: TerminalRow.SemanticPrompt = - switch (row_header.semantic_prompt) { - .none => .none, - .prompt => .prompt, - .prompt_continuation => .prompt_continuation, - .invalid => return error.InvalidRow, - }; const row = page.getRow(y); row.wrap = row_header.wrap; row.wrap_continuation = row_header.wrap_continuation; - row.semantic_prompt = semantic_prompt; + row.semantic_prompt = row_header.semantic_prompt; const cells = page.getCells(row); for (cells, 0..) |*cell, x| { @@ -396,15 +396,8 @@ pub fn decode( const RowHeader = packed struct(u8) { wrap: bool = false, wrap_continuation: bool = false, - semantic_prompt: SemanticPrompt = .none, + semantic_prompt: TerminalRow.SemanticPrompt = .none, _padding: u4 = 0, - - const SemanticPrompt = enum(u2) { - none = 0, - prompt = 1, - prompt_continuation = 2, - invalid = 3, - }; }; /// The fixed fields that precede a cell's grapheme suffix codepoints. @@ -451,14 +444,9 @@ const CellHeader = struct { self: CellHeader, writer: *std.Io.Writer, ) std.Io.Writer.Error!void { - const semantic_content: Flags.SemanticContent = switch (self.semantic_content) { - .output => .output, - .input => .input, - .prompt => .prompt, - }; const flags: Flags = .{ .protected = self.protected, - .semantic_content = semantic_content, + .semantic_content = self.semantic_content, }; // 0 1 2 3 4 6 8 12 16 @@ -488,14 +476,18 @@ const CellHeader = struct { try reader.takeByte(), ) orelse return error.InvalidCell; - const flags: Flags = @bitCast(try reader.takeByte()); + const flags_raw = try reader.takeByte(); + + // As with the row header, validate the exhaustive native enum before + // bitcasting the complete packed byte. + const semantic_content_raw: u2 = @truncate(flags_raw >> 1); + _ = std.enums.fromInt( + TerminalCell.SemanticContent, + semantic_content_raw, + ) orelse return error.InvalidCell; + + const flags: Flags = @bitCast(flags_raw); if (flags._padding != 0) return error.InvalidCell; - const semantic_content: TerminalCell.SemanticContent = switch (flags.semantic_content) { - .output => .output, - .input => .input, - .prompt => .prompt, - .invalid => return error.InvalidCell, - }; // This byte is reserved so the IDs and content value remain naturally // aligned within the fixed cell header. @@ -505,7 +497,7 @@ const CellHeader = struct { .content_kind = content_kind, .width = width, .protected = flags.protected, - .semantic_content = semantic_content, + .semantic_content = flags.semantic_content, .style_id = try io.readInt(reader, TerminalStyleId), .hyperlink_id = try io.readInt(reader, TerminalHyperlinkId), .value = @bitCast(try io.readInt(reader, u32)), @@ -547,14 +539,7 @@ const CellHeader = struct { const Flags = packed struct(u8) { protected: bool = false, - semantic_content: SemanticContent = .output, + semantic_content: TerminalCell.SemanticContent = .output, _padding: u5 = 0, - - const SemanticContent = enum(u2) { - output = 0, - input = 1, - prompt = 2, - invalid = 3, - }; }; }; diff --git a/src/terminal/snapshot/history.zig b/src/terminal/snapshot/history.zig index 52d527cfd..1fcf192b3 100644 --- a/src/terminal/snapshot/history.zig +++ b/src/terminal/snapshot/history.zig @@ -86,7 +86,7 @@ pub const Header = struct { } /// Identifies the previously encoded screen that owns this history. - key: screen.Key, + key: TerminalScreenKey, /// Number of complete PAGE records immediately following this record. page_count: u32, @@ -102,7 +102,7 @@ pub const Header = struct { self: Header, writer: *std.Io.Writer, ) std.Io.Writer.Error!void { - try io.writeInt(writer, u16, @intFromEnum(self.key)); + try io.writeInt(writer, u16, @intCast(@intFromEnum(self.key))); try io.writeInt(writer, u32, self.page_count); try io.writeInt(writer, u64, self.total_rows); try io.writeInt(writer, u16, self.screen_overlap_rows); @@ -112,9 +112,13 @@ pub const Header = struct { /// 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( - screen.Key, - try io.readInt(reader, u16), + TerminalScreenKey, + value, ) orelse return error.InvalidKey; return .{ .key = key, @@ -186,10 +190,7 @@ pub fn encode( }; const header: Header = .{ - .key = switch (key) { - .primary => .primary, - .alternate => .alternate, - }, + .key = key, .page_count = std.math.cast( u32, page_count, @@ -265,11 +266,7 @@ pub fn decode( break :header header; }; - const decoded_key: TerminalScreenKey = switch (header.key) { - .primary => .primary, - .alternate => .alternate, - }; - if (decoded_key != expected_key) return error.UnexpectedScreenKey; + if (header.key != expected_key) return error.UnexpectedScreenKey; // A freshly restored SCREEN may carry overlap inside its first page, but // cannot already contain a complete historical page before that boundary. @@ -437,7 +434,7 @@ test "HISTORY encodes newest first and restores complete history" { 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(screen.Key.primary, header.key); + try std.testing.expectEqual(TerminalScreenKey.primary, header.key); try std.testing.expectEqual(@as(u32, 2), header.page_count); try std.testing.expectEqual( @as(u16, active_top.y), diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index cd4d272e1..8b9d1215f 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -193,9 +193,9 @@ //! bit 8 +---------------------------+ //! ``` //! -//! All enum values and bit assignments below are snapshot-version registries, -//! not native enum ordinals. Changing any of them requires a snapshot version -//! bump. +//! 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"); @@ -209,6 +209,7 @@ 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"); @@ -221,7 +222,6 @@ const TerminalStyle = terminal_style.Style; /// Errors possible while encoding fixed SCREEN payload fields. const PayloadEncodeError = std.Io.Writer.Error || error{ InvalidCursorFlags, - InvalidSemanticContent, InvalidCharsetState, InvalidKittyKeyboardIndex, InvalidKittyKeyboardFlags, @@ -349,7 +349,7 @@ pub fn decode( defer if (cursor_hyperlink) |*value| value.deinit(alloc); try record_reader.finish(); - const key = header.key.terminal(); + const key = header.key; if (header.page_count == 0) return error.InvalidPageCount; // Dimensions are terminal-wide state supplied by the enclosing snapshot. @@ -417,13 +417,12 @@ pub fn decode( .cursor = .{ .x = header.cursor_x, .y = header.cursor_y, - .cursor_style = header.cursor_style.terminal(), + .cursor_style = header.cursor_style, .pending_wrap = header.cursor_flags.pending_wrap, .protected = header.cursor_flags.protected, .style = header.cursor_pen, .hyperlink_implicit_id = header.hyperlink_implicit_id, - .semantic_content = header.cursor_flags.semantic_content - .terminal(), + .semantic_content = header.cursor_flags.semantic_content, .semantic_content_clear_eol = header.cursor_flags .semantic_content_clear_eol, .page_pin = cursor_pin, @@ -434,12 +433,12 @@ pub fn decode( value.terminal() else null, - .charset = header.charset.terminal(), - .protected_mode = header.protected_mode.terminal(), + .charset = header.charset, + .protected_mode = header.protected_mode, .kitty_keyboard = header.kitty_keyboard.terminal(), .semantic_prompt = .{ .seen = false, - .click = header.semantic_click.terminal(), + .click = header.semantic_click, }, }; }; @@ -527,86 +526,19 @@ const PayloadDecodeError = style.DecodeError || error{ InvalidSavedCursorFlags, }; -/// Identifies which terminal screen the record restores. -pub const Key = enum(u16) { - primary = 0, - alternate = 1, - - fn init(value: TerminalScreenKey) Key { - return switch (value) { - .primary => .primary, - .alternate => .alternate, - }; - } - - fn terminal(self: Key) TerminalScreenKey { - return switch (self) { - .primary => .primary, - .alternate => .alternate, - }; - } -}; - -/// Snapshot registry for the cursor's visual shape. -pub const CursorStyle = enum(u8) { - bar = 0, - block = 1, - underline = 2, - block_hollow = 3, - - fn init(value: TerminalScreen.CursorStyle) CursorStyle { - return switch (value) { - .bar => .bar, - .block => .block, - .underline => .underline, - .block_hollow => .block_hollow, - }; - } - - fn terminal(self: CursorStyle) TerminalScreen.CursorStyle { - return switch (self) { - .bar => .bar, - .block => .block, - .underline => .underline, - .block_hollow => .block_hollow, - }; - } -}; - /// Flags encoded after the cursor's visual shape. pub const CursorFlags = packed struct(u8) { pending_wrap: bool = false, protected: bool = false, - semantic_content: SemanticContent = .output, + semantic_content: terminal_page.Cell.SemanticContent = .output, semantic_content_clear_eol: bool = false, _padding: u3 = 0, - /// Snapshot registry for the cursor's semantic content. - pub const SemanticContent = enum(u2) { - output = 0, - input = 1, - prompt = 2, - invalid = 3, - - fn terminal(self: SemanticContent) terminal_page.Cell.SemanticContent { - return switch (self) { - .output => .output, - .input => .input, - .prompt => .prompt, - .invalid => unreachable, - }; - } - }; - fn init(cursor: *const TerminalScreen.Cursor) CursorFlags { return .{ .pending_wrap = cursor.pending_wrap, .protected = cursor.protected, - .semantic_content = switch (cursor.semantic_content) { - .output => .output, - .input => .input, - .prompt => .prompt, - }, + .semantic_content = cursor.semantic_content, .semantic_content_clear_eol = cursor .semantic_content_clear_eol, }; @@ -614,166 +546,143 @@ pub const CursorFlags = packed struct(u8) { /// Decode and validate the cursor flag registry. pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!CursorFlags { - const result: CursorFlags = @bitCast(try reader.takeByte()); + const raw = try reader.takeByte(); + const semantic_content_raw: u2 = @truncate(raw >> 2); + _ = std.enums.fromInt( + terminal_page.Cell.SemanticContent, + semantic_content_raw, + ) orelse return error.InvalidSemanticContent; + + const result: CursorFlags = @bitCast(raw); if (result._padding != 0) return error.InvalidCursorFlags; - if (result.semantic_content == .invalid) { - return error.InvalidSemanticContent; - } return result; } }; -/// The packed charset state used by current and saved cursors. -pub const CharsetState = packed struct(u16) { - g0: Charset = .utf8, - g1: Charset = .utf8, - g2: Charset = .utf8, - g3: Charset = .utf8, - gl: Slot = .g0, - gr: Slot = .g2, - single_shift: SingleShift = .none, +/// 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, - - /// Snapshot registry for one graphical character set. - pub const Charset = enum(u2) { - utf8 = 0, - ascii = 1, - british = 2, - dec_special = 3, - - fn terminal(self: Charset) terminal_charsets.Charset { - return switch (self) { - .utf8 => .utf8, - .ascii => .ascii, - .british => .british, - .dec_special => .dec_special, - }; - } - }; - - /// Snapshot registry for a G0 through G3 charset slot. - pub const Slot = enum(u2) { - g0 = 0, - g1 = 1, - g2 = 2, - g3 = 3, - - fn terminal(self: Slot) terminal_charsets.Slots { - return switch (self) { - .g0 => .G0, - .g1 => .G1, - .g2 => .G2, - .g3 => .G3, - }; - } - }; - - /// Snapshot registry for the single-shift charset slot. - pub const SingleShift = enum(u3) { - none = 0, - g0 = 1, - g1 = 2, - g2 = 3, - g3 = 4, - _, - - fn terminal(self: SingleShift) ?terminal_charsets.Slots { - return switch (self) { - .none => null, - .g0 => .G0, - .g1 => .G1, - .g2 => .G2, - .g3 => .G3, - _ => unreachable, - }; - } - }; - - fn init(value: TerminalScreen.CharsetState) CharsetState { - return .{ - .g0 = charset(value.charsets.get(.G0)), - .g1 = charset(value.charsets.get(.G1)), - .g2 = charset(value.charsets.get(.G2)), - .g3 = charset(value.charsets.get(.G3)), - .gl = slot(value.gl), - .gr = slot(value.gr), - .single_shift = if (value.single_shift) |value_slot| - switch (value_slot) { - .G0 => .g0, - .G1 => .g1, - .G2 => .g2, - .G3 => .g3, - } - else - .none, - }; - } - - fn terminal(self: CharsetState) TerminalScreen.CharsetState { - var result: TerminalScreen.CharsetState = .{ - .gl = self.gl.terminal(), - .gr = self.gr.terminal(), - .single_shift = self.single_shift.terminal(), - }; - result.charsets.set(.G0, self.g0.terminal()); - result.charsets.set(.G1, self.g1.terminal()); - result.charsets.set(.G2, self.g2.terminal()); - result.charsets.set(.G3, self.g3.terminal()); - return result; - } - - fn charset(value: terminal_charsets.Charset) Charset { - return switch (value) { - .utf8 => .utf8, - .ascii => .ascii, - .british => .british, - .dec_special => .dec_special, - }; - } - - fn slot(value: terminal_charsets.Slots) Slot { - return switch (value) { - .G0 => .g0, - .G1 => .g1, - .G2 => .g2, - .G3 => .g3, - }; - } - - /// Decode and validate the packed charset registry. - pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!CharsetState { - const result: CharsetState = @bitCast(try io.readInt(reader, u16)); - if (result._padding != 0) return error.InvalidCharsetState; - switch (result.single_shift) { - .none, .g0, .g1, .g2, .g3 => {}, - _ => return error.InvalidCharsetState, - } - return result; - } }; -/// Snapshot registry for selective-erase protection behavior. -pub const ProtectedMode = enum(u8) { - off = 0, - iso = 1, - dec = 2, +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 init(value: terminal_ansi.ProtectedMode) ProtectedMode { - return switch (value) { - .off => .off, - .iso => .iso, - .dec => .dec, - }; +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, +) error{InvalidCharsetState}!TerminalScreen.CharsetState { + const bits: CharsetBits = @bitCast(raw); + if (bits._padding != 0 or bits.single_shift > 4) { + return error.InvalidCharsetState; } - fn terminal(self: ProtectedMode) terminal_ansi.ProtectedMode { - return switch (self) { - .off => .off, - .iso => .iso, - .dec => .dec, - }; - } -}; + var result: TerminalScreen.CharsetState = .{ + .gl = enumFromInt( + terminal_charsets.Slots, + bits.gl, + ) orelse return error.InvalidCharsetState, + .gr = enumFromInt( + terminal_charsets.Slots, + bits.gr, + ) orelse return error.InvalidCharsetState, + .single_shift = if (bits.single_shift == 0) + null + else + enumFromInt( + terminal_charsets.Slots, + bits.single_shift - 1, + ) orelse return error.InvalidCharsetState, + }; + result.charsets.set( + .G0, + enumFromInt( + terminal_charsets.Charset, + bits.g0, + ) orelse return error.InvalidCharsetState, + ); + result.charsets.set( + .G1, + enumFromInt( + terminal_charsets.Charset, + bits.g1, + ) orelse return error.InvalidCharsetState, + ); + result.charsets.set( + .G2, + enumFromInt( + terminal_charsets.Charset, + bits.g2, + ) orelse return error.InvalidCharsetState, + ); + result.charsets.set( + .G3, + enumFromInt( + terminal_charsets.Charset, + bits.g3, + ) orelse return error.InvalidCharsetState, + ); + return result; +} /// The complete fixed-size Kitty keyboard stack. pub const KittyKeyboard = struct { @@ -840,98 +749,31 @@ pub const KittyKeyboard = struct { } }; -/// Selects the interpretation of the semantic-click value byte. -pub const SemanticClickKind = enum(u8) { - none = 0, - click_events = 1, - cl = 2, -}; +/// Decode and validate the semantic-click kind and value bytes. +fn decodeSemanticClick( + reader: *std.Io.Reader, +) PayloadDecodeError!TerminalScreen.SemanticPrompt.SemanticClick { + const kind = enumFromInt( + TerminalScreen.SemanticPrompt.SemanticClickKind, + try reader.takeByte(), + ) orelse return error.InvalidSemanticClick; + const value = try reader.takeByte(); -/// Snapshot registry for the OSC 133 `click_events` option. -pub const ClickEvents = enum(u8) { - absolute = 0, - relative = 1, -}; - -/// Snapshot registry for the OSC 133 `cl` option. -pub const Click = enum(u8) { - line = 0, - multiple = 1, - conservative_vertical = 2, - smart_vertical = 3, -}; - -/// The typed semantic-click kind and value from the fixed header. -pub const SemanticClick = union(SemanticClickKind) { - none, - click_events: ClickEvents, - cl: Click, - - fn init( - value: TerminalScreen.SemanticPrompt.SemanticClick, - ) SemanticClick { - return switch (value) { - .none => .none, - .click_events => |click_events| .{ - .click_events = switch (click_events) { - .absolute => .absolute, - .relative => .relative, - }, - }, - .cl => |click| .{ - .cl = switch (click) { - .line => .line, - .multiple => .multiple, - .conservative_vertical => .conservative_vertical, - .smart_vertical => .smart_vertical, - }, - }, - }; - } - - fn terminal( - self: SemanticClick, - ) TerminalScreen.SemanticPrompt.SemanticClick { - return switch (self) { - .none => .none, - .click_events => |value| .{ .click_events = switch (value) { - .absolute => .absolute, - .relative => .relative, - } }, - .cl => |value| .{ .cl = switch (value) { - .line => .line, - .multiple => .multiple, - .conservative_vertical => .conservative_vertical, - .smart_vertical => .smart_vertical, - } }, - }; - } - - /// Decode and validate the semantic-click kind and value bytes. - pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!SemanticClick { - const kind_raw = try reader.takeByte(); - const value_raw = try reader.takeByte(); - const kind = std.enums.fromInt( - SemanticClickKind, - kind_raw, - ) orelse return error.InvalidSemanticClick; - - return switch (kind) { - .none => if (value_raw == 0) - .none - else - error.InvalidSemanticClick, - .click_events => .{ .click_events = std.enums.fromInt( - ClickEvents, - value_raw, - ) orelse return error.InvalidSemanticClick }, - .cl => .{ .cl = std.enums.fromInt( - Click, - value_raw, - ) orelse return error.InvalidSemanticClick }, - }; - } -}; + return switch (kind) { + .none => if (value == 0) + .none + else + error.InvalidSemanticClick, + .click_events => .{ .click_events = enumFromInt( + terminal_osc.semantic_prompt.ClickEvents, + value, + ) orelse return error.InvalidSemanticClick }, + .cl => .{ .cl = enumFromInt( + terminal_osc.semantic_prompt.Click, + value, + ) orelse return error.InvalidSemanticClick }, + }; +} /// The optional fixed-size saved cursor state. pub const SavedCursor = struct { @@ -946,7 +788,7 @@ pub const SavedCursor = struct { y: u16, pen: TerminalStyle, flags: Flags, - charset: CharsetState, + charset: TerminalScreen.CharsetState, /// Flags encoded with an optional saved cursor. pub const Flags = packed struct(u8) { @@ -973,7 +815,7 @@ pub const SavedCursor = struct { .pending_wrap = value.pending_wrap, .origin = value.origin, }, - .charset = .init(value.charset), + .charset = value.charset, }; } @@ -985,7 +827,7 @@ pub const SavedCursor = struct { .protected = self.flags.protected, .pending_wrap = self.flags.pending_wrap, .origin = self.flags.origin, - .charset = self.charset.terminal(), + .charset = self.charset, }; } @@ -997,17 +839,13 @@ pub const SavedCursor = struct { if (self.flags._padding != 0) { return error.InvalidSavedCursorFlags; } - if (self.charset._padding != 0) return error.InvalidCharsetState; - switch (self.charset.single_shift) { - .none, .g0, .g1, .g2, .g3 => {}, - _ => return error.InvalidCharsetState, - } + 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, @bitCast(self.charset)); + try io.writeInt(writer, u16, charset); } /// Decode and validate one optional saved cursor value. @@ -1017,7 +855,9 @@ pub const SavedCursor = struct { .y = try io.readInt(reader, u16), .pen = try style.decode(reader), .flags = try Flags.decode(reader), - .charset = try CharsetState.decode(reader), + .charset = try decodeCharsetState( + try io.readInt(reader, u16), + ), }; } @@ -1047,19 +887,19 @@ pub const Header = struct { std.debug.assert(len == 45); } - key: Key, + key: TerminalScreenKey, /// Complete pages in the minimal suffix covering the active area. page_count: u16, cursor_x: u16, cursor_y: u16, - cursor_style: CursorStyle, + cursor_style: TerminalScreen.CursorStyle, cursor_flags: CursorFlags, cursor_pen: TerminalStyle, hyperlink_implicit_id: u32, - charset: CharsetState, - protected_mode: ProtectedMode, + charset: TerminalScreen.CharsetState, + protected_mode: terminal_ansi.ProtectedMode, kitty_keyboard: KittyKeyboard, - semantic_click: SemanticClick, + semantic_click: TerminalScreen.SemanticPrompt.SemanticClick, saved_cursor_present: bool, /// Initialize the fixed header from one native screen. @@ -1069,18 +909,18 @@ pub const Header = struct { page_count: u16, ) Header { return .{ - .key = .init(key), + .key = key, .page_count = page_count, .cursor_x = screen.cursor.x, .cursor_y = screen.cursor.y, - .cursor_style = .init(screen.cursor.cursor_style), + .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 = .init(screen.charset), - .protected_mode = .init(screen.protected_mode), + .charset = screen.charset, + .protected_mode = screen.protected_mode, .kitty_keyboard = .init(screen.kitty_keyboard), - .semantic_click = .init(screen.semantic_prompt.click), + .semantic_click = screen.semantic_prompt.click, .saved_cursor_present = screen.saved_cursor != null, }; } @@ -1094,16 +934,9 @@ pub const Header = struct { if (self.cursor_flags._padding != 0) { return error.InvalidCursorFlags; } - if (self.cursor_flags.semantic_content == .invalid) { - return error.InvalidSemanticContent; - } - // Validate packed charset state. - if (self.charset._padding != 0) return error.InvalidCharsetState; - switch (self.charset.single_shift) { - .none, .g0, .g1, .g2, .g3 => {}, - _ => return error.InvalidCharsetState, - } + // 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) { @@ -1116,41 +949,37 @@ pub const Header = struct { } // Screen identity and cursor position. - try io.writeInt(writer, u16, @intFromEnum(self.key)); + try io.writeInt(writer, u16, @intCast(@intFromEnum(self.key))); try io.writeInt(writer, u16, self.page_count); try io.writeInt(writer, u16, self.cursor_x); try io.writeInt(writer, u16, self.cursor_y); // Current cursor rendering state. - try writer.writeByte(@intFromEnum(self.cursor_style)); + 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, @bitCast(self.charset)); - try writer.writeByte(@intFromEnum(self.protected_mode)); + 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(@intFromEnum(SemanticClickKind.none)); - try writer.writeByte(0); - }, - .click_events => |value| { - try writer.writeByte( - @intFromEnum(SemanticClickKind.click_events), - ); - try writer.writeByte(@intFromEnum(value)); - }, - .cl => |value| { - try writer.writeByte(@intFromEnum(SemanticClickKind.cl)); - try writer.writeByte(@intFromEnum(value)); - }, + .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. @@ -1160,8 +989,8 @@ pub const Header = struct { /// Decode and validate the fixed SCREEN payload header. pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!Header { // Screen identity and cursor position. - const key = std.enums.fromInt( - Key, + const key = enumFromInt( + TerminalScreenKey, try io.readInt(reader, u16), ) orelse return error.InvalidKey; const page_count = try io.readInt(reader, u16); @@ -1169,8 +998,8 @@ pub const Header = struct { const cursor_y = try io.readInt(reader, u16); // Current cursor rendering state. - const cursor_style = std.enums.fromInt( - CursorStyle, + const cursor_style = enumFromInt( + TerminalScreen.CursorStyle, try reader.takeByte(), ) orelse return error.InvalidCursorStyle; const cursor_flags = try CursorFlags.decode(reader); @@ -1178,15 +1007,17 @@ pub const Header = struct { const hyperlink_implicit_id = try io.readInt(reader, u32); // Charset and selective-erase state. - const charset = try CharsetState.decode(reader); - const protected_mode = std.enums.fromInt( - ProtectedMode, + const charset = try decodeCharsetState( + try io.readInt(reader, u16), + ); + const protected_mode = enumFromInt( + terminal_ansi.ProtectedMode, try reader.takeByte(), ) orelse return error.InvalidProtectedMode; // Keyboard modes and semantic-click state. const kitty_keyboard = try KittyKeyboard.decode(reader); - const semantic_click = try SemanticClick.decode(reader); + const semantic_click = try decodeSemanticClick(reader); // Presence of the optional saved-cursor suffix. const saved_cursor_present = switch (try reader.takeByte()) { @@ -1299,16 +1130,17 @@ const test_saved_cursor_fixture = "\x00\x00\x00\x00\x00\x00\x00\x00" ++ "\x07\xe4\x3d"; -fn testCharsetState() CharsetState { - return .{ - .g0 = .utf8, - .g1 = .ascii, - .g2 = .british, - .g3 = .dec_special, - .gl = .g1, - .gr = .g3, - .single_shift = .g2, +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 { @@ -1443,14 +1275,14 @@ test "header decoding with a one-byte reader buffer" { } test "header registry values round trip" { - const keys = [_]Key{ .primary, .alternate }; + const keys = [_]TerminalScreenKey{ .primary, .alternate }; for (keys) |value| { var header = testHeader(); header.key = value; try expectHeaderRoundTrip(header); } - const cursor_styles = [_]CursorStyle{ + const cursor_styles = [_]TerminalScreen.CursorStyle{ .bar, .block, .underline, @@ -1462,7 +1294,7 @@ test "header registry values round trip" { try expectHeaderRoundTrip(header); } - const semantic_contents = [_]CursorFlags.SemanticContent{ + const semantic_contents = [_]terminal_page.Cell.SemanticContent{ .output, .input, .prompt, @@ -1473,7 +1305,7 @@ test "header registry values round trip" { try expectHeaderRoundTrip(header); } - const charsets = [_]CharsetState.Charset{ + const charsets = [_]terminal_charsets.Charset{ .utf8, .ascii, .british, @@ -1481,14 +1313,14 @@ test "header registry values round trip" { }; for (charsets) |value| { var header = testHeader(); - header.charset.g0 = value; - header.charset.g1 = value; - header.charset.g2 = value; - header.charset.g3 = value; + header.charset.charsets.set(.G0, value); + header.charset.charsets.set(.G1, value); + header.charset.charsets.set(.G2, value); + header.charset.charsets.set(.G3, value); try expectHeaderRoundTrip(header); } - const slots = [_]CharsetState.Slot{ .g0, .g1, .g2, .g3 }; + const slots = [_]terminal_charsets.Slots{ .G0, .G1, .G2, .G3 }; for (slots) |value| { var header = testHeader(); header.charset.gl = value; @@ -1496,12 +1328,12 @@ test "header registry values round trip" { try expectHeaderRoundTrip(header); } - const single_shifts = [_]CharsetState.SingleShift{ - .none, - .g0, - .g1, - .g2, - .g3, + const single_shifts = [_]?terminal_charsets.Slots{ + null, + .G0, + .G1, + .G2, + .G3, }; for (single_shifts) |value| { var header = testHeader(); @@ -1509,22 +1341,27 @@ test "header registry values round trip" { try expectHeaderRoundTrip(header); } - const protected_modes = [_]ProtectedMode{ .off, .iso, .dec }; + const protected_modes = [_]terminal_ansi.ProtectedMode{ + .off, + .iso, + .dec, + }; for (protected_modes) |value| { var header = testHeader(); header.protected_mode = value; try expectHeaderRoundTrip(header); } - const semantic_clicks = [_]SemanticClick{ - .none, - .{ .click_events = .absolute }, - .{ .click_events = .relative }, - .{ .cl = .line }, - .{ .cl = .multiple }, - .{ .cl = .conservative_vertical }, - .{ .cl = .smart_vertical }, - }; + const semantic_clicks = + [_]TerminalScreen.SemanticPrompt.SemanticClick{ + .none, + .{ .click_events = .absolute }, + .{ .click_events = .relative }, + .{ .cl = .line }, + .{ .cl = .multiple }, + .{ .cl = .conservative_vertical }, + .{ .cl = .smart_vertical }, + }; for (semantic_clicks) |value| { var header = testHeader(); header.semantic_click = value; @@ -1591,30 +1428,6 @@ test "header encoding rejects invalid state" { invalid_cursor_flags.encode(&cursor_flags_writer), ); - var invalid_semantic_content = testHeader(); - invalid_semantic_content.cursor_flags.semantic_content = .invalid; - var semantic_content_writer: std.Io.Writer = .fixed(&encoded); - try std.testing.expectError( - error.InvalidSemanticContent, - invalid_semantic_content.encode(&semantic_content_writer), - ); - - var invalid_charset = testHeader(); - invalid_charset.charset._padding = 1; - var charset_writer: std.Io.Writer = .fixed(&encoded); - try std.testing.expectError( - error.InvalidCharsetState, - invalid_charset.encode(&charset_writer), - ); - - var invalid_single_shift = testHeader(); - invalid_single_shift.charset.single_shift = @enumFromInt(5); - var single_shift_writer: std.Io.Writer = .fixed(&encoded); - try std.testing.expectError( - error.InvalidCharsetState, - invalid_single_shift.encode(&single_shift_writer), - ); - var invalid_kitty_index = testHeader(); invalid_kitty_index.kitty_keyboard.index = 8; var kitty_index_writer: std.Io.Writer = .fixed(&encoded); @@ -1781,7 +1594,7 @@ test "native SCREEN payload omits absent optional state" { var reader: std.Io.Reader = .fixed(writer.buffered()); const header = try Header.decode(&reader); - try std.testing.expectEqual(Key.primary, header.key); + try std.testing.expectEqual(TerminalScreenKey.primary, header.key); try std.testing.expectEqual(@as(u16, 1), header.page_count); try std.testing.expect(!header.saved_cursor_present); try std.testing.expectEqual( @@ -1824,22 +1637,6 @@ test "saved cursor rejects invalid state" { invalid_flags.encode(&flags_writer), ); - var invalid_charset = testSavedCursor(); - invalid_charset.charset._padding = 1; - var charset_writer: std.Io.Writer = .fixed(&encoded); - try std.testing.expectError( - error.InvalidCharsetState, - invalid_charset.encode(&charset_writer), - ); - - var invalid_single_shift_value = testSavedCursor(); - invalid_single_shift_value.charset.single_shift = @enumFromInt(5); - var single_shift_value_writer: std.Io.Writer = .fixed(&encoded); - try std.testing.expectError( - error.InvalidCharsetState, - invalid_single_shift_value.encode(&single_shift_value_writer), - ); - const valid = [_]u8{0} ** SavedCursor.len; var invalid_flags_bytes = valid; @@ -2176,7 +1973,7 @@ test "SCREEN encodes the minimal complete-page active suffix" { const payload_reader = screen_record.payloadReader(); const header = try Header.decode(payload_reader); - try std.testing.expectEqual(Key.primary, header.key); + try std.testing.expectEqual(TerminalScreenKey.primary, header.key); try std.testing.expectEqual(@as(u16, 2), header.page_count); try std.testing.expect(!header.saved_cursor_present); try std.testing.expectEqual( From 43ec9b373b76a8c0e86a093f63aee643e01c69ff Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Jul 2026 13:07:25 -0700 Subject: [PATCH 17/35] terminal/snapshot: harden hyperlink decoding, allow invalid hyperlinks for page --- src/terminal/snapshot/hyperlink.zig | 113 ++++++++++++++++------ src/terminal/snapshot/page.zig | 106 +++++++++++++++++---- src/terminal/snapshot/screen.zig | 142 ++++++++++++++++++++++++++++ 3 files changed, 316 insertions(+), 45 deletions(-) diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig index 71b27558c..8a7a7d920 100644 --- a/src/terminal/snapshot/hyperlink.zig +++ b/src/terminal/snapshot/hyperlink.zig @@ -7,8 +7,11 @@ //! Indexing and ordering are properties of the containing record rather than //! this codec. //! -//! IDs and URIs are arbitrary byte strings. Their lengths are retained on the -//! wire; they are not NUL-terminated and need not contain UTF-8. +//! 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, so standalone decoding rejects zero lengths and +//! PAGE decoding consumes but ignores the invalid table entry. //! //! All integers are unsigned and little-endian. //! @@ -56,6 +59,12 @@ pub const EncodeError = std.Io.Writer.Error; 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. @@ -106,6 +115,8 @@ pub fn decode( .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); @@ -118,11 +129,15 @@ pub fn decode( .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); @@ -139,7 +154,9 @@ pub fn decode( /// /// 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. +/// the native ID assigned by the destination page. Zero is returned when the +/// encoded URI or explicit ID is empty because native page storage cannot +/// represent empty hyperlink strings. pub fn decodePage( page: *terminal_page.Page, reader: *std.Io.Reader, @@ -152,6 +169,8 @@ pub fn decodePage( .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 = try decodePageString( page, reader, @@ -166,17 +185,33 @@ pub fn decodePage( .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 = try decodePageString( page, reader, id_len, ); - errdefer if (id.len > 0) page.string_alloc.free( + 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 = try decodePageString( page, reader, @@ -214,7 +249,7 @@ fn decodePageString( reader: *std.Io.Reader, len: usize, ) (std.Io.Reader.Error || error{StringsOutOfMemory})!terminal_size.Offset(u8).Slice { - if (len == 0) return .{}; + std.debug.assert(len > 0); // Allocate space for the string and read directly into it. const value = page.string_alloc.alloc( @@ -267,37 +302,57 @@ test "golden explicit encoding" { ); } -test "empty strings round trip" { - const values: [2]terminal_hyperlink.Hyperlink = .{ +test "decode rejects empty strings" { + const cases = [_]struct { + fixture: []const u8, + expected: anyerror, + }{ .{ - .id = .{ .implicit = 0 }, - .uri = "", + .fixture = "\x01\x04\x03\x02\x01\x00\x00\x00\x00", + .expected = error.InvalidUri, }, .{ - .id = .{ .explicit = "" }, - .uri = "", + .fixture = "\x02\x00\x00\x00\x00", + .expected = error.InvalidExplicitId, + }, + .{ + .fixture = "\x02\x02\x00\x00\x00id\x00\x00\x00\x00", + .expected = error.InvalidUri, }, }; - for (values) |value| { - var encoded: [9]u8 = undefined; - var writer: std.Io.Writer = .fixed(&encoded); - try encode(value, &writer); + for (cases) |case| { + var reader: std.Io.Reader = .fixed(case.fixture); + try std.testing.expectError( + case.expected, + decode(&reader, std.testing.allocator), + ); + } +} - var reader: std.Io.Reader = .fixed(writer.buffered()); - const decoded = try decode(&reader, std.testing.allocator); - defer decoded.deinit(std.testing.allocator); - try std.testing.expectEqualStrings("", decoded.uri); - switch (value.id) { - .implicit => |id| try std.testing.expectEqual( - id, - decoded.id.implicit, - ), - .explicit => |id| try std.testing.expectEqualStrings( - id, - decoded.id.explicit, - ), - } +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()); } } diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index f61d1a472..d1207f2a3 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -373,6 +373,10 @@ fn decodePayloadBody( error.EndOfStream => return error.EndOfStream, error.ReadFailed => return error.ReadFailed, }; + + // Zero records an ignored table entry. Keeping that mapping preserves + // encoded-ID uniqueness while grid decoding treats every reference to + // the invalid hyperlink as no hyperlink. hyperlink_remap.putAssumeCapacityNoClobber( native_id, decoded_id, @@ -1104,21 +1108,21 @@ test "decode accepts unordered sparse hyperlink IDs" { .style_capacity = 0, .hyperlink_capacity_bytes = 512, .grapheme_capacity_bytes = 0, - .string_capacity_bytes = 0, + .string_capacity_bytes = 6, }; const first: TerminalHyperlink = .{ .id = .{ .implicit = 1 }, - .uri = "", + .uri = "one", }; const second: TerminalHyperlink = .{ .id = .{ .implicit = 2 }, - .uri = "", + .uri = "two", }; var encoded: [ Header.len + - 2 * 11 + + 2 * 14 + 1 + 16 ]u8 = undefined; @@ -1406,7 +1410,7 @@ test "decode rejects duplicate and default style entries" { ); } -test "decode rejects duplicate hyperlinks with empty strings" { +test "decode rejects duplicate hyperlinks" { const header: Header = .{ .columns = 1, .rows = 1, @@ -1415,24 +1419,94 @@ test "decode rejects duplicate hyperlinks with empty strings" { .style_capacity = 0, .hyperlink_capacity_bytes = 512, .grapheme_capacity_bytes = 0, - .string_capacity_bytes = 0, + .string_capacity_bytes = 16, }; const duplicate: TerminalHyperlink = .{ - .id = .{ .explicit = "" }, - .uri = "", + .id = .{ .explicit = "id" }, + .uri = "uri", }; - var encoded: [Header.len + 22]u8 = undefined; - var writer: std.Io.Writer = .fixed(&encoded); - try header.encode(&writer); - try io.writeInt(&writer, TerminalHyperlinkId, 1); - try hyperlink.encode(duplicate, &writer); - try io.writeInt(&writer, TerminalHyperlinkId, 3); - try hyperlink.encode(duplicate, &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, 3); + try hyperlink.encode(duplicate, &encoded.writer); - var reader: std.Io.Reader = .fixed(writer.buffered()); + var reader: std.Io.Reader = .fixed(encoded.written()); try std.testing.expectError( error.DuplicateHyperlink, decodePayload(&reader, std.testing.allocator), ); } + +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 = 512, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 16, + }; + const cases = [_]struct { + value: TerminalHyperlink, + }{ + .{ + .value = .{ + .id = .{ .implicit = 1 }, + .uri = "", + }, + }, + .{ + .value = .{ + .id = .{ .explicit = "" }, + .uri = "uri", + }, + }, + .{ + .value = .{ + .id = .{ .explicit = "id" }, + .uri = "", + }, + }, + }; + + for (cases) |case| { + 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(case.value, &encoded.writer); + + // The cell refers to the ignored table entry. Its absent native + // remapping must degrade to no hyperlink while preserving the cell. + try encoded.writer.writeByte(0); + try encoded.writer.writeAll(&.{ 0, 0, 0, 0 }); + try io.writeInt(&encoded.writer, TerminalStyleId, 0); + try io.writeInt(&encoded.writer, TerminalHyperlinkId, 1); + try io.writeInt(&encoded.writer, u32, 'A'); + try io.writeInt(&encoded.writer, u32, 0); + + 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/screen.zig b/src/terminal/snapshot/screen.zig index 8b9d1215f..c61191337 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -2148,6 +2148,34 @@ test "cursor hyperlink rejects invalid kind and every truncation" { ), ); + const empty_cases = [_]struct { + fixture: []const u8, + expected: anyerror, + }{ + .{ + .fixture = "\x01\x04\x03\x02\x01\x00\x00\x00\x00", + .expected = error.InvalidUri, + }, + .{ + .fixture = "\x02\x00\x00\x00\x00", + .expected = error.InvalidExplicitId, + }, + .{ + .fixture = "\x02\x02\x00\x00\x00id\x00\x00\x00\x00", + .expected = error.InvalidUri, + }, + }; + for (empty_cases) |case| { + var reader: std.Io.Reader = .fixed(case.fixture); + try std.testing.expectError( + case.expected, + decodeCursorHyperlink( + &reader, + std.testing.allocator, + ), + ); + } + const fixtures = .{ "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", @@ -2165,3 +2193,117 @@ test "cursor hyperlink rejects invalid kind and every truncation" { } } } + +test "SCREEN decode rejects an empty cursor hyperlink URI" { + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.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; + + var record_writer = try record.Writer.init(&destination, .screen); + try header.encode(record_writer.payloadWriter()); + try hyperlink.encode(.{ + .id = .{ .implicit = 1 }, + .uri = "", + }, record_writer.payloadWriter()); + try record_writer.finish(); + + var source: std.Io.Reader = .fixed(destination.written()); + try std.testing.expectError( + error.InvalidUri, + decode( + &source, + std.testing.io, + std.testing.allocator, + .{ .cols = 1, .rows = 1 }, + ), + ); +} + +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 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; + + var screen_writer = try record.Writer.init(&destination, .screen); + try header.encode(screen_writer.payloadWriter()); + try screen_writer.payloadWriter().writeByte(0); + try screen_writer.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, + }; + var page_writer = try record.Writer.init(&destination, .page); + try page_header.encode(page_writer.payloadWriter()); + try io.writeInt( + page_writer.payloadWriter(), + terminal_hyperlink.Id, + 1, + ); + try hyperlink.encode(.{ + .id = .{ .implicit = 1 }, + .uri = "", + }, page_writer.payloadWriter()); + + // 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_writer.payloadWriter().writeByte(0); + try page_writer.payloadWriter().writeAll(&.{ 0, 0, 0, 0 }); + try io.writeInt( + page_writer.payloadWriter(), + terminal_style.Id, + 0, + ); + try io.writeInt( + page_writer.payloadWriter(), + terminal_hyperlink.Id, + 1, + ); + try io.writeInt(page_writer.payloadWriter(), u32, 'A'); + try io.writeInt(page_writer.payloadWriter(), u32, 0); + try page_writer.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); +} From 92c8dfd5084d8e9b88397dab1cb61ad0d38b1d4b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Jul 2026 13:22:15 -0700 Subject: [PATCH 18/35] terminal/snapshot: clean up tests --- src/terminal/snapshot/checkpoint.zig | 51 +--- src/terminal/snapshot/history.zig | 28 +- src/terminal/snapshot/hyperlink.zig | 2 +- src/terminal/snapshot/page.zig | 181 +++---------- src/terminal/snapshot/screen.zig | 376 ++++++++++----------------- src/terminal/snapshot/style.zig | 174 +++++++------ src/terminal/snapshot/terminal.zig | 173 ++++-------- 7 files changed, 326 insertions(+), 659 deletions(-) diff --git a/src/terminal/snapshot/checkpoint.zig b/src/terminal/snapshot/checkpoint.zig index 3b76df432..3eca93f82 100644 --- a/src/terminal/snapshot/checkpoint.zig +++ b/src/terminal/snapshot/checkpoint.zig @@ -123,9 +123,12 @@ test "checkpoint BLAKE3-256 registry" { var hasher = Blake3.init(.{}); hasher.update("a"); - hasher.update("bc"); + var prefix: Digest = undefined; + Blake3.hash("a", &prefix, .{}); hasher.final(&actual); - try std.testing.expectEqual(expected, actual); + try std.testing.expectEqual(prefix, actual); + + hasher.update("bc"); hasher.final(&actual); try std.testing.expectEqual(expected, actual); } @@ -142,7 +145,6 @@ test "READY and FINISH checkpoint coverage" { var ready_digest: Digest = undefined; Blake3.hash(snapshot.written(), &ready_digest, .{}); try encode(.ready, &snapshot); - const ready_end = snapshot.written().len; // History follows READY and is included by FINISH. try snapshot.writer.writeAll("history"); @@ -160,19 +162,6 @@ test "READY and FINISH checkpoint coverage" { snapshot.written()[finish_offset..], ); try decode(.finish, finish_digest, &finish_source); - - // Including READY changes the FINISH digest even before history is added. - var ready_record_digest: Digest = undefined; - Blake3.hash( - snapshot.written()[0..ready_end], - &ready_record_digest, - .{}, - ); - try testing.expect(!std.mem.eql( - u8, - &ready_digest, - &ready_record_digest, - )); } test "checkpoint rejects wrong tags, digests, and FINISH trailing data" { @@ -219,33 +208,3 @@ test "checkpoint rejects wrong tags, digests, and FINISH trailing data" { decode(.finish, digest, &trailing_source), ); } - -test "checkpoint rejects corruption and every truncation" { - const testing = std.testing; - - var snapshot: std.Io.Writer.Allocating = .init(testing.allocator); - defer snapshot.deinit(); - try snapshot.writer.writeAll("prefix"); - const checkpoint_offset = snapshot.written().len; - var digest: Digest = undefined; - Blake3.hash(snapshot.written(), &digest, .{}); - try encode(.ready, &snapshot); - const fixture = snapshot.written()[checkpoint_offset..]; - - const corrupt = try testing.allocator.dupe(u8, fixture); - defer testing.allocator.free(corrupt); - corrupt[record.Header.len] ^= 1; - var corrupt_source: std.Io.Reader = .fixed(corrupt); - try testing.expectError( - error.InvalidChecksum, - decode(.ready, digest, &corrupt_source), - ); - - for (0..fixture.len) |fixture_len| { - var source: std.Io.Reader = .fixed(fixture[0..fixture_len]); - try testing.expectError( - error.EndOfStream, - decode(.ready, digest, &source), - ); - } -} diff --git a/src/terminal/snapshot/history.zig b/src/terminal/snapshot/history.zig index 1fcf192b3..c9f63510c 100644 --- a/src/terminal/snapshot/history.zig +++ b/src/terminal/snapshot/history.zig @@ -668,56 +668,60 @@ test "HISTORY rejects manifest mismatches" { // Encode a manifest without PAGE records and vary only the fixed fields so // each structural validation boundary is exercised in isolation. - const cases = .{ + const Case = struct { + header: Header, + expected: anyerror, + }; + const cases = [_]Case{ .{ - Header{ + .header = .{ .key = .alternate, .page_count = 0, .total_rows = 0, .screen_overlap_rows = 0, }, - error.UnexpectedScreenKey, + .expected = error.UnexpectedScreenKey, }, .{ - Header{ + .header = .{ .key = .primary, .page_count = 0, .total_rows = 1, .screen_overlap_rows = 1, }, - error.InvalidScreenOverlap, + .expected = error.InvalidScreenOverlap, }, .{ - Header{ + .header = .{ .key = .primary, .page_count = 0, .total_rows = 1, .screen_overlap_rows = 0, }, - error.InvalidHistoryRows, + .expected = error.InvalidHistoryRows, }, .{ - Header{ + .header = .{ .key = .primary, .page_count = 1, .total_rows = 1, .screen_overlap_rows = 0, }, - error.EndOfStream, + .expected = error.EndOfStream, }, }; - inline for (cases) |case| { + for (cases) |case| { var destination: std.Io.Writer.Allocating = .init( std.testing.allocator, ); defer destination.deinit(); var record_writer = try record.Writer.init(&destination, .history); - try case[0].encode(record_writer.payloadWriter()); + try case.header.encode(record_writer.payloadWriter()); try record_writer.finish(); var source: std.Io.Reader = .fixed(destination.written()); try std.testing.expectError( - case[1], + case.expected, decode( &source, std.testing.allocator, diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig index 8a7a7d920..804288249 100644 --- a/src/terminal/snapshot/hyperlink.zig +++ b/src/terminal/snapshot/hyperlink.zig @@ -357,7 +357,7 @@ test "decodePage ignores empty strings" { } test "reject invalid kinds" { - inline for (.{ 0, 3, std.math.maxInt(u8) }) |kind| { + for ([_]u8{ 0, 3, std.math.maxInt(u8) }) |kind| { var fixture: [1]u8 = .{kind}; var reader: std.Io.Reader = .fixed(&fixture); try std.testing.expectError( diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index d1207f2a3..9001ac8c9 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -94,7 +94,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const envelope = @import("envelope.zig"); const grid = @import("grid.zig"); const hyperlink = @import("hyperlink.zig"); const io = @import("io.zig"); @@ -577,7 +576,7 @@ const test_empty_framed_page_fixture = "\x00\x00\x00\x00\x00\x00\x00\x00" ++ "\x00"; -test "golden encoding" { +test "PAGE header golden encoding and decoding" { const header: Header = .{ .columns = 0x0102, .rows = 0x0304, @@ -588,41 +587,20 @@ test "golden encoding" { .grapheme_capacity_bytes = 0x0d0e0f10, .string_capacity_bytes = 0x11121314, }; - - var buf: [Header.len]u8 = undefined; - var writer: std.Io.Writer = .fixed(&buf); - try header.encode(&writer); - - try std.testing.expectEqualStrings( - "\x02\x01\x04\x03\x06\x05\x08\x07" ++ - "\x0a\x09\x0c\x0b\x10\x0f\x0e\x0d" ++ - "\x14\x13\x12\x11", - writer.buffered(), - ); -} - -test "decode with a one-byte reader buffer" { const fixture = "\x02\x01\x04\x03\x06\x05\x08\x07" ++ "\x0a\x09\x0c\x0b\x10\x0f\x0e\x0d" ++ "\x14\x13\x12\x11"; - var source: std.Io.Reader = .fixed(fixture); - var buf: [1]u8 = undefined; - var limited = source.limited(.unlimited, &buf); - try std.testing.expectEqual( - Header{ - .columns = 0x0102, - .rows = 0x0304, - .style_count = 0x0506, - .hyperlink_count = 0x0708, - .style_capacity = 0x090a, - .hyperlink_capacity_bytes = 0x0b0c, - .grapheme_capacity_bytes = 0x0d0e0f10, - .string_capacity_bytes = 0x11121314, - }, - try Header.decode(&limited.interface), - ); + var buf: [Header.len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try header.encode(&writer); + try std.testing.expectEqualStrings(fixture, writer.buffered()); + + var source: std.Io.Reader = .fixed(fixture); + var read_buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &read_buf); + try std.testing.expectEqual(header, try Header.decode(&limited.interface)); } test "reject every truncation" { @@ -870,41 +848,6 @@ test "framed PAGE encode and decode a sparse native page" { rewriter.buffered(), rewriter_again.buffered(), ); - - // The public codec appends one complete PAGE record after the envelope. - var snapshot: std.Io.Writer.Allocating = .init(std.testing.allocator); - defer snapshot.deinit(); - try envelope.encode(&snapshot.writer); - try encode(&page, &snapshot); - - const snapshot_bytes = snapshot.written(); - try std.testing.expectEqualStrings( - test_page_fixture, - snapshot_bytes[envelope.encoded_len + record.Header.len ..], - ); - - var snapshot_reader: std.Io.Reader = .fixed(snapshot_bytes); - try envelope.decode(&snapshot_reader); - var framed_page = try decode( - &snapshot_reader, - std.testing.allocator, - ); - defer framed_page.deinit(); - - try std.testing.expectEqual(header, Header.init(&framed_page)); - const framed_first = framed_page.getRowAndCell(0, 0); - try std.testing.expectEqual(@as(u21, 'A'), framed_first.cell.codepoint()); - try std.testing.expectEqual( - TerminalCell.SemanticContent.prompt, - framed_first.cell.semantic_content, - ); - try std.testing.expectEqualSlices( - u21, - &.{ 0x0301, 0x0302 }, - framed_page.lookupGrapheme( - framed_page.getRowAndCell(0, 1).cell, - ).?, - ); } test "framed PAGE golden empty record" { @@ -936,90 +879,22 @@ test "framed PAGE golden empty record" { ); } -test "framed PAGE validates tag length checksum and exhaustion" { - { - // A valid non-PAGE tag is rejected before payload decoding. - var wrong_tag = test_empty_framed_page_fixture.*; - std.mem.writeInt(u16, wrong_tag[0..2], @intFromEnum(record.Tag.screen), .little); - var reader: std.Io.Reader = .fixed(&wrong_tag); - try std.testing.expectError( - error.UnexpectedRecordTag, - decode(&reader, std.testing.allocator), - ); - } - - { - // Corrupt only the stored checksum. - var invalid_checksum = test_empty_framed_page_fixture.*; - invalid_checksum[6] ^= 1; - var reader: std.Io.Reader = .fixed(&invalid_checksum); - try std.testing.expectError( - error.InvalidChecksum, - decode(&reader, std.testing.allocator), - ); - } - - { - // Mutate a cell without updating the checksum. - var invalid_payload = test_empty_framed_page_fixture.*; - const value_offset = record.Header.len + - Header.len + - 1 + - 8; - invalid_payload[value_offset] = 'A'; - var reader: std.Io.Reader = .fixed(&invalid_payload); - try std.testing.expectError( - error.InvalidChecksum, - decode(&reader, std.testing.allocator), - ); - } - - { - // Advertise one byte less than the PAGE decoder requires. - var short_payload = test_empty_framed_page_fixture.*; - std.mem.writeInt(u32, short_payload[2..6], 36, .little); - var reader: std.Io.Reader = .fixed(&short_payload); - try std.testing.expectError( - error.EndOfStream, - decode(&reader, std.testing.allocator), - ); - } - - { - // Add a payload byte and recompute the checksum so exhaustion, rather - // than checksum validation, is what fails. - var trailing: [test_empty_framed_page_fixture.len + 1]u8 = undefined; - @memcpy( - trailing[0..test_empty_framed_page_fixture.len], - test_empty_framed_page_fixture, - ); - trailing[trailing.len - 1] = 0; - std.mem.writeInt(u32, trailing[2..6], 38, .little); - - var checksum: record.Checksum = .init(.page, 38); - try checksum.writer().writeAll(trailing[record.Header.len..]); - std.mem.writeInt(u32, trailing[6..10], checksum.final(), .little); - - var reader: std.Io.Reader = .fixed(&trailing); - try std.testing.expectError( - error.PayloadNotExhausted, - decode(&reader, std.testing.allocator), - ); - } +test "framed PAGE rejects a different record tag" { + var wrong_tag = test_empty_framed_page_fixture.*; + std.mem.writeInt( + u16, + wrong_tag[0..2], + @intFromEnum(record.Tag.screen), + .little, + ); + var reader: std.Io.Reader = .fixed(&wrong_tag); + try std.testing.expectError( + error.UnexpectedRecordTag, + decode(&reader, std.testing.allocator), + ); } -test "framed PAGE rejects every truncation and preserves following bytes" { - for (0..test_empty_framed_page_fixture.len) |len| { - var reader: std.Io.Reader = .fixed( - test_empty_framed_page_fixture[0..len], - ); - if (decode(&reader, std.testing.allocator)) |decoded_value| { - var unexpected = decoded_value; - unexpected.deinit(); - return error.ExpectedDecodeFailure; - } else |_| {} - } - +test "framed PAGE preserves following bytes" { var source: std.Io.Reader = .fixed( test_empty_framed_page_fixture ++ "next", ); @@ -1290,7 +1165,11 @@ test "decode rejects undefined row and cell values" { } test "decode validates dimensions and native table capacities" { - const cases = .{ + const Case = struct { + expected: anyerror, + header: Header, + }; + const cases = [_]Case{ .{ .expected = error.InvalidDimensions, .header = Header{ @@ -1345,7 +1224,7 @@ test "decode validates dimensions and native table capacities" { }, }; - inline for (cases) |case| { + for (cases) |case| { var encoded: [Header.len]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try case.header.encode(&writer); diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index c61191337..ea282db56 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -1219,38 +1219,7 @@ fn testSavedCursor() SavedCursor { }; } -fn expectHeaderRoundTrip(expected: Header) !void { - var encoded: [Header.len]u8 = undefined; - var writer: std.Io.Writer = .fixed(&encoded); - try expected.encode(&writer); - - var reader: std.Io.Reader = .fixed(writer.buffered()); - const actual = try Header.decode(&reader); - try std.testing.expectEqualDeep(expected, actual); -} - -fn expectHyperlinkEqual( - expected: TerminalHyperlink, - actual: TerminalHyperlink, -) !void { - try std.testing.expectEqualStrings(expected.uri, actual.uri); - try std.testing.expectEqual( - std.meta.activeTag(expected.id), - std.meta.activeTag(actual.id), - ); - switch (expected.id) { - .implicit => |expected_id| try std.testing.expectEqual( - expected_id, - actual.id.implicit, - ), - .explicit => |expected_id| try std.testing.expectEqualStrings( - expected_id, - actual.id.explicit, - ), - } -} - -test "header golden encoding" { +test "SCREEN header golden encoding and decoding" { try std.testing.expectEqual(Header.len, test_header_fixture.len); var encoded: [Header.len]u8 = undefined; @@ -1261,9 +1230,7 @@ test "header golden encoding" { test_header_fixture, writer.buffered(), ); -} -test "header decoding with a one-byte reader buffer" { var source: std.Io.Reader = .fixed(test_header_fixture); var buffer: [1]u8 = undefined; var limited = source.limited(.unlimited, &buffer); @@ -1274,12 +1241,13 @@ test "header decoding with a one-byte reader buffer" { ); } -test "header registry values round trip" { +test "native enum values used by the SCREEN format" { const keys = [_]TerminalScreenKey{ .primary, .alternate }; - for (keys) |value| { - var header = testHeader(); - header.key = value; - try expectHeaderRoundTrip(header); + for (keys, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } const cursor_styles = [_]TerminalScreen.CursorStyle{ @@ -1288,10 +1256,11 @@ test "header registry values round trip" { .underline, .block_hollow, }; - for (cursor_styles) |value| { - var header = testHeader(); - header.cursor_style = value; - try expectHeaderRoundTrip(header); + for (cursor_styles, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } const semantic_contents = [_]terminal_page.Cell.SemanticContent{ @@ -1299,10 +1268,11 @@ test "header registry values round trip" { .input, .prompt, }; - for (semantic_contents) |value| { - var header = testHeader(); - header.cursor_flags.semantic_content = value; - try expectHeaderRoundTrip(header); + for (semantic_contents, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } const charsets = [_]terminal_charsets.Charset{ @@ -1311,34 +1281,19 @@ test "header registry values round trip" { .british, .dec_special, }; - for (charsets) |value| { - var header = testHeader(); - header.charset.charsets.set(.G0, value); - header.charset.charsets.set(.G1, value); - header.charset.charsets.set(.G2, value); - header.charset.charsets.set(.G3, value); - try expectHeaderRoundTrip(header); + for (charsets, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } const slots = [_]terminal_charsets.Slots{ .G0, .G1, .G2, .G3 }; - for (slots) |value| { - var header = testHeader(); - header.charset.gl = value; - header.charset.gr = value; - try expectHeaderRoundTrip(header); - } - - const single_shifts = [_]?terminal_charsets.Slots{ - null, - .G0, - .G1, - .G2, - .G3, - }; - for (single_shifts) |value| { - var header = testHeader(); - header.charset.single_shift = value; - try expectHeaderRoundTrip(header); + for (slots, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } const protected_modes = [_]terminal_ansi.ProtectedMode{ @@ -1346,74 +1301,116 @@ test "header registry values round trip" { .iso, .dec, }; - for (protected_modes) |value| { - var header = testHeader(); - header.protected_mode = value; - try expectHeaderRoundTrip(header); + for (protected_modes, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } - const semantic_clicks = - [_]TerminalScreen.SemanticPrompt.SemanticClick{ - .none, - .{ .click_events = .absolute }, - .{ .click_events = .relative }, - .{ .cl = .line }, - .{ .cl = .multiple }, - .{ .cl = .conservative_vertical }, - .{ .cl = .smart_vertical }, - }; - for (semantic_clicks) |value| { - var header = testHeader(); - header.semantic_click = value; - try expectHeaderRoundTrip(header); + const click_kinds = [_]TerminalScreen.SemanticPrompt.SemanticClickKind{ + .none, + .click_events, + .cl, + }; + for (click_kinds, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); + } + + const click_events = [_]terminal_osc.semantic_prompt.ClickEvents{ + .absolute, + .relative, + }; + for (click_events, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); + } + + const clicks = [_]terminal_osc.semantic_prompt.Click{ + .line, + .multiple, + .conservative_vertical, + .smart_vertical, + }; + for (clicks, 0..) |value, expected| { + try std.testing.expectEqual( + expected, + @as(usize, @intCast(@intFromEnum(value))), + ); } } test "cursor, Kitty, and saved cursor flag bit layouts" { - const cursor_cases = .{ - .{ CursorFlags{ .pending_wrap = true }, @as(u8, 1 << 0) }, - .{ CursorFlags{ .protected = true }, @as(u8, 1 << 1) }, + const CursorCase = struct { + value: CursorFlags, + expected: u8, + }; + const cursor_cases = [_]CursorCase{ + .{ .value = .{ .pending_wrap = true }, .expected = 1 << 0 }, + .{ .value = .{ .protected = true }, .expected = 1 << 1 }, .{ - CursorFlags{ .semantic_content = .input }, - @as(u8, 1 << 2), + .value = .{ .semantic_content = .input }, + .expected = 1 << 2, }, .{ - CursorFlags{ .semantic_content = .prompt }, - @as(u8, 2 << 2), + .value = .{ .semantic_content = .prompt }, + .expected = 2 << 2, }, .{ - CursorFlags{ .semantic_content_clear_eol = true }, - @as(u8, 1 << 4), + .value = .{ .semantic_content_clear_eol = true }, + .expected = 1 << 4, }, }; - inline for (cursor_cases) |case| { - try std.testing.expectEqual(case[1], @as(u8, @bitCast(case[0]))); + for (cursor_cases) |case| { + try std.testing.expectEqual( + case.expected, + @as(u8, @bitCast(case.value)), + ); } - const kitty_cases = .{ - .{ KittyKeyboard.Flags{ .disambiguate = true }, @as(u8, 1 << 0) }, - .{ KittyKeyboard.Flags{ .report_events = true }, @as(u8, 1 << 1) }, + const KittyCase = struct { + value: KittyKeyboard.Flags, + expected: u8, + }; + const kitty_cases = [_]KittyCase{ + .{ .value = .{ .disambiguate = true }, .expected = 1 << 0 }, + .{ .value = .{ .report_events = true }, .expected = 1 << 1 }, .{ - KittyKeyboard.Flags{ .report_alternates = true }, - @as(u8, 1 << 2), + .value = .{ .report_alternates = true }, + .expected = 1 << 2, }, - .{ KittyKeyboard.Flags{ .report_all = true }, @as(u8, 1 << 3) }, + .{ .value = .{ .report_all = true }, .expected = 1 << 3 }, .{ - KittyKeyboard.Flags{ .report_associated = true }, - @as(u8, 1 << 4), + .value = .{ .report_associated = true }, + .expected = 1 << 4, }, }; - inline for (kitty_cases) |case| { - try std.testing.expectEqual(case[1], @as(u8, @bitCast(case[0]))); + for (kitty_cases) |case| { + try std.testing.expectEqual( + case.expected, + @as(u8, @bitCast(case.value)), + ); } - const saved_cases = .{ - .{ SavedCursor.Flags{ .protected = true }, @as(u8, 1 << 0) }, - .{ SavedCursor.Flags{ .pending_wrap = true }, @as(u8, 1 << 1) }, - .{ SavedCursor.Flags{ .origin = true }, @as(u8, 1 << 2) }, + const SavedCase = struct { + value: SavedCursor.Flags, + expected: u8, }; - inline for (saved_cases) |case| { - try std.testing.expectEqual(case[1], @as(u8, @bitCast(case[0]))); + const saved_cases = [_]SavedCase{ + .{ .value = .{ .protected = true }, .expected = 1 << 0 }, + .{ .value = .{ .pending_wrap = true }, .expected = 1 << 1 }, + .{ .value = .{ .origin = true }, .expected = 1 << 2 }, + }; + for (saved_cases) |case| { + try std.testing.expectEqual( + case.expected, + @as(u8, @bitCast(case.value)), + ); } } @@ -1543,12 +1540,12 @@ test "header decoding rejects invalid values" { Header.decode(&semantic_click_kind_reader), ); - const invalid_semantic_clicks = .{ - .{ @as(u8, 0), @as(u8, 1) }, - .{ @as(u8, 1), @as(u8, 2) }, - .{ @as(u8, 2), @as(u8, 4) }, + const invalid_semantic_clicks = [_][2]u8{ + .{ 0, 1 }, + .{ 1, 2 }, + .{ 2, 4 }, }; - inline for (invalid_semantic_clicks) |invalid| { + for (invalid_semantic_clicks) |invalid| { var fixture = valid; fixture[42] = invalid[0]; fixture[43] = invalid[1]; @@ -1678,7 +1675,7 @@ test "saved cursor decoding rejects every truncation" { } } -test "cursor hyperlink encoding and decoding" { +test "cursor hyperlink null encoding and decoding" { var none_encoded: [1]u8 = undefined; var none_writer: std.Io.Writer = .fixed(&none_encoded); try encodeCursorHyperlink(null, &none_writer); @@ -1689,31 +1686,6 @@ test "cursor hyperlink encoding and decoding" { null, try decodeCursorHyperlink(&none_reader, std.testing.allocator), ); - - const values = [_]TerminalHyperlink{ - .{ - .id = .{ .implicit = 0x01020304 }, - .uri = "implicit", - }, - .{ - .id = .{ .explicit = "id" }, - .uri = "explicit", - }, - }; - - for (values) |expected| { - var encoded: [128]u8 = undefined; - var writer: std.Io.Writer = .fixed(&encoded); - try encodeCursorHyperlink(expected, &writer); - - var reader: std.Io.Reader = .fixed(writer.buffered()); - var actual = (try decodeCursorHyperlink( - &reader, - std.testing.allocator, - )).?; - defer actual.deinit(std.testing.allocator); - try expectHyperlinkEqual(expected, actual); - } } test "framed native SCREEN and PAGE sequence" { @@ -1797,44 +1769,6 @@ test "framed native SCREEN and PAGE sequence" { destination.written()[0..6], ); - var source: std.Io.Reader = .fixed(destination.written()[6..]); - var record_reader: record.Reader = undefined; - try record_reader.init(&source); - try std.testing.expectEqual(record.Tag.screen, record_reader.header.tag); - - const payload_reader = record_reader.payloadReader(); - var expected_header = testHeader(); - expected_header.page_count = 1; - expected_header.cursor_x = 7; - expected_header.cursor_y = 6; - expected_header.hyperlink_implicit_id = 0x0a0b0c0e; - try std.testing.expectEqualDeep( - expected_header, - try Header.decode(payload_reader), - ); - try std.testing.expectEqualDeep( - testSavedCursor(), - try SavedCursor.decode(payload_reader), - ); - - const expected_hyperlink: TerminalHyperlink = .{ - .id = .{ .implicit = 0x0a0b0c0d }, - .uri = "cursor-uri", - }; - var actual_hyperlink = (try decodeCursorHyperlink( - payload_reader, - std.testing.allocator, - )).?; - defer actual_hyperlink.deinit(std.testing.allocator); - try expectHyperlinkEqual(expected_hyperlink, actual_hyperlink); - try record_reader.finish(); - - var decoded_page = try page.decode(&source, std.testing.allocator); - defer decoded_page.deinit(); - try std.testing.expectEqual(@as(u16, 8), decoded_page.size.cols); - try std.testing.expectEqual(@as(u16, 8), decoded_page.size.rows); - try std.testing.expectError(error.EndOfStream, source.takeByte()); - // The complete sequence restores directly into native Screen/PageList // state, including page-local cursor references. var restore_source: std.Io.Reader = .fixed(destination.written()[6..]); @@ -1868,10 +1802,18 @@ test "framed native SCREEN and PAGE sequence" { @as(u32, 0x0a0b0c0e), restored.cursor.hyperlink_implicit_id, ); - try expectHyperlinkEqual( - expected_hyperlink, - restored.cursor.hyperlink.?.*, + const restored_hyperlink = restored.cursor.hyperlink.?.*; + try std.testing.expectEqualStrings( + "cursor-uri", + restored_hyperlink.uri, ); + switch (restored_hyperlink.id) { + .implicit => |id| try std.testing.expectEqual( + @as(u32, 0x0a0b0c0d), + id, + ), + .explicit => try std.testing.expect(false), + } const restored_saved = restored.saved_cursor.?; try std.testing.expectEqual(@as(u16, 0x0102), restored_saved.x); @@ -2138,62 +2080,6 @@ test "SCREEN sequence failure preserves preceding bytes" { try std.testing.expectEqualStrings("prefix", destination.written()); } -test "cursor hyperlink rejects invalid kind and every truncation" { - var invalid_kind_reader: std.Io.Reader = .fixed("\x03"); - try std.testing.expectError( - error.InvalidKind, - decodeCursorHyperlink( - &invalid_kind_reader, - std.testing.allocator, - ), - ); - - const empty_cases = [_]struct { - fixture: []const u8, - expected: anyerror, - }{ - .{ - .fixture = "\x01\x04\x03\x02\x01\x00\x00\x00\x00", - .expected = error.InvalidUri, - }, - .{ - .fixture = "\x02\x00\x00\x00\x00", - .expected = error.InvalidExplicitId, - }, - .{ - .fixture = "\x02\x02\x00\x00\x00id\x00\x00\x00\x00", - .expected = error.InvalidUri, - }, - }; - for (empty_cases) |case| { - var reader: std.Io.Reader = .fixed(case.fixture); - try std.testing.expectError( - case.expected, - decodeCursorHyperlink( - &reader, - std.testing.allocator, - ), - ); - } - - const fixtures = .{ - "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", - "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", - }; - inline for (fixtures) |fixture| { - for (0..fixture.len) |fixture_len| { - var reader: std.Io.Reader = .fixed(fixture[0..fixture_len]); - try std.testing.expectError( - error.EndOfStream, - decodeCursorHyperlink( - &reader, - std.testing.allocator, - ), - ); - } - } -} - test "SCREEN decode rejects an empty cursor hyperlink URI" { var destination: std.Io.Writer.Allocating = .init( std.testing.allocator, diff --git a/src/terminal/snapshot/style.zig b/src/terminal/snapshot/style.zig index 78c905dff..481d7da99 100644 --- a/src/terminal/snapshot/style.zig +++ b/src/terminal/snapshot/style.zig @@ -84,6 +84,9 @@ pub const DecodeError = std.Io.Reader.Error || error{ /// A color kind is not defined by snapshot version 1. InvalidColorKind, + /// Bytes unused by the selected color kind are not zero. + InvalidColor, + /// The encoded underline kind is not defined by snapshot version 1. InvalidUnderline, @@ -187,8 +190,14 @@ fn decodeColor( }; return switch (kind) { - .none => .none, - .palette => .{ .palette = encoded[1] }, + .none => if (std.mem.eql(u8, encoded[1..], &.{ 0, 0, 0 })) + .none + else + error.InvalidColor, + .palette => if (encoded[2] == 0 and encoded[3] == 0) + .{ .palette = encoded[1] } + else + error.InvalidColor, .rgb => .{ .rgb = .{ .r = encoded[1], .g = encoded[2], @@ -206,7 +215,7 @@ fn computeLen() usize { } } -test "golden encoding" { +test "golden encoding and decoding" { const value: terminal_style.Style = .{ .fg_color = .none, .bg_color = .{ .palette = 0x7f }, @@ -227,103 +236,100 @@ test "golden encoding" { .underline = .curly, }, }; - - var buf: [len]u8 = undefined; - var writer: std.Io.Writer = .fixed(&buf); - try encode(value, &writer); - - try std.testing.expectEqualStrings( - "\x00\x00\x00\x00" ++ - "\x01\x7f\x00\x00" ++ - "\x02\x12\x34\x56" ++ - "\xff\x03\x00\x00", - writer.buffered(), - ); -} - -test "flag bit layout" { - const cases = .{ - .{ Flags{ .bold = true }, @as(u16, 1 << 0) }, - .{ Flags{ .italic = true }, @as(u16, 1 << 1) }, - .{ Flags{ .faint = true }, @as(u16, 1 << 2) }, - .{ Flags{ .blink = true }, @as(u16, 1 << 3) }, - .{ Flags{ .inverse = true }, @as(u16, 1 << 4) }, - .{ Flags{ .invisible = true }, @as(u16, 1 << 5) }, - .{ Flags{ .strikethrough = true }, @as(u16, 1 << 6) }, - .{ Flags{ .overline = true }, @as(u16, 1 << 7) }, - .{ - Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.single) }, - @as(u16, 1 << 8), - }, - .{ - Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.double) }, - @as(u16, 2 << 8), - }, - .{ - Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.curly) }, - @as(u16, 3 << 8), - }, - .{ - Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.dotted) }, - @as(u16, 4 << 8), - }, - .{ - Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.dashed) }, - @as(u16, 5 << 8), - }, - .{ Flags{ .reserved = 1 }, @as(u16, 1 << 11) }, - }; - - inline for (cases) |case| { - try std.testing.expectEqual(case[1], @as(u16, @bitCast(case[0]))); - } -} - -test "decoding" { const fixture = "\x00\x00\x00\x00" ++ "\x01\x7f\x00\x00" ++ "\x02\x12\x34\x56" ++ "\xff\x03\x00\x00"; - var source: std.Io.Reader = .fixed(fixture); - var buf: [1]u8 = undefined; - var limited = source.limited(.unlimited, &buf); - const expected: terminal_style.Style = .{ - .fg_color = .none, - .bg_color = .{ .palette = 0x7f }, - .underline_color = .{ .rgb = .{ - .r = 0x12, - .g = 0x34, - .b = 0x56, - } }, - .flags = .{ - .bold = true, - .italic = true, - .faint = true, - .blink = true, - .inverse = true, - .invisible = true, - .strikethrough = true, - .overline = true, - .underline = .curly, - }, - }; - const actual = try decode(&limited.interface); - try std.testing.expect(expected.eql(actual)); + var buf: [len]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(value, &writer); + try std.testing.expectEqualStrings(fixture, writer.buffered()); + + var source: std.Io.Reader = .fixed(fixture); + var read_buf: [1]u8 = undefined; + var limited = source.limited(.unlimited, &read_buf); + try std.testing.expect(value.eql(try decode(&limited.interface))); } -test "reject invalid color kinds" { - inline for (.{ 0, 4, 8 }) |offset| { +test "flag bit layout" { + const Case = struct { + value: Flags, + expected: u16, + }; + const cases = [_]Case{ + .{ .value = .{ .bold = true }, .expected = 1 << 0 }, + .{ .value = .{ .italic = true }, .expected = 1 << 1 }, + .{ .value = .{ .faint = true }, .expected = 1 << 2 }, + .{ .value = .{ .blink = true }, .expected = 1 << 3 }, + .{ .value = .{ .inverse = true }, .expected = 1 << 4 }, + .{ .value = .{ .invisible = true }, .expected = 1 << 5 }, + .{ .value = .{ .strikethrough = true }, .expected = 1 << 6 }, + .{ .value = .{ .overline = true }, .expected = 1 << 7 }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.single), + }, + .expected = 1 << 8, + }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.double), + }, + .expected = 2 << 8, + }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.curly), + }, + .expected = 3 << 8, + }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.dotted), + }, + .expected = 4 << 8, + }, + .{ + .value = .{ + .underline = @intFromEnum(sgr.Attribute.Underline.dashed), + }, + .expected = 5 << 8, + }, + .{ .value = .{ .reserved = 1 }, .expected = 1 << 11 }, + }; + + for (cases) |case| { + try std.testing.expectEqual( + case.expected, + @as(u16, @bitCast(case.value)), + ); + } +} + +test "reject invalid colors" { + for ([_]usize{ 0, 4, 8 }) |offset| { var invalid_kind: [len]u8 = @splat(0); invalid_kind[offset] = 3; var reader: std.Io.Reader = .fixed(&invalid_kind); try std.testing.expectError(error.InvalidColorKind, decode(&reader)); } + + var invalid_none: [len]u8 = @splat(0); + invalid_none[1] = 1; + var none_reader: std.Io.Reader = .fixed(&invalid_none); + try std.testing.expectError(error.InvalidColor, decode(&none_reader)); + + var invalid_palette: [len]u8 = @splat(0); + invalid_palette[0] = @intFromEnum(ColorKind.palette); + invalid_palette[2] = 1; + var palette_reader: std.Io.Reader = .fixed(&invalid_palette); + try std.testing.expectError(error.InvalidColor, decode(&palette_reader)); } test "reject invalid flags and reserved field" { - inline for (.{ 6, 7 }) |underline| { + for ([_]u16{ 6, 7 }) |underline| { var invalid_underline: [len]u8 = @splat(0); std.mem.writeInt( u16, diff --git a/src/terminal/snapshot/terminal.zig b/src/terminal/snapshot/terminal.zig index 64b2052b8..40a79c29d 100644 --- a/src/terminal/snapshot/terminal.zig +++ b/src/terminal/snapshot/terminal.zig @@ -1195,16 +1195,11 @@ const test_header_fixture = "\xff\xff\xff\xff\xff\xff\xff\xff" ++ "\x08\x07\x06\x05\x04\x03\x02\x01"; -test "TERMINAL payload type registries" { - try std.testing.expectEqual(@as(usize, 103), Header.len); +test "TERMINAL mode bit layout" { try std.testing.expectEqual( @as(usize, 42), @bitSizeOf(terminal_modes.ModePacked), ); - try std.testing.expectEqual( - @as(usize, 8), - @sizeOf(terminal_modes.ModePacked), - ); var first: terminal_modes.ModePacked = std.mem.zeroes( terminal_modes.ModePacked, @@ -1232,15 +1227,6 @@ test "TERMINAL payload type registries" { @as(u42, 1) << 41, @as(u42, @bitCast(last)), ); - - try std.testing.expectEqual( - @as(u1, 0), - @intFromEnum(terminal_ansi.StatusDisplay.main), - ); - try std.testing.expectEqual( - @as(c_int, 33), - @intFromEnum(terminal_mouse.Shape.zoom_out), - ); } test "TERMINAL header golden encoding and decoding" { @@ -1297,28 +1283,33 @@ test "TERMINAL header rejects invalid values" { // Single-byte registries and boolean encodings reject every value just // beyond their current snapshot range. - const byte_cases = .{ - .{ @as(usize, 20), @as(u8, 2), error.InvalidStatusDisplay }, - .{ @as(usize, 21), @as(u8, 2), error.InvalidActiveScreenKey }, - .{ @as(usize, 29), @as(u8, 2), error.InvalidCursorIsDefault }, - .{ @as(usize, 30), @as(u8, 4), error.InvalidCursorStyle }, - .{ @as(usize, 31), @as(u8, 3), error.InvalidCursorBlink }, - .{ @as(usize, 32), @as(u8, 3), error.InvalidShellRedraw }, - .{ @as(usize, 33), @as(u8, 2), error.InvalidModifyOtherKeys }, - .{ @as(usize, 34), @as(u8, 5), error.InvalidMouseEvent }, - .{ @as(usize, 35), @as(u8, 5), error.InvalidMouseFormat }, - .{ @as(usize, 36), @as(u8, 3), error.InvalidMouseShiftCapture }, - .{ @as(usize, 37), @as(u8, 34), error.InvalidMouseShape }, - .{ @as(usize, 38), @as(u8, 2), error.InvalidPasswordInput }, - .{ @as(usize, 44), @as(u8, 4), error.InvalidModes }, - .{ @as(usize, 63), @as(u8, 2), error.InvalidDynamicRGB }, - .{ @as(usize, 68), @as(u8, 1), error.InvalidDynamicRGB }, + const ByteCase = struct { + offset: usize, + value: u8, + expected: anyerror, }; - inline for (byte_cases) |case| { + const byte_cases = [_]ByteCase{ + .{ .offset = 20, .value = 2, .expected = error.InvalidStatusDisplay }, + .{ .offset = 21, .value = 2, .expected = error.InvalidActiveScreenKey }, + .{ .offset = 29, .value = 2, .expected = error.InvalidCursorIsDefault }, + .{ .offset = 30, .value = 4, .expected = error.InvalidCursorStyle }, + .{ .offset = 31, .value = 3, .expected = error.InvalidCursorBlink }, + .{ .offset = 32, .value = 3, .expected = error.InvalidShellRedraw }, + .{ .offset = 33, .value = 2, .expected = error.InvalidModifyOtherKeys }, + .{ .offset = 34, .value = 5, .expected = error.InvalidMouseEvent }, + .{ .offset = 35, .value = 5, .expected = error.InvalidMouseFormat }, + .{ .offset = 36, .value = 3, .expected = error.InvalidMouseShiftCapture }, + .{ .offset = 37, .value = 34, .expected = error.InvalidMouseShape }, + .{ .offset = 38, .value = 2, .expected = error.InvalidPasswordInput }, + .{ .offset = 44, .value = 4, .expected = error.InvalidModes }, + .{ .offset = 63, .value = 2, .expected = error.InvalidDynamicRGB }, + .{ .offset = 68, .value = 1, .expected = error.InvalidDynamicRGB }, + }; + for (byte_cases) |case| { var fixture = test_header_fixture.*; - fixture[case[0]] = case[1]; + fixture[case.offset] = case.value; var reader: std.Io.Reader = .fixed(&fixture); - try testing.expectError(case[2], Header.decode(&reader)); + try testing.expectError(case.expected, Header.decode(&reader)); } // Cross-field and multi-byte invariants are validated after decoding. @@ -1480,7 +1471,7 @@ test "TERMINAL payload rejects noncanonical state" { try testing.expectEqual(@as(usize, 0), writer.end); } -test "TERMINAL payload rejects padding and every truncation" { +test "TERMINAL payload rejects tab-stop padding" { const testing = std.testing; var tabstops = try TerminalTabstops.init( @@ -1519,16 +1510,6 @@ test "TERMINAL payload rejects padding and every truncation" { error.InvalidTabStops, decodePayload(&padding_reader, testing.allocator), ); - - for (0..destination.written().len) |fixture_len| { - var reader: std.Io.Reader = .fixed( - destination.written()[0..fixture_len], - ); - try testing.expectError( - error.EndOfStream, - decodePayload(&reader, testing.allocator), - ); - } } test "TERMINAL record encodes native terminal state" { @@ -1579,87 +1560,14 @@ test "TERMINAL record encodes native terminal state" { try terminal.setPwd("file:///tmp/native"); try terminal.setTitle("native title"); - // Decode the generated framing and payload independently. + // Encode and restore through the public record codec. var destination: std.Io.Writer.Allocating = .init(testing.allocator); defer destination.deinit(); try encode(&terminal, &destination); var source: std.Io.Reader = .fixed(destination.written()); - var record_reader: record.Reader = undefined; - try record_reader.init(&source); - try testing.expectEqual(record.Tag.terminal, record_reader.header.tag); - var decoded = try decodePayload( - record_reader.payloadReader(), - testing.allocator, - ); - defer decoded.deinit(testing.allocator); - try record_reader.finish(); - - // Native screen routing and policy are derived rather than caller-supplied. - try testing.expectEqual(@as(u16, 10), decoded.header.columns); - try testing.expectEqual(@as(u16, 3), decoded.header.rows); - try testing.expectEqual(@as(u32, 800), decoded.header.width_px); - try testing.expectEqual(@as(u32, 600), decoded.header.height_px); - try testing.expectEqual( - terminal.scrolling_region, - Terminal.ScrollingRegion{ - .top = decoded.header.scrolling_region_top, - .bottom = decoded.header.scrolling_region_bottom, - .left = decoded.header.scrolling_region_left, - .right = decoded.header.scrolling_region_right, - }, - ); - try testing.expectEqual(terminal.status_display, decoded.header.status_display); - try testing.expectEqual(.primary, decoded.header.active_screen_key); - try testing.expectEqual(@as(u16, 1), decoded.header.screen_count); - try testing.expectEqual(@as(?u21, 'X'), decoded.header.previous_codepoint); - try testing.expectEqual(terminal.cursor.is_default, decoded.header.cursor_is_default); - try testing.expectEqual( - terminal.cursor.default_style, - decoded.header.cursor_default_style, - ); - try testing.expectEqual( - terminal.cursor.default_blink, - decoded.header.cursor_default_blink, - ); - try testing.expectEqual( - terminal.flags.shell_redraws_prompt, - decoded.header.shell_redraw, - ); - try testing.expectEqual( - terminal.flags.modify_other_keys_2, - decoded.header.modify_other_keys_2, - ); - try testing.expectEqual(terminal.flags.mouse_event, decoded.header.mouse_event); - try testing.expectEqual( - terminal.flags.mouse_format, - decoded.header.mouse_format, - ); - try testing.expectEqual(@as(?bool, true), decoded.header.mouse_shift_capture); - try testing.expectEqual(terminal.mouse_shape, decoded.header.mouse_shape); - try testing.expectEqual( - terminal.flags.password_input, - decoded.header.password_input, - ); - try testing.expectEqualDeep(terminal.modes.values, decoded.header.current_modes); - try testing.expectEqualDeep(terminal.modes.saved, decoded.header.saved_modes); - try testing.expectEqualDeep(terminal.modes.default, decoded.header.default_modes); - try testing.expectEqualDeep( - terminal.colors.background, - decoded.header.background, - ); - try testing.expectEqualDeep(terminal.colors.palette, decoded.palette); - try testing.expectEqual(@as(?u64, 12_345), decoded.header.max_scrollback_bytes); - try testing.expectEqual(@as(?u64, 67), decoded.header.max_scrollback_rows); - try testing.expect(decoded.tabstops.get(1)); - try testing.expect(decoded.tabstops.get(9)); - try testing.expectEqualStrings("file:///tmp/native", decoded.pwd); - try testing.expectEqualStrings("native title", decoded.title); - - // The public decoder installs the same payload into native ownership. - var native_source: std.Io.Reader = .fixed(destination.written()); var restored = try decode( - &native_source, + &source, testing.io, testing.allocator, ); @@ -1675,6 +1583,31 @@ test "TERMINAL record encodes native terminal state" { try testing.expect(restored.screens.get(.alternate) == null); try testing.expectEqual(@as(?u21, 'X'), restored.previous_char); try testing.expectEqualDeep(terminal.cursor, restored.cursor); + try testing.expectEqual( + terminal.flags.shell_redraws_prompt, + restored.flags.shell_redraws_prompt, + ); + try testing.expectEqual( + terminal.flags.modify_other_keys_2, + restored.flags.modify_other_keys_2, + ); + try testing.expectEqual( + terminal.flags.mouse_event, + restored.flags.mouse_event, + ); + try testing.expectEqual( + terminal.flags.mouse_format, + restored.flags.mouse_format, + ); + try testing.expectEqual( + terminal.flags.mouse_shift_capture, + restored.flags.mouse_shift_capture, + ); + try testing.expectEqual( + terminal.flags.password_input, + restored.flags.password_input, + ); + try testing.expectEqual(terminal.mouse_shape, restored.mouse_shape); try testing.expectEqualDeep(terminal.modes, restored.modes); try testing.expectEqualDeep(terminal.colors, restored.colors); try testing.expectEqual( From 32f11a4663c897366f91876b5dda7fdc715e6ac0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Jul 2026 15:14:14 -0700 Subject: [PATCH 19/35] terminal/snapshot: test fixtures --- src/terminal/snapshot/envelope.zig | 18 +- src/terminal/snapshot/fixture.zig | 416 ++++++++++++++++++ src/terminal/snapshot/history.zig | 25 +- src/terminal/snapshot/hyperlink.zig | 45 +- src/terminal/snapshot/page.zig | 88 ++-- src/terminal/snapshot/record.zig | 20 +- src/terminal/snapshot/screen.zig | 51 ++- src/terminal/snapshot/snapshot.zig | 81 +++- src/terminal/snapshot/style.zig | 21 +- src/terminal/snapshot/terminal.zig | 40 +- .../snapshot/testdata/complete-v1.hex | 138 ++++++ .../snapshot/testdata/envelope-v1.hex | 7 + .../snapshot/testdata/history-header-v1.hex | 7 + .../testdata/hyperlink-explicit-v1.hex | 7 + .../testdata/hyperlink-implicit-v1.hex | 7 + .../testdata/page-empty-record-v1.hex | 9 + .../snapshot/testdata/page-header-v1.hex | 8 + src/terminal/snapshot/testdata/page-v1.hex | 21 + .../testdata/record-page-header-v1.hex | 7 + .../snapshot/testdata/screen-header-v1.hex | 9 + .../testdata/screen-saved-cursor-v1.hex | 8 + src/terminal/snapshot/testdata/style-v1.hex | 7 + .../snapshot/testdata/terminal-header-v1.hex | 13 + 23 files changed, 913 insertions(+), 140 deletions(-) create mode 100644 src/terminal/snapshot/fixture.zig create mode 100644 src/terminal/snapshot/testdata/complete-v1.hex create mode 100644 src/terminal/snapshot/testdata/envelope-v1.hex create mode 100644 src/terminal/snapshot/testdata/history-header-v1.hex create mode 100644 src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex create mode 100644 src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex create mode 100644 src/terminal/snapshot/testdata/page-empty-record-v1.hex create mode 100644 src/terminal/snapshot/testdata/page-header-v1.hex create mode 100644 src/terminal/snapshot/testdata/page-v1.hex create mode 100644 src/terminal/snapshot/testdata/record-page-header-v1.hex create mode 100644 src/terminal/snapshot/testdata/screen-header-v1.hex create mode 100644 src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex create mode 100644 src/terminal/snapshot/testdata/style-v1.hex create mode 100644 src/terminal/snapshot/testdata/terminal-header-v1.hex diff --git a/src/terminal/snapshot/envelope.zig b/src/terminal/snapshot/envelope.zig index 726167391..9963ced2a 100644 --- a/src/terminal/snapshot/envelope.zig +++ b/src/terminal/snapshot/envelope.zig @@ -17,6 +17,7 @@ //! | 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 @@ -65,15 +66,25 @@ fn computeLen() usize { } } +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 std.testing.expectEqualStrings( - "GHOSTSNP\x01\x00", + 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" { @@ -88,9 +99,8 @@ test "reject invalid magic and version" { } test "reject every truncation" { - const fixture = "GHOSTSNP\x01\x00"; for (0..encoded_len) |len| { - var reader: std.Io.Reader = .fixed(fixture[0..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..6d256ac3d --- /dev/null +++ b/src/terminal/snapshot/fixture.zig @@ -0,0 +1,416 @@ +//! 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), +//! ); +//! } +//! ``` + +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(); + + // 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, 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, + bytes: []const u8, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { + // Every generated candidate starts with enough maintenance context to be + // understandable when copied out of a failing test's temp directory. + try writer.print( + "# Ghostty snapshot fixture\n" ++ + "# Wire version: {}\n" ++ + "# Generated by its snapshot test; review before replacing.\n" ++ + "# On mismatch, the candidate is copied to the repository root.\n", + .{envelope.version}, + ); + + // 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 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/history.zig b/src/terminal/snapshot/history.zig index c9f63510c..8422c4f73 100644 --- a/src/terminal/snapshot/history.zig +++ b/src/terminal/snapshot/history.zig @@ -65,6 +65,7 @@ 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"); @@ -334,6 +335,10 @@ fn hasSemanticPrompt(terminal_page: *const TerminalPage) bool { 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, @@ -341,20 +346,24 @@ test "HISTORY header golden encoding and decoding" { .total_rows = 0x0102030405060708, .screen_overlap_rows = 0x090a, }; - const fixture = - "\x01\x00\x04\x03\x02\x01" ++ - "\x08\x07\x06\x05\x04\x03\x02\x01\x0a\x09"; - - try std.testing.expectEqual(Header.len, fixture.len); var encoded: [Header.len]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try expected.encode(&writer); - try std.testing.expectEqualStrings(fixture, writer.buffered()); + 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(fixture); + 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(fixture[0..fixture_len]); + var truncated: std.Io.Reader = .fixed( + test_header_fixture[0..fixture_len], + ); try std.testing.expectError( error.EndOfStream, Header.decode(&truncated), diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig index 804288249..6baade906 100644 --- a/src/terminal/snapshot/hyperlink.zig +++ b/src/terminal/snapshot/hyperlink.zig @@ -42,6 +42,7 @@ 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"); @@ -270,6 +271,14 @@ fn decodePageString( }; } +const test_implicit_fixture = test_fixture.parse( + @embedFile("testdata/hyperlink-implicit-v1.hex"), +); + +const test_explicit_fixture = test_fixture.parse( + @embedFile("testdata/hyperlink-explicit-v1.hex"), +); + test "golden implicit encoding" { const value: terminal_hyperlink.Hyperlink = .{ .id = .{ .implicit = 0x01020304 }, @@ -280,10 +289,18 @@ test "golden implicit encoding" { var writer: std.Io.Writer = .fixed(&buf); try encode(value, &writer); - try std.testing.expectEqualStrings( - "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", + 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" { @@ -296,10 +313,18 @@ test "golden explicit encoding" { var writer: std.Io.Writer = .fixed(&buf); try encode(value, &writer); - try std.testing.expectEqualStrings( - "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", + 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 "decode rejects empty strings" { @@ -369,9 +394,7 @@ test "reject invalid kinds" { test "decode allocation failure" { { - var reader: std.Io.Reader = .fixed( - "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", - ); + var reader: std.Io.Reader = .fixed(&test_implicit_fixture); var failing = std.testing.FailingAllocator.init( std.testing.allocator, .{ .fail_index = 0 }, @@ -387,9 +410,7 @@ test "decode allocation failure" { std.testing.allocator, .{ .fail_index = fail_index }, ); - var reader: std.Io.Reader = .fixed( - "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", - ); + var reader: std.Io.Reader = .fixed(&test_explicit_fixture); try std.testing.expectError( error.OutOfMemory, decode(&reader, failing.allocator()), @@ -399,8 +420,8 @@ test "decode allocation failure" { test "reject every truncation" { const fixtures: [2][]const u8 = .{ - "\x01\x04\x03\x02\x01\x03\x00\x00\x00uri", - "\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri", + &test_implicit_fixture, + &test_explicit_fixture, }; for (fixtures) |fixture| { diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 9001ac8c9..4466f0663 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -94,6 +94,7 @@ 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"); @@ -546,35 +547,17 @@ fn pageHyperlink( }; } -// Regenerate these after a wire-format change from `writer.buffered()` in the -// sparse-page test and `destination.written()` in the empty-record test below. -// Format those byte slices as Zig-escaped strings before replacing the literals. -const test_page_fixture = - "\x03\x00\x02\x00\x02\x00\x02\x00\x08\x00\x00\x02\x80\x00\x00\x00" ++ - "\x00\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ++ - "\x00\x00\x01\x00\x00\x00\x03\x00\x00\x00\x00\x00\x01\x2a\x00\x00" ++ - "\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x01\x00\x00\x00\x61" ++ - "\x05\x00\x00\x00\x61\x6c\x70\x68\x61\x03\x00\x01\x04\x03\x02\x01" ++ - "\x04\x00\x00\x00\x62\x65\x74\x61\x04\x00\x01\x05\x00\x01\x00\x01" ++ - "\x00\x41\x00\x00\x00\x00\x00\x00\x00\x00\x02\x02\x00\x03\x00\x03" ++ - "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01" ++ - "\x00\x07\x00\x00\x00\x00\x00\x00\x00\x0b\x00\x00\x00\x00\x00\x00" ++ - "\x00\x00\x78\x00\x00\x00\x02\x00\x00\x00\x01\x03\x00\x00\x02\x03" ++ - "\x00\x00\x02\x00\x01\x00\x00\x00\x00\x00\xaa\xbb\xcc\x00\x00\x00" ++ - "\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ++ - "\x00\x00"; +const test_page_fixture = test_fixture.parse( + @embedFile("testdata/page-v1.hex"), +); -const test_empty_framed_page_fixture = - // Record header: PAGE, 37-byte payload, CRC32C. - "\x03\x00\x25\x00\x00\x00\x8c\x05\xd6\xd3" ++ - // PAGE header: one column, one row, and zero counts/capacities. - "\x01\x00\x01\x00\x00\x00\x00\x00" ++ - "\x00\x00\x00\x00\x00\x00\x00\x00" ++ - "\x00\x00\x00\x00" ++ - // One default row and one empty cell. - "\x00\x00\x00\x00\x00\x00\x00\x00" ++ - "\x00\x00\x00\x00\x00\x00\x00\x00" ++ - "\x00"; +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 = .{ @@ -587,29 +570,26 @@ test "PAGE header golden encoding and decoding" { .grapheme_capacity_bytes = 0x0d0e0f10, .string_capacity_bytes = 0x11121314, }; - const fixture = - "\x02\x01\x04\x03\x06\x05\x08\x07" ++ - "\x0a\x09\x0c\x0b\x10\x0f\x0e\x0d" ++ - "\x14\x13\x12\x11"; - var buf: [Header.len]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); try header.encode(&writer); - try std.testing.expectEqualStrings(fixture, writer.buffered()); + 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(fixture); + 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" { - const fixture = - "\x02\x01\x04\x03\x06\x05\x08\x07" ++ - "\x0a\x09\x0c\x0b\x10\x0f\x0e\x0d" ++ - "\x14\x13\x12\x11"; for (0..Header.len) |len| { - var reader: std.Io.Reader = .fixed(fixture[0..len]); + var reader: std.Io.Reader = .fixed(test_header_fixture[0..len]); try std.testing.expectError(error.EndOfStream, Header.decode(&reader)); } } @@ -729,12 +709,21 @@ test "framed PAGE encode and decode a sparse native page" { var writer: std.Io.Writer = .fixed(&encoded); try encodePayload(&page, &writer); - const fixture = test_page_fixture; - try std.testing.expectEqualStrings(fixture, writer.buffered()); - try std.testing.expectEqual(@as(u64, fixture.len), counter.count); + 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(writer.buffered()); + 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( @@ -865,12 +854,15 @@ test "framed PAGE golden empty record" { var destination: std.Io.Writer.Allocating = .init(std.testing.allocator); defer destination.deinit(); try encode(&page, &destination); - try std.testing.expectEqualStrings( - test_empty_framed_page_fixture, + 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(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( @@ -880,7 +872,7 @@ test "framed PAGE golden empty record" { } test "framed PAGE rejects a different record tag" { - var wrong_tag = test_empty_framed_page_fixture.*; + var wrong_tag = test_empty_framed_page_fixture; std.mem.writeInt( u16, wrong_tag[0..2], diff --git a/src/terminal/snapshot/record.zig b/src/terminal/snapshot/record.zig index 6a5a20e70..19c9285a5 100644 --- a/src/terminal/snapshot/record.zig +++ b/src/terminal/snapshot/record.zig @@ -19,6 +19,7 @@ //! Supported tags are in `Tag`. const std = @import("std"); +const test_fixture = @import("fixture.zig"); const io = @import("io.zig"); /// CRC32C as specified by the snapshot format. Zig names this standard @@ -295,6 +296,10 @@ fn encodeChecksumPrefix( 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" ++ @@ -314,10 +319,16 @@ test "golden PAGE record header and checksum" { var writer: std.Io.Writer = .fixed(&buf); try header.encode(&writer); - try std.testing.expectEqualStrings( - "\x03\x00\x18\x00\x00\x00\x1b\x44\x78\x71", + 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" { @@ -330,9 +341,10 @@ test "reject invalid tags" { } test "reject every header truncation" { - const fixture = "\x03\x00\x18\x00\x00\x00\x1b\x44\x78\x71"; for (0..Header.len) |len| { - var reader: std.Io.Reader = .fixed(fixture[0..len]); + var reader: std.Io.Reader = .fixed( + test_page_header_fixture[0..len], + ); try std.testing.expectError(error.EndOfStream, Header.decode(&reader)); } } diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index ea282db56..e9de03c66 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -201,6 +201,7 @@ 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"); @@ -1116,19 +1117,13 @@ pub fn decodeCursorHyperlink( return try hyperlink.decode(reader, alloc); } -const test_header_fixture = - "\x01\x00\x03\x02\x05\x04\x07\x06\x03\x19" ++ - "\x00\x00\x00\x00\x01\x7f\x00\x00" ++ - "\x02\x12\x34\x56\xff\x03\x00\x00" ++ - "\x0d\x0c\x0b\x0a\xe4\x3d\x02\x07" ++ - "\x01\x02\x04\x08\x10\x1f\x00\x11" ++ - "\x02\x03\x01"; +const test_header_fixture = test_fixture.parse( + @embedFile("testdata/screen-header-v1.hex"), +); -const test_saved_cursor_fixture = - "\x02\x01\x04\x03" ++ - "\x00\x00\x00\x00\x00\x00\x00\x00" ++ - "\x00\x00\x00\x00\x00\x00\x00\x00" ++ - "\x07\xe4\x3d"; +const test_saved_cursor_fixture = test_fixture.parse( + @embedFile("testdata/screen-saved-cursor-v1.hex"), +); fn testCharsetState() TerminalScreen.CharsetState { var result: TerminalScreen.CharsetState = .{ @@ -1220,18 +1215,20 @@ fn testSavedCursor() SavedCursor { } test "SCREEN header golden encoding and decoding" { - try std.testing.expectEqual(Header.len, test_header_fixture.len); - var encoded: [Header.len]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try testHeader().encode(&writer); - try std.testing.expectEqualStrings( - test_header_fixture, + 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 source: std.Io.Reader = .fixed(&test_header_fixture); var buffer: [1]u8 = undefined; var limited = source.limited(.unlimited, &buffer); @@ -1601,20 +1598,22 @@ test "native SCREEN payload omits absent optional state" { } 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 encoded: [SavedCursor.len]u8 = undefined; - var writer: std.Io.Writer = .fixed(&encoded); - try testSavedCursor().encode(&writer); - try std.testing.expectEqualStrings( - test_saved_cursor_fixture, - writer.buffered(), - ); - - var source: std.Io.Reader = .fixed(test_saved_cursor_fixture); + 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( diff --git a/src/terminal/snapshot/snapshot.zig b/src/terminal/snapshot/snapshot.zig index 48494a5fe..cd7215ab4 100644 --- a/src/terminal/snapshot/snapshot.zig +++ b/src/terminal/snapshot/snapshot.zig @@ -5,14 +5,20 @@ 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 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 || @@ -196,15 +202,14 @@ test "complete snapshot round trip with history and alternate screen" { const testing = std.testing; var t = try Terminal.init(testing.io, testing.allocator, .{ - .cols = 80, + .cols = 2, .rows = 3, .max_scrollback_bytes = null, .max_scrollback_lines = null, }); defer t.deinit(testing.allocator); - // Exercise terminal-wide state and grow the primary screen until its - // active area is preceded by multiple complete history pages. + // Exercise terminal-wide state. t.width_px = 800; t.height_px = 600; t.colors.palette.set(7, .{ .r = 1, .g = 2, .b = 3 }); @@ -213,9 +218,58 @@ test "complete snapshot round trip with history and alternate screen" { try t.setTitle("complete snapshot"); const primary = t.screens.get(.primary).?; - while (primary.pages.totalPages() < 4) { - try t.printString("primary history\n"); - } + + // 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 @@ -232,8 +286,16 @@ test "complete snapshot round trip with history and alternate screen" { defer encoded.deinit(); try encode(&t, &encoded); 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(), + ); - var encoded_source: std.Io.Reader = .fixed(encoded.written()); + // 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( @@ -266,7 +328,10 @@ test "complete snapshot round trip with history and alternate screen" { var reencoded: std.Io.Writer.Allocating = .init(testing.allocator); defer reencoded.deinit(); try encode(&restored, &reencoded); - try testing.expectEqualStrings(encoded.written(), reencoded.written()); + try testing.expectEqualStrings( + &test_complete_fixture, + reencoded.written(), + ); // SCREEN keys make their order independent. Keep HISTORY canonical here; // only the active-state screen sequences are intentionally reversed. diff --git a/src/terminal/snapshot/style.zig b/src/terminal/snapshot/style.zig index 481d7da99..268c29289 100644 --- a/src/terminal/snapshot/style.zig +++ b/src/terminal/snapshot/style.zig @@ -46,6 +46,7 @@ //! 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"); @@ -215,6 +216,10 @@ fn computeLen() usize { } } +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, @@ -236,18 +241,18 @@ test "golden encoding and decoding" { .underline = .curly, }, }; - const fixture = - "\x00\x00\x00\x00" ++ - "\x01\x7f\x00\x00" ++ - "\x02\x12\x34\x56" ++ - "\xff\x03\x00\x00"; - var buf: [len]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); try encode(value, &writer); - try std.testing.expectEqualStrings(fixture, writer.buffered()); + 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(fixture); + 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))); diff --git a/src/terminal/snapshot/terminal.zig b/src/terminal/snapshot/terminal.zig index 40a79c29d..d0fd0e49f 100644 --- a/src/terminal/snapshot/terminal.zig +++ b/src/terminal/snapshot/terminal.zig @@ -239,6 +239,7 @@ 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"); @@ -1180,20 +1181,9 @@ const test_header: Header = header: { }; }; -const test_header_fixture = - "\x02\x01\x04\x03\x08\x07\x06\x05\x0c\x0b\x0a\x09" ++ - "\x01\x00\x02\x00\x03\x00\x04\x00" ++ - "\x01\x01\x00\x02\x00\x41\x00\x00\x00" ++ - "\x01\x03\x02" ++ - "\x02\x01\x04\x04\x01\x21\x01" ++ - "\x01\x00\x00\x00\x00\x00\x00\x00" ++ - "\x00\x00\x00\x00\x00\x02\x00\x00" ++ - "\x01\x00\x00\x00\x00\x02\x00\x00" ++ - "\x01\x01\x02\x03\x00\x00\x00\x00" ++ - "\x00\x00\x00\x00\x01\x04\x05\x06" ++ - "\x01\x07\x08\x09\x01\x0a\x0b\x0c" ++ - "\xff\xff\xff\xff\xff\xff\xff\xff" ++ - "\x08\x07\x06\x05\x04\x03\x02\x01"; +const test_header_fixture = test_fixture.parse( + @embedFile("testdata/terminal-header-v1.hex"), +); test "TERMINAL mode bit layout" { try std.testing.expectEqual( @@ -1231,15 +1221,21 @@ test "TERMINAL mode bit layout" { test "TERMINAL header golden encoding and decoding" { const testing = std.testing; - try testing.expectEqual(Header.len, test_header_fixture.len); var encoded: [Header.len]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); try test_header.encode(&writer); - try testing.expectEqualStrings(test_header_fixture, writer.buffered()); + 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 source: std.Io.Reader = .fixed(&test_header_fixture); var buffer: [1]u8 = undefined; var limited = source.limited(.unlimited, &buffer); try testing.expectEqualDeep( @@ -1306,14 +1302,14 @@ test "TERMINAL header rejects invalid values" { .{ .offset = 68, .value = 1, .expected = error.InvalidDynamicRGB }, }; for (byte_cases) |case| { - var fixture = test_header_fixture.*; + var fixture = test_header_fixture; fixture[case.offset] = case.value; var reader: std.Io.Reader = .fixed(&fixture); try testing.expectError(case.expected, Header.decode(&reader)); } // Cross-field and multi-byte invariants are validated after decoding. - var invalid_screen_count = test_header_fixture.*; + 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( @@ -1321,7 +1317,7 @@ test "TERMINAL header rejects invalid values" { Header.decode(&screen_count_reader), ); - var missing_active_screen = test_header_fixture.*; + 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.expectError( @@ -1329,7 +1325,7 @@ test "TERMINAL header rejects invalid values" { Header.decode(&active_screen_reader), ); - var invalid_scrolling_region = test_header_fixture.*; + var invalid_scrolling_region = test_header_fixture; invalid_scrolling_region[14] = 0x04; invalid_scrolling_region[15] = 0x03; var scrolling_region_reader: std.Io.Reader = .fixed( @@ -1340,7 +1336,7 @@ test "TERMINAL header rejects invalid values" { Header.decode(&scrolling_region_reader), ); - var invalid_codepoint = test_header_fixture.*; + var invalid_codepoint = test_header_fixture; invalid_codepoint[25] = 0x00; invalid_codepoint[26] = 0xd8; invalid_codepoint[27] = 0x00; diff --git a/src/terminal/snapshot/testdata/complete-v1.hex b/src/terminal/snapshot/testdata/complete-v1.hex new file mode 100644 index 000000000..8982c2add --- /dev/null +++ b/src/terminal/snapshot/testdata/complete-v1.hex @@ -0,0 +1,138 @@ +# Ghostty snapshot fixture +# 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 46 bytes +02 00 2e 00 00 00 dc 02 f1 37 00 00 01 00 00 00 # 0x000003cc +00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000003dc +00 00 00 00 00 00 00 00 00 08 00 00 00 00 00 00 # 0x000003ec +00 00 00 00 00 00 00 00 # 0x000003fc + +# offset 0x00000404: page record, payload 119 bytes +03 00 77 00 00 00 48 b7 91 cb 02 00 03 00 00 00 # 0x00000404 +00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x00000414 +00 00 00 00 00 00 00 43 00 00 00 00 00 00 00 00 # 0x00000424 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000434 +00 00 00 00 00 00 00 00 44 00 00 00 00 00 00 00 # 0x00000444 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000454 +00 00 00 00 00 00 00 00 00 45 00 00 00 00 00 00 # 0x00000464 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000474 +00 # 0x00000484 + +# offset 0x00000485: screen record, payload 46 bytes +02 00 2e 00 00 00 05 b9 5a 3a 01 00 01 00 01 00 # 0x00000485 +02 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000495 +00 00 00 00 00 00 00 00 00 08 00 00 00 00 00 00 # 0x000004a5 +00 00 00 00 00 00 00 00 # 0x000004b5 + +# offset 0x000004bd: page record, payload 119 bytes +03 00 77 00 00 00 84 a6 e9 08 02 00 03 00 00 00 # 0x000004bd +00 00 80 00 c0 00 00 02 00 00 00 08 00 00 03 00 # 0x000004cd +00 00 00 00 00 00 00 72 00 00 00 00 00 00 00 00 # 0x000004dd +00 00 00 00 00 00 00 6e 00 00 00 00 00 00 00 03 # 0x000004ed +00 00 00 00 00 00 00 00 61 00 00 00 00 00 00 00 # 0x000004fd +00 00 00 00 00 00 00 00 74 00 00 00 00 00 00 00 # 0x0000050d +03 00 00 00 00 00 00 00 00 65 00 00 00 00 00 00 # 0x0000051d +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000052d +00 # 0x0000053d + +# offset 0x0000053e: ready record, payload 32 bytes +05 00 20 00 00 00 29 ce 51 e1 28 ab 80 18 19 00 # 0x0000053e +ef b3 15 fa 9d 03 c3 42 7f 77 2d f3 0c 00 87 cc # 0x0000054e +a1 dc ac ce 6c fc fb 27 66 e1 # 0x0000055e + +# offset 0x00000568: history record, payload 16 bytes +04 00 10 00 00 00 cc 85 a3 b6 00 00 02 00 00 00 # 0x00000568 +04 00 00 00 00 00 00 00 00 00 # 0x00000578 + +# offset 0x00000582: page record, payload 86 bytes +03 00 56 00 00 00 52 3b 6e 8d 02 00 02 00 00 00 # 0x00000582 +00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x00000592 +00 00 00 00 00 00 00 42 00 00 00 00 00 00 00 00 # 0x000005a2 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005b2 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005c2 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005d2 + +# offset 0x000005e2: page record, payload 86 bytes +03 00 56 00 00 00 fb bc 15 06 02 00 02 00 00 00 # 0x000005e2 +00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x000005f2 +00 00 00 00 00 00 00 41 00 00 00 00 00 00 00 00 # 0x00000602 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000612 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000622 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000632 + +# offset 0x00000642: history record, payload 16 bytes +04 00 10 00 00 00 39 57 cc cf 01 00 00 00 00 00 # 0x00000642 +00 00 00 00 00 00 00 00 00 00 # 0x00000652 + +# offset 0x0000065c: finish record, payload 32 bytes +06 00 20 00 00 00 05 71 15 22 3e 4d e9 7a c1 02 # 0x0000065c +0b a3 6a 10 a0 88 1c 07 03 2c 68 52 15 be 76 d0 # 0x0000066c +4c a9 a6 2b fd f4 ac 49 62 2b # 0x0000067c diff --git a/src/terminal/snapshot/testdata/envelope-v1.hex b/src/terminal/snapshot/testdata/envelope-v1.hex new file mode 100644 index 000000000..3e4ddadfa --- /dev/null +++ b/src/terminal/snapshot/testdata/envelope-v1.hex @@ -0,0 +1,7 @@ +# Ghostty snapshot fixture +# 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/history-header-v1.hex b/src/terminal/snapshot/testdata/history-header-v1.hex new file mode 100644 index 000000000..7949ca620 --- /dev/null +++ b/src/terminal/snapshot/testdata/history-header-v1.hex @@ -0,0 +1,7 @@ +# Ghostty snapshot fixture +# 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 08 07 06 05 04 03 02 01 0a 09 # 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..6f925099a --- /dev/null +++ b/src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex @@ -0,0 +1,7 @@ +# Ghostty snapshot fixture +# 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..8ae474676 --- /dev/null +++ b/src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex @@ -0,0 +1,7 @@ +# Ghostty snapshot fixture +# 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..06db7cc54 --- /dev/null +++ b/src/terminal/snapshot/testdata/page-empty-record-v1.hex @@ -0,0 +1,9 @@ +# Ghostty snapshot fixture +# 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..811ad80fc --- /dev/null +++ b/src/terminal/snapshot/testdata/page-header-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# 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..d3c04d0c4 --- /dev/null +++ b/src/terminal/snapshot/testdata/page-v1.hex @@ -0,0 +1,21 @@ +# Ghostty snapshot fixture +# 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..339246dab --- /dev/null +++ b/src/terminal/snapshot/testdata/record-page-header-v1.hex @@ -0,0 +1,7 @@ +# Ghostty snapshot fixture +# 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..3b8f9ef92 --- /dev/null +++ b/src/terminal/snapshot/testdata/screen-header-v1.hex @@ -0,0 +1,9 @@ +# Ghostty snapshot fixture +# 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 05 04 07 06 03 19 00 00 00 00 01 7f # 0x00000000 +00 00 02 12 34 56 ff 03 00 00 0d 0c 0b 0a e4 3d # 0x00000010 +02 07 01 02 04 08 10 1f 00 11 02 03 01 # 0x00000020 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..62470761c --- /dev/null +++ b/src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# 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..94e381cda --- /dev/null +++ b/src/terminal/snapshot/testdata/style-v1.hex @@ -0,0 +1,7 @@ +# Ghostty snapshot fixture +# 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..690891c65 --- /dev/null +++ b/src/terminal/snapshot/testdata/terminal-header-v1.hex @@ -0,0 +1,13 @@ +# Ghostty snapshot fixture +# 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 From 627f3430975db22582b88a7f528518d58bdff188 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Jul 2026 15:28:17 -0700 Subject: [PATCH 20/35] build: helpgen needs terminal options --- src/terminal/cursor.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminal/cursor.zig b/src/terminal/cursor.zig index 8495f3043..f5f66075a 100644 --- a/src/terminal/cursor.zig +++ b/src/terminal/cursor.zig @@ -1,4 +1,4 @@ -const lib = @import("lib.zig"); +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 @@ -6,7 +6,7 @@ const lib = @import("lib.zig"); /// /// 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(lib.target, &.{ +pub const Style = lib.Enum(.zig, &.{ // DECSCUSR 5, 6 "bar", From 13bc78b7f33536351fef16b2e37992b845215679 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Jul 2026 20:16:46 -0700 Subject: [PATCH 21/35] terminal/snapshot: grid tests --- src/terminal/snapshot/checkpoint.zig | 57 ++++-- src/terminal/snapshot/grid.zig | 162 ++++++++++++++++++ src/terminal/snapshot/page.zig | 34 ---- .../snapshot/testdata/checkpoint-ready-v1.hex | 14 ++ src/terminal/snapshot/testdata/grid-v1.hex | 29 ++++ 5 files changed, 248 insertions(+), 48 deletions(-) create mode 100644 src/terminal/snapshot/testdata/checkpoint-ready-v1.hex create mode 100644 src/terminal/snapshot/testdata/grid-v1.hex diff --git a/src/terminal/snapshot/checkpoint.zig b/src/terminal/snapshot/checkpoint.zig index 3eca93f82..7d8f0f097 100644 --- a/src/terminal/snapshot/checkpoint.zig +++ b/src/terminal/snapshot/checkpoint.zig @@ -24,6 +24,7 @@ //! ``` const std = @import("std"); +const test_fixture = @import("fixture.zig"); const record = @import("record.zig"); const Blake3 = std.crypto.hash.Blake3; @@ -110,27 +111,55 @@ pub fn decode( } } -test "checkpoint BLAKE3-256 registry" { - const expected = [_]u8{ - 0x64, 0x37, 0xb3, 0xac, 0x38, 0x46, 0x51, 0x33, - 0xff, 0xb6, 0x3b, 0x75, 0x27, 0x3a, 0x8d, 0xb5, - 0x48, 0xc5, 0x58, 0x46, 0x5d, 0x79, 0xdb, 0x03, - 0xfd, 0x35, 0x9c, 0x6c, 0xd5, 0xbd, 0x9d, 0x85, - }; - var actual: Digest = undefined; - Blake3.hash("abc", &actual, .{}); - try std.testing.expectEqual(expected, actual); +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(); + try snapshot.writer.writeAll(prefix); + try encode(.ready, &snapshot); + + 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 = undefined; - Blake3.hash("a", &prefix, .{}); + var prefix_digest: Digest = undefined; + Blake3.hash("a", &prefix_digest, .{}); hasher.final(&actual); - try std.testing.expectEqual(prefix, actual); + try std.testing.expectEqual(prefix_digest, actual); hasher.update("bc"); hasher.final(&actual); - try std.testing.expectEqual(expected, 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[prefix.len..]); + try decode(.ready, actual, &source); } test "READY and FINISH checkpoint coverage" { diff --git a/src/terminal/snapshot/grid.zig b/src/terminal/snapshot/grid.zig index f9aa1d2b1..4d598aa46 100644 --- a/src/terminal/snapshot/grid.zig +++ b/src/terminal/snapshot/grid.zig @@ -105,6 +105,7 @@ //! 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"); @@ -543,3 +544,164 @@ const CellHeader = struct { _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(), + ); +} diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 4466f0663..2c6c1b4d6 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -764,13 +764,6 @@ test "framed PAGE encode and decode a sparse native page" { try std.testing.expectEqual(null, hyperlink_it.next()); const decoded_first = decoded.getRowAndCell(0, 0); - try std.testing.expectEqual(@as(u21, 'A'), decoded_first.cell.codepoint()); - try std.testing.expectEqual(TerminalCell.Wide.wide, decoded_first.cell.wide); - try std.testing.expect(decoded_first.cell.protected); - try std.testing.expectEqual( - TerminalCell.SemanticContent.prompt, - decoded_first.cell.semantic_content, - ); try std.testing.expectEqual(@as(TerminalStyleId, 1), decoded_first.cell.style_id); try std.testing.expectEqual( @as(TerminalHyperlinkId, 1), @@ -778,39 +771,12 @@ test "framed PAGE encode and decode a sparse native page" { ); const decoded_second = decoded.getRowAndCell(1, 0); - try std.testing.expectEqual(TerminalCell.Wide.spacer_tail, decoded_second.cell.wide); try std.testing.expectEqual(@as(TerminalStyleId, 2), decoded_second.cell.style_id); try std.testing.expectEqual( @as(TerminalHyperlinkId, 2), decoded.lookupHyperlink(decoded_second.cell).?, ); - const decoded_grapheme = decoded.getRowAndCell(0, 1); - try std.testing.expectEqualSlices( - u21, - &.{ 0x0301, 0x0302 }, - decoded.lookupGrapheme(decoded_grapheme.cell).?, - ); - - const decoded_rgb = decoded.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, - ); - - const decoded_spacer_head = decoded.getRowAndCell(2, 1); - try std.testing.expectEqual( - TerminalCell.Wide.spacer_head, - decoded_spacer_head.cell.wide, - ); - try std.testing.expect(decoded_spacer_head.row.wrap); - try std.testing.expect(decoded_spacer_head.row.wrap_continuation); - try std.testing.expectEqual( - TerminalRow.SemanticPrompt.prompt_continuation, - decoded_spacer_head.row.semantic_prompt, - ); - // The first re-encode reflects compacted IDs; subsequent round trips must // stabilize byte-for-byte. var reencoded: [512]u8 = undefined; 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..b3a24104b --- /dev/null +++ b/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex @@ -0,0 +1,14 @@ +# Ghostty snapshot fixture +# 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/grid-v1.hex b/src/terminal/snapshot/testdata/grid-v1.hex new file mode 100644 index 000000000..83a668326 --- /dev/null +++ b/src/terminal/snapshot/testdata/grid-v1.hex @@ -0,0 +1,29 @@ +# Ghostty snapshot fixture +# 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 From 38d92c50c9c8c19f543a13c126aa8710f9f6e856 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Jul 2026 21:02:19 -0700 Subject: [PATCH 22/35] terminal/snapshot: kaitai verification Describe the complete version 1 snapshot format with a Kaitai schema and make every golden fixture self-describing for automatic discovery. Add a verifier that compiles the schema, parses all fixtures, and checks record checksums, checkpoint digests, and cross-record invariants. Preserve Kaitai metadata when generating fixture candidates and provide the compiler and Python runtime dependencies through the development shell. Keep the mode registry portable to Kaitai JavaScript targets so the complete fixture also works in the web IDE. --- nix/devShell.nix | 16 +- src/terminal/snapshot/envelope.zig | 4 +- src/terminal/snapshot/fixture.zig | 71 +- src/terminal/snapshot/history.zig | 12 +- src/terminal/snapshot/hyperlink.zig | 9 +- src/terminal/snapshot/page.zig | 14 +- src/terminal/snapshot/screen.zig | 9 +- src/terminal/snapshot/snapshot.ksy | 998 ++++++++++++++++++ src/terminal/snapshot/style.zig | 4 +- .../snapshot/testdata/checkpoint-ready-v1.hex | 3 + .../snapshot/testdata/complete-v1.hex | 3 + .../snapshot/testdata/envelope-v1.hex | 3 + src/terminal/snapshot/testdata/grid-v1.hex | 3 + .../snapshot/testdata/history-header-v1.hex | 3 + .../testdata/hyperlink-explicit-v1.hex | 3 + .../testdata/hyperlink-implicit-v1.hex | 3 + .../testdata/page-empty-record-v1.hex | 3 + .../snapshot/testdata/page-header-v1.hex | 3 + src/terminal/snapshot/testdata/page-v1.hex | 3 + .../testdata/record-page-header-v1.hex | 3 + .../snapshot/testdata/screen-header-v1.hex | 3 + .../testdata/screen-saved-cursor-v1.hex | 3 + src/terminal/snapshot/testdata/style-v1.hex | 3 + .../snapshot/testdata/terminal-header-v1.hex | 3 + src/terminal/snapshot/verify-kaitai.py | 422 ++++++++ 25 files changed, 1553 insertions(+), 51 deletions(-) create mode 100644 src/terminal/snapshot/snapshot.ksy create mode 100755 src/terminal/snapshot/verify-kaitai.py diff --git a/nix/devShell.nix b/nix/devShell.nix index 6fa1f14b1..5401ba0d1 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -59,6 +59,7 @@ zlib, alejandra, jq, + kaitai-struct-compiler, minisign, pandoc, pinact, @@ -85,6 +86,11 @@ gi_typelib_path = import ./build-support/gi-typelib-path.nix { inherit pkgs lib stdenv; }; + python = python3.withPackages (python-pkgs: [ + python-pkgs.blake3 + python-pkgs.kaitaistruct + python-pkgs.ucs-detect + ]); in mkShell { name = "ghostty"; @@ -116,9 +122,10 @@ in # Testing parallel - python3 + python vttest hyperfine + kaitai-struct-compiler # wasm wabt @@ -138,11 +145,6 @@ in blueprint-compiler libadwaita gtk4 - - # Python packages - (python3.withPackages (python-pkgs: [ - python-pkgs.ucs-detect - ])) ] ++ lib.optionals stdenv.hostPlatform.isLinux [ # My nix shell environment installs the non-interactive version @@ -242,6 +244,6 @@ in # We need to remove "xcrun" from the PATH. It is injected by # some dependency but we need to rely on system Xcode tools export PATH=$(echo "$PATH" | awk -v RS=: -v ORS=: '$0 !~ /xcrun/ || $0 == "/usr/bin" {print}' | sed 's/:$//') - export PATH="/opt/homebrew/opt/llvm/bin:/opt/homebrew/bin:/usr/local/opt/llvm/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" + export PATH="${python}/bin:/opt/homebrew/opt/llvm/bin:/opt/homebrew/bin:/usr/local/opt/llvm/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" ''); } diff --git a/src/terminal/snapshot/envelope.zig b/src/terminal/snapshot/envelope.zig index 9963ced2a..21fcefb9d 100644 --- a/src/terminal/snapshot/envelope.zig +++ b/src/terminal/snapshot/envelope.zig @@ -66,9 +66,7 @@ fn computeLen() usize { } } -const test_golden_fixture = test_fixture.parse( - @embedFile("testdata/envelope-v1.hex"), -); +const test_golden_fixture = test_fixture.parse(@embedFile("testdata/envelope-v1.hex")); test "golden encoding" { var buf: [encoded_len]u8 = undefined; diff --git a/src/terminal/snapshot/fixture.zig b/src/terminal/snapshot/fixture.zig index 6d256ac3d..d5c86b2b7 100644 --- a/src/terminal/snapshot/fixture.zig +++ b/src/terminal/snapshot/fixture.zig @@ -49,6 +49,23 @@ //! ); //! } //! ``` +//! +//! ## Kaitai +//! +//! Every fixture has three machine-readable comment fields: +//! +//! ```text +//! # Kaitai type: page_payload +//! # Kaitai params: +//! # Kaitai offset: 0 +//! ``` +//! +//! These select a type from `snapshot.ksy`, its whitespace-separated +//! parameters, and the byte offset where parsing begins. The Kaitai verifier +//! discovers fixtures from the filesystem and uses this metadata instead of +//! maintaining a parallel filename table. Generated candidates copy the +//! metadata from their checked-in reference. +//! const std = @import("std"); const envelope = @import("envelope.zig"); @@ -128,6 +145,17 @@ pub fn expectEqual( var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); + // Kaitai parser selection lives in the reference itself. Read the textual + // source so generated candidates preserve that metadata even though the + // byte comparison below uses the comptime-decoded reference. + const reference_source = try std.Io.Dir.cwd().readFileAlloc( + testing.io, + reference_path, + alloc, + .unlimited, + ); + defer alloc.free(reference_source); + // Always materialize a candidate, even when it will match. This keeps the // generation path exercised on every test run while avoiding repository // artifacts for the normal successful case. @@ -143,7 +171,12 @@ pub fn expectEqual( testing.io, &write_buffer, ); - try format(kind, actual, &candidate_writer.interface); + try format( + kind, + reference_source, + actual, + &candidate_writer.interface, + ); try candidate_writer.interface.flush(); } @@ -267,14 +300,16 @@ fn hexNibble(comptime value: u8) u8 { fn format( kind: Kind, + reference_source: []const u8, bytes: []const u8, writer: *std.Io.Writer, -) std.Io.Writer.Error!void { +) (std.Io.Writer.Error || error{InvalidKaitaiMetadata})!void { // Every generated candidate starts with enough maintenance context to be // understandable when copied out of a failing test's temp directory. + try writer.writeAll("# Ghostty snapshot fixture\n"); + try writeKaitaiMetadata(reference_source, writer); try writer.print( - "# Ghostty snapshot fixture\n" ++ - "# Wire version: {}\n" ++ + "# Wire version: {}\n" ++ "# Generated by its snapshot test; review before replacing.\n" ++ "# On mismatch, the candidate is copied to the repository root.\n", .{envelope.version}, @@ -295,6 +330,34 @@ fn format( } } +fn writeKaitaiMetadata( + reference_source: []const u8, + writer: *std.Io.Writer, +) (std.Io.Writer.Error || error{InvalidKaitaiMetadata})!void { + const prefixes = [_][]const u8{ + "# Kaitai type:", + "# Kaitai params:", + "# Kaitai offset:", + }; + var found = [_]bool{false} ** prefixes.len; + + var lines = std.mem.splitScalar(u8, reference_source, '\n'); + while (lines.next()) |untrimmed| { + const line = std.mem.trim(u8, untrimmed, " \r"); + for (prefixes, &found) |prefix, *seen| { + if (!std.mem.startsWith(u8, line, prefix)) continue; + if (seen.*) return error.InvalidKaitaiMetadata; + seen.* = true; + try writer.print("{s}\n", .{line}); + break; + } + } + + for (found) |seen| { + if (!seen) return error.InvalidKaitaiMetadata; + } +} + fn formatPage( bytes: []const u8, writer: *std.Io.Writer, diff --git a/src/terminal/snapshot/history.zig b/src/terminal/snapshot/history.zig index 8422c4f73..d4ea5a60d 100644 --- a/src/terminal/snapshot/history.zig +++ b/src/terminal/snapshot/history.zig @@ -288,9 +288,7 @@ pub fn decode( // the destination PageList to allocate the final backing memory once. var decoder: page.Decoder = undefined; try decoder.init(source); - var allocation = try terminal_screen.pages.allocatePage( - decoder.capacity(), - ); + var allocation = try terminal_screen.pages.allocatePage(decoder.capacity()); defer allocation.deinit(); try decoder.decode(allocation.page(), alloc); @@ -319,9 +317,7 @@ pub fn decode( } fn hasSemanticPrompt(terminal_page: *const TerminalPage) bool { - const rows = terminal_page.rows.ptr( - terminal_page.memory, - )[0..terminal_page.size.rows]; + const rows = terminal_page.rows.ptr(terminal_page.memory)[0..terminal_page.size.rows]; for (rows) |row| { if (row.semantic_prompt != .none) return true; @@ -335,9 +331,7 @@ fn hasSemanticPrompt(terminal_page: *const TerminalPage) bool { return false; } -const test_header_fixture = test_fixture.parse( - @embedFile("testdata/history-header-v1.hex"), -); +const test_header_fixture = test_fixture.parse(@embedFile("testdata/history-header-v1.hex")); test "HISTORY header golden encoding and decoding" { const expected: Header = .{ diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig index 6baade906..ec3b14efe 100644 --- a/src/terminal/snapshot/hyperlink.zig +++ b/src/terminal/snapshot/hyperlink.zig @@ -271,13 +271,8 @@ fn decodePageString( }; } -const test_implicit_fixture = test_fixture.parse( - @embedFile("testdata/hyperlink-implicit-v1.hex"), -); - -const test_explicit_fixture = test_fixture.parse( - @embedFile("testdata/hyperlink-explicit-v1.hex"), -); +const test_implicit_fixture = test_fixture.parse(@embedFile("testdata/hyperlink-implicit-v1.hex")); +const test_explicit_fixture = test_fixture.parse(@embedFile("testdata/hyperlink-explicit-v1.hex")); test "golden implicit encoding" { const value: terminal_hyperlink.Hyperlink = .{ diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 2c6c1b4d6..05ad3c03a 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -547,17 +547,9 @@ fn pageHyperlink( }; } -const test_page_fixture = test_fixture.parse( - @embedFile("testdata/page-v1.hex"), -); - -const test_header_fixture = test_fixture.parse( - @embedFile("testdata/page-header-v1.hex"), -); - -const test_empty_framed_page_fixture = test_fixture.parse( - @embedFile("testdata/page-empty-record-v1.hex"), -); +const test_page_fixture = test_fixture.parse(@embedFile("testdata/page-v1.hex")); +const test_header_fixture = test_fixture.parse(@embedFile("testdata/page-header-v1.hex")); +const test_empty_framed_page_fixture = test_fixture.parse(@embedFile("testdata/page-empty-record-v1.hex")); test "PAGE header golden encoding and decoding" { const header: Header = .{ diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index e9de03c66..505c9c5ff 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -1117,13 +1117,8 @@ pub fn decodeCursorHyperlink( return try hyperlink.decode(reader, alloc); } -const test_header_fixture = test_fixture.parse( - @embedFile("testdata/screen-header-v1.hex"), -); - -const test_saved_cursor_fixture = test_fixture.parse( - @embedFile("testdata/screen-saved-cursor-v1.hex"), -); +const test_header_fixture = test_fixture.parse(@embedFile("testdata/screen-header-v1.hex")); +const test_saved_cursor_fixture = test_fixture.parse(@embedFile("testdata/screen-saved-cursor-v1.hex")); fn testCharsetState() TerminalScreen.CharsetState { var result: TerminalScreen.CharsetState = .{ diff --git a/src/terminal/snapshot/snapshot.ksy b/src/terminal/snapshot/snapshot.ksy new file mode 100644 index 000000000..a800b2782 --- /dev/null +++ b/src/terminal/snapshot/snapshot.ksy @@ -0,0 +1,998 @@ +meta: + id: ghostty_snapshot + title: Ghostty terminal snapshot + application: Ghostty + license: MIT + endian: le +doc: | + Ghostty terminal snapshot format version 1. + + A complete snapshot contains an envelope, terminal-wide state, one or two + renderable screen sequences, a READY checkpoint, matching history sequences, + and a FINISH checkpoint. SCREEN pages are oldest-to-newest. HISTORY pages are + newest-to-oldest. + + Record CRC32C values and checkpoint BLAKE3-256 digests are represented here + but cannot be calculated by portable Kaitai Struct expressions. The adjacent + verify-kaitai.py script validates those values after parsing. +seq: + - id: envelope + type: envelope + - id: terminal + type: terminal_record + - id: screens + type: screen_sequence + repeat: expr + repeat-expr: terminal.payload.header.screen_count + - id: ready + type: checkpoint_record(5) + - id: histories + type: history_sequence + repeat: expr + repeat-expr: terminal.payload.header.screen_count + - id: finish + type: checkpoint_record(6) + - id: trailing_data + size-eos: true + valid: + expr: _.size == 0 +enums: + record_tag: + 1: terminal + 2: screen + 3: page + 4: history + 5: ready + 6: finish + screen_key: + 0: primary + 1: alternate + cursor_style: + 0: bar + 1: block + 2: underline + 3: block_hollow + status_display: + 0: main + 1: status_line + shell_redraw: + 0: full + 1: none + 2: last + mouse_event: + 0: none + 1: x10 + 2: normal + 3: button + 4: any + mouse_format: + 0: x10 + 1: utf8 + 2: sgr + 3: urxvt + 4: sgr_pixels + mouse_shape: + 0: default + 1: context_menu + 2: help + 3: pointer + 4: progress + 5: wait + 6: cell + 7: crosshair + 8: text + 9: vertical_text + 10: alias + 11: copy + 12: move + 13: no_drop + 14: not_allowed + 15: grab + 16: grabbing + 17: all_scroll + 18: col_resize + 19: row_resize + 20: n_resize + 21: e_resize + 22: s_resize + 23: w_resize + 24: ne_resize + 25: nw_resize + 26: se_resize + 27: sw_resize + 28: ew_resize + 29: ns_resize + 30: nesw_resize + 31: nwse_resize + 32: zoom_in + 33: zoom_out + protected_mode: + 0: "off" + 1: iso + 2: dec + semantic_click_kind: + 0: none + 1: click_events + 2: cl + color_kind: + 0: none + 1: palette + 2: rgb + hyperlink_kind: + 0: none + 1: implicit + 2: explicit + cell_content_kind: + 0: codepoint + 1: background_palette + 2: background_rgb + cell_width: + 0: narrow + 1: wide + 2: spacer_tail + 3: spacer_head + underline: + 0: none + 1: single + 2: double + 3: curly + 4: dotted + 5: dashed + semantic_content: + 0: output + 1: input + 2: prompt + semantic_prompt: + 0: none + 1: prompt + 2: prompt_continuation +types: + envelope: + doc: Fixed ten-byte snapshot identification and version header. + seq: + - id: magic + contents: [0x47, 0x48, 0x4f, 0x53, 0x54, 0x53, 0x4e, 0x50] + - id: version + type: u2 + valid: 1 + + record_header: + doc: | + Common record framing. crc32c covers the encoded tag, payload length, + and payload bytes, excluding the crc32c field itself. + params: + - id: expected_tag + type: u2 + seq: + - id: tag + type: u2 + valid: expected_tag + - id: payload_length + type: u4 + - id: crc32c + type: u4 + + terminal_record: + seq: + - id: header + type: record_header(1) + - id: payload + type: terminal_payload + size: header.payload_length + + screen_record: + seq: + - id: header + type: record_header(2) + - id: payload + type: screen_payload + size: header.payload_length + + page_record: + seq: + - id: header + type: record_header(3) + - id: payload + type: page_payload + size: header.payload_length + + history_record: + seq: + - id: header + type: record_header(4) + - id: payload + type: history_payload + size: header.payload_length + + checkpoint_record: + params: + - id: expected_tag + type: u2 + seq: + - id: header + type: record_header(expected_tag) + - id: payload + type: checkpoint_payload + size: header.payload_length + + screen_sequence: + doc: SCREEN followed by its declared PAGE records, oldest-to-newest. + seq: + - id: screen + type: screen_record + - id: pages + type: page_record + repeat: expr + repeat-expr: screen.payload.header.page_count + + history_sequence: + doc: HISTORY followed by its declared PAGE records, newest-to-oldest. + seq: + - id: history + type: history_record + - id: pages + type: page_record + repeat: expr + repeat-expr: history.payload.page_count + + checkpoint_payload: + seq: + - id: prefix_digest + size: 32 + - id: trailing_data + size-eos: true + valid: + expr: _.size == 0 + + terminal_payload: + seq: + - id: header + type: terminal_header + - id: tab_stops + type: tab_stops(header.columns) + - id: original_palette + type: rgb + repeat: expr + repeat-expr: 256 + - id: palette_override_mask + size: 32 + - id: palette_overrides + type: palette_override(palette_override_mask, _index) + repeat: expr + repeat-expr: 256 + - id: len_pwd + type: u4 + - id: pwd + size: len_pwd + - id: len_title + type: u4 + - id: title + size: len_title + - id: trailing_data + size-eos: true + valid: + expr: _.size == 0 + + terminal_header: + seq: + - id: columns + type: u2 + valid: + min: 1 + - id: rows + type: u2 + valid: + min: 1 + - id: width_px + type: u4 + - id: height_px + type: u4 + - id: scrolling_region_top + type: u2 + - id: scrolling_region_bottom + type: u2 + valid: + expr: _ >= scrolling_region_top and _ < rows + - id: scrolling_region_left + type: u2 + - id: scrolling_region_right + type: u2 + valid: + expr: _ >= scrolling_region_left and _ < columns + - id: status_display + type: u1 + enum: status_display + valid: + expr: _.to_i <= 1 + - id: active_screen_key + type: u2 + enum: screen_key + valid: + expr: _.to_i <= 1 + - id: screen_count + type: u2 + valid: + expr: (_ == 1 or _ == 2) and (active_screen_key.to_i == 0 or _ == 2) + - id: previous_codepoint + type: u4 + valid: + expr: _ == 0xffffffff or (_ <= 0x10ffff and not (_ >= 0xd800 and _ <= 0xdfff)) + - id: cursor_is_default + type: u1 + valid: + max: 1 + - id: cursor_default_style + type: u1 + enum: cursor_style + valid: + expr: _.to_i <= 3 + - id: cursor_default_blink + type: u1 + valid: + max: 2 + - id: shell_redraw + type: u1 + enum: shell_redraw + valid: + expr: _.to_i <= 2 + - id: modify_other_keys_2 + type: u1 + valid: + max: 1 + - id: mouse_event + type: u1 + enum: mouse_event + valid: + expr: _.to_i <= 4 + - id: mouse_format + type: u1 + enum: mouse_format + valid: + expr: _.to_i <= 4 + - id: mouse_shift_capture + type: u1 + valid: + max: 2 + - id: mouse_shape + type: u1 + enum: mouse_shape + valid: + expr: _.to_i <= 33 + - id: password_input + type: u1 + valid: + max: 1 + - id: current_modes + type: mode_set + - id: saved_modes + type: mode_set + - id: default_modes + type: mode_set + - id: background + type: dynamic_rgb + - id: foreground + type: dynamic_rgb + - id: cursor_color + type: dynamic_rgb + - id: max_scrollback_bytes + type: u8 + - id: max_scrollback_rows + type: u8 + + mode_set: + doc: | + The stable packed registry shared by current, saved, and default modes. + Each named instance exposes one bit from the little-endian integer. + Arithmetic division is used instead of bitwise operations because the + JavaScript target implements those operations with signed 32-bit values. + All values remain exact because the registry occupies only 42 bits, + within JavaScript's 53-bit safe integer range. + seq: + - id: raw + type: u8 + valid: + max: 4398046511103 + instances: + disable_keyboard: + value: (raw / 1) % 2 != 0 + insert: + value: (raw / 2) % 2 != 0 + send_receive_mode: + value: (raw / 4) % 2 != 0 + linefeed: + value: (raw / 8) % 2 != 0 + cursor_keys: + value: (raw / 16) % 2 != 0 + column_132: + value: (raw / 32) % 2 != 0 + slow_scroll: + value: (raw / 64) % 2 != 0 + reverse_colors: + value: (raw / 128) % 2 != 0 + origin: + value: (raw / 256) % 2 != 0 + wraparound: + value: (raw / 512) % 2 != 0 + autorepeat: + value: (raw / 1024) % 2 != 0 + mouse_event_x10: + value: (raw / 2048) % 2 != 0 + cursor_blinking: + value: (raw / 4096) % 2 != 0 + cursor_visible: + value: (raw / 8192) % 2 != 0 + enable_mode_3: + value: (raw / 16384) % 2 != 0 + reverse_wrap: + value: (raw / 32768) % 2 != 0 + alt_screen_legacy: + value: (raw / 65536) % 2 != 0 + keypad_keys: + value: (raw / 131072) % 2 != 0 + backarrow_key_mode: + value: (raw / 262144) % 2 != 0 + enable_left_and_right_margin: + value: (raw / 524288) % 2 != 0 + mouse_event_normal: + value: (raw / 1048576) % 2 != 0 + mouse_event_button: + value: (raw / 2097152) % 2 != 0 + mouse_event_any: + value: (raw / 4194304) % 2 != 0 + focus_event: + value: (raw / 8388608) % 2 != 0 + mouse_format_utf8: + value: (raw / 16777216) % 2 != 0 + mouse_format_sgr: + value: (raw / 33554432) % 2 != 0 + mouse_alternate_scroll: + value: (raw / 67108864) % 2 != 0 + mouse_format_urxvt: + value: (raw / 134217728) % 2 != 0 + mouse_format_sgr_pixels: + value: (raw / 268435456) % 2 != 0 + ignore_keypad_with_numlock: + value: (raw / 536870912) % 2 != 0 + alt_esc_prefix: + value: (raw / 1073741824) % 2 != 0 + alt_sends_escape: + value: (raw / 2147483648) % 2 != 0 + reverse_wrap_extended: + value: (raw / 4294967296) % 2 != 0 + alt_screen: + value: (raw / 8589934592) % 2 != 0 + save_cursor: + value: (raw / 17179869184) % 2 != 0 + alt_screen_save_cursor_clear_enter: + value: (raw / 34359738368) % 2 != 0 + bracketed_paste: + value: (raw / 68719476736) % 2 != 0 + synchronized_output: + value: (raw / 137438953472) % 2 != 0 + grapheme_cluster: + value: (raw / 274877906944) % 2 != 0 + report_color_scheme: + value: (raw / 549755813888) % 2 != 0 + report_visibility: + value: (raw / 1099511627776) % 2 != 0 + in_band_size_reports: + value: (raw / 2199023255552) % 2 != 0 + + tab_stops: + params: + - id: columns + type: u2 + seq: + - id: complete_bytes + size: columns / 8 + - id: partial_byte + type: u1 + if: columns % 8 != 0 + valid: + expr: _ < (1 << (columns % 8)) + + rgb: + seq: + - id: red + type: u1 + - id: green + type: u1 + - id: blue + type: u1 + + dynamic_rgb: + seq: + - id: default_present + type: u1 + valid: + max: 1 + - id: default_red + type: u1 + valid: + expr: default_present == 1 or _ == 0 + - id: default_green + type: u1 + valid: + expr: default_present == 1 or _ == 0 + - id: default_blue + type: u1 + valid: + expr: default_present == 1 or _ == 0 + - id: override_present + type: u1 + valid: + max: 1 + - id: override_red + type: u1 + valid: + expr: override_present == 1 or _ == 0 + - id: override_green + type: u1 + valid: + expr: override_present == 1 or _ == 0 + - id: override_blue + type: u1 + valid: + expr: override_present == 1 or _ == 0 + + palette_override: + params: + - id: mask + type: bytes + - id: index + type: u2 + seq: + - id: color + type: rgb + if: (mask[index / 8] & (1 << (index % 8))) != 0 + + screen_payload: + seq: + - id: header + type: screen_header + - id: saved_cursor + type: saved_cursor + if: header.saved_cursor_present == 1 + - id: cursor_hyperlink + type: hyperlink(true, false) + - id: trailing_data + size-eos: true + valid: + expr: _.size == 0 + + screen_header: + seq: + - id: key + type: u2 + enum: screen_key + valid: + expr: _.to_i <= 1 + - id: page_count + type: u2 + valid: + min: 1 + - id: cursor_x + type: u2 + - id: cursor_y + type: u2 + - id: cursor_style + type: u1 + enum: cursor_style + valid: + expr: _.to_i <= 3 + - id: cursor_flags + type: cursor_flags + - id: cursor_pen + type: style + - id: hyperlink_implicit_id + type: u4 + - id: charset + type: charset_state + - id: protected_mode + type: u1 + enum: protected_mode + valid: + expr: _.to_i <= 2 + - id: kitty_keyboard_index + type: u1 + valid: + max: 7 + - id: kitty_keyboard_flags + type: kitty_keyboard_flags + repeat: expr + repeat-expr: 8 + - id: semantic_click_kind + type: u1 + enum: semantic_click_kind + valid: + expr: _.to_i <= 2 + - id: semantic_click_value + type: u1 + valid: + expr: | + semantic_click_kind.to_i == 0 ? _ == 0 : + semantic_click_kind.to_i == 1 ? _ <= 1 : + _ <= 3 + - id: saved_cursor_present + type: u1 + valid: + max: 1 + + cursor_flags: + seq: + - id: raw + type: u1 + valid: + expr: (_ & 0xe0) == 0 and ((_ >> 2) & 0x3) <= 2 + instances: + pending_wrap: + value: (raw & (1 << 0)) != 0 + protected: + value: (raw & (1 << 1)) != 0 + semantic_content: + value: (raw >> 2) & 0x3 + semantic_content_clear_eol: + value: (raw & (1 << 4)) != 0 + + charset_state: + doc: | + Four selected character sets, the GL and GR slots, and an optional + single-shift slot. single_shift is zero for none or one plus a slot. + seq: + - id: raw + type: u2 + valid: + expr: (_ & 0x8000) == 0 and ((_ >> 12) & 0x7) <= 4 + instances: + g0: + value: (raw >> 0) & 0x3 + g1: + value: (raw >> 2) & 0x3 + g2: + value: (raw >> 4) & 0x3 + g3: + value: (raw >> 6) & 0x3 + gl: + value: (raw >> 8) & 0x3 + gr: + value: (raw >> 10) & 0x3 + single_shift: + value: (raw >> 12) & 0x7 + + kitty_keyboard_flags: + seq: + - id: raw + type: u1 + valid: + expr: (_ & 0xe0) == 0 + instances: + disambiguate: + value: (raw & (1 << 0)) != 0 + report_events: + value: (raw & (1 << 1)) != 0 + report_alternates: + value: (raw & (1 << 2)) != 0 + report_all: + value: (raw & (1 << 3)) != 0 + report_associated: + value: (raw & (1 << 4)) != 0 + + saved_cursor: + seq: + - id: x + type: u2 + - id: y + type: u2 + - id: pen + type: style + - id: flags + type: saved_cursor_flags + - id: charset + type: charset_state + + saved_cursor_flags: + seq: + - id: raw + type: u1 + valid: + expr: (_ & 0xf8) == 0 + instances: + protected: + value: (raw & (1 << 0)) != 0 + pending_wrap: + value: (raw & (1 << 1)) != 0 + origin: + value: (raw & (1 << 2)) != 0 + + history_payload: + seq: + - id: key + type: u2 + enum: screen_key + valid: + expr: _.to_i <= 1 + - id: page_count + type: u4 + - id: total_rows + type: u8 + - id: screen_overlap_rows + type: u2 + - id: trailing_data + size-eos: true + valid: + expr: _.size == 0 + + page_payload: + seq: + - id: header + type: page_header + - id: styles + type: style_table_entry + repeat: expr + repeat-expr: header.style_count + - id: hyperlinks + type: hyperlink_table_entry + repeat: expr + repeat-expr: header.hyperlink_count + - id: grid + type: grid(header.rows, header.columns) + - id: trailing_data + size-eos: true + valid: + expr: _.size == 0 + + page_header: + seq: + - id: columns + type: u2 + valid: + min: 1 + - id: rows + type: u2 + valid: + min: 1 + - id: style_count + type: u2 + - id: hyperlink_count + type: u2 + - id: style_capacity + type: u2 + - id: hyperlink_capacity_bytes + type: u2 + - id: grapheme_capacity_bytes + type: u4 + - id: string_capacity_bytes + type: u4 + + style_table_entry: + seq: + - id: encoded_id + type: u2 + valid: + min: 1 + - id: value + type: style + + hyperlink_table_entry: + seq: + - id: encoded_id + type: u2 + valid: + min: 1 + - id: value + type: hyperlink(false, true) + + style: + seq: + - id: foreground + type: style_color + - id: background + type: style_color + - id: underline_color + type: style_color + - id: flags + type: style_flags + - id: reserved + type: u2 + valid: 0 + + style_flags: + seq: + - id: raw + type: u2 + valid: + expr: (_ & 0xf800) == 0 and ((_ >> 8) & 0x7) <= 5 + instances: + bold: + value: (raw & (1 << 0)) != 0 + italic: + value: (raw & (1 << 1)) != 0 + faint: + value: (raw & (1 << 2)) != 0 + blink: + value: (raw & (1 << 3)) != 0 + inverse: + value: (raw & (1 << 4)) != 0 + invisible: + value: (raw & (1 << 5)) != 0 + strikethrough: + value: (raw & (1 << 6)) != 0 + overline: + value: (raw & (1 << 7)) != 0 + underline: + value: (raw >> 8) & 0x7 + + style_color: + seq: + - id: kind + type: u1 + enum: color_kind + valid: + expr: _.to_i <= 2 + - id: first + type: u1 + valid: + expr: kind.to_i != 0 or _ == 0 + - id: second + type: u1 + valid: + expr: kind.to_i == 2 or _ == 0 + - id: third + type: u1 + valid: + expr: kind.to_i == 2 or _ == 0 + + hyperlink: + params: + - id: allow_none + type: bool + - id: allow_empty + type: bool + seq: + - id: kind + type: u1 + enum: hyperlink_kind + valid: + expr: _.to_i <= 2 and (allow_none or _.to_i != 0) + - id: implicit + type: implicit_hyperlink(allow_empty) + if: kind.to_i == 1 + - id: explicit + type: explicit_hyperlink(allow_empty) + if: kind.to_i == 2 + + implicit_hyperlink: + params: + - id: allow_empty + type: bool + seq: + - id: id + type: u4 + - id: len_uri + type: u4 + valid: + expr: allow_empty or _ >= 1 + - id: uri + size: len_uri + + explicit_hyperlink: + params: + - id: allow_empty + type: bool + seq: + - id: len_id + type: u4 + valid: + expr: allow_empty or _ >= 1 + - id: id + size: len_id + - id: len_uri + type: u4 + valid: + expr: allow_empty or _ >= 1 + - id: uri + size: len_uri + + grid: + params: + - id: num_rows + type: u2 + - id: columns + type: u2 + seq: + - id: rows + type: grid_row(columns) + repeat: expr + repeat-expr: num_rows + + grid_row: + params: + - id: num_cells + type: u2 + seq: + - id: flags + type: grid_row_flags + - id: cells + type: grid_cell(_index, num_cells, flags.wrap) + repeat: expr + repeat-expr: num_cells + + grid_row_flags: + seq: + - id: raw + type: u1 + valid: + expr: (_ & 0xf0) == 0 and ((_ >> 2) & 0x3) <= 2 + instances: + wrap: + value: (raw & 1) != 0 + wrap_continuation: + value: (raw & 2) != 0 + semantic_prompt: + value: (raw >> 2) & 0x3 + + grid_cell: + params: + - id: index + type: u2 + - id: columns + type: u2 + - id: row_wrap + type: bool + seq: + - id: content_kind + type: u1 + valid: + max: 2 + - id: width + type: u1 + valid: + expr: | + _ <= 3 and + (_ != 2 or + (index > 0 and _parent.cells[index - 1].width == 1)) and + (_ != 3 or + (index + 1 == columns and row_wrap)) + - id: flags + type: grid_cell_flags + - id: reserved + type: u1 + valid: 0 + - id: style_id + type: u2 + - id: hyperlink_id + type: u2 + - id: value + type: u4 + valid: + expr: | + content_kind == 0 ? + (_ <= 0x10ffff and not (_ >= 0xd800 and _ <= 0xdfff) and _ != 0x10eeee) : + content_kind == 1 ? + _ <= 0xff : + _ <= 0xffffff + - id: num_graphemes + type: u4 + valid: + expr: | + content_kind == 0 ? + (_ == 0 or value != 0) : + _ == 0 + - id: graphemes + type: u4 + repeat: expr + repeat-expr: num_graphemes + valid: + expr: _ <= 0x10ffff and not (_ >= 0xd800 and _ <= 0xdfff) and _ != 0x10eeee + + grid_cell_flags: + seq: + - id: raw + type: u1 + valid: + expr: (_ & 0xf8) == 0 and ((_ >> 1) & 0x3) <= 2 + instances: + protected: + value: (raw & (1 << 0)) != 0 + semantic_content: + value: (raw >> 1) & 0x3 diff --git a/src/terminal/snapshot/style.zig b/src/terminal/snapshot/style.zig index 268c29289..69e0973c2 100644 --- a/src/terminal/snapshot/style.zig +++ b/src/terminal/snapshot/style.zig @@ -216,9 +216,7 @@ fn computeLen() usize { } } -const test_golden_fixture = test_fixture.parse( - @embedFile("testdata/style-v1.hex"), -); +const test_golden_fixture = test_fixture.parse(@embedFile("testdata/style-v1.hex")); test "golden encoding and decoding" { const value: terminal_style.Style = .{ diff --git a/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex b/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex index b3a24104b..51381196c 100644 --- a/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex +++ b/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: checkpoint_record +# Kaitai params: 5 +# Kaitai offset: 3 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/complete-v1.hex b/src/terminal/snapshot/testdata/complete-v1.hex index 8982c2add..01a72f1ea 100644 --- a/src/terminal/snapshot/testdata/complete-v1.hex +++ b/src/terminal/snapshot/testdata/complete-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: ghostty_snapshot +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/envelope-v1.hex b/src/terminal/snapshot/testdata/envelope-v1.hex index 3e4ddadfa..04a7eb6d5 100644 --- a/src/terminal/snapshot/testdata/envelope-v1.hex +++ b/src/terminal/snapshot/testdata/envelope-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: envelope +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/grid-v1.hex b/src/terminal/snapshot/testdata/grid-v1.hex index 83a668326..3389a876f 100644 --- a/src/terminal/snapshot/testdata/grid-v1.hex +++ b/src/terminal/snapshot/testdata/grid-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: grid +# Kaitai params: 2 3 +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/history-header-v1.hex b/src/terminal/snapshot/testdata/history-header-v1.hex index 7949ca620..6e44379dc 100644 --- a/src/terminal/snapshot/testdata/history-header-v1.hex +++ b/src/terminal/snapshot/testdata/history-header-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: history_payload +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex b/src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex index 6f925099a..7643b203a 100644 --- a/src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex +++ b/src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: hyperlink +# Kaitai params: false false +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex b/src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex index 8ae474676..cb15cf123 100644 --- a/src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex +++ b/src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: hyperlink +# Kaitai params: false false +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/page-empty-record-v1.hex b/src/terminal/snapshot/testdata/page-empty-record-v1.hex index 06db7cc54..4b759b704 100644 --- a/src/terminal/snapshot/testdata/page-empty-record-v1.hex +++ b/src/terminal/snapshot/testdata/page-empty-record-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: page_record +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/page-header-v1.hex b/src/terminal/snapshot/testdata/page-header-v1.hex index 811ad80fc..b7b00765f 100644 --- a/src/terminal/snapshot/testdata/page-header-v1.hex +++ b/src/terminal/snapshot/testdata/page-header-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: page_header +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/page-v1.hex b/src/terminal/snapshot/testdata/page-v1.hex index d3c04d0c4..dfc53eeaa 100644 --- a/src/terminal/snapshot/testdata/page-v1.hex +++ b/src/terminal/snapshot/testdata/page-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: page_payload +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/record-page-header-v1.hex b/src/terminal/snapshot/testdata/record-page-header-v1.hex index 339246dab..28e6269ae 100644 --- a/src/terminal/snapshot/testdata/record-page-header-v1.hex +++ b/src/terminal/snapshot/testdata/record-page-header-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: record_header +# Kaitai params: 3 +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/screen-header-v1.hex b/src/terminal/snapshot/testdata/screen-header-v1.hex index 3b8f9ef92..950d5736b 100644 --- a/src/terminal/snapshot/testdata/screen-header-v1.hex +++ b/src/terminal/snapshot/testdata/screen-header-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: screen_header +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex b/src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex index 62470761c..f4bd7e3ab 100644 --- a/src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex +++ b/src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: saved_cursor +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/style-v1.hex b/src/terminal/snapshot/testdata/style-v1.hex index 94e381cda..0e834f870 100644 --- a/src/terminal/snapshot/testdata/style-v1.hex +++ b/src/terminal/snapshot/testdata/style-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: style +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/testdata/terminal-header-v1.hex b/src/terminal/snapshot/testdata/terminal-header-v1.hex index 690891c65..9c9eb4bfb 100644 --- a/src/terminal/snapshot/testdata/terminal-header-v1.hex +++ b/src/terminal/snapshot/testdata/terminal-header-v1.hex @@ -1,4 +1,7 @@ # Ghostty snapshot fixture +# Kaitai type: terminal_header +# Kaitai params: +# Kaitai offset: 0 # Wire version: 1 # Generated by its snapshot test; review before replacing. # On mismatch, the candidate is copied to the repository root. diff --git a/src/terminal/snapshot/verify-kaitai.py b/src/terminal/snapshot/verify-kaitai.py new file mode 100755 index 000000000..1a88ab41c --- /dev/null +++ b/src/terminal/snapshot/verify-kaitai.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Compile the snapshot Kaitai schema and parse every checked-in fixture. + +Run this from a Ghostty development shell: + + src/terminal/snapshot/verify-kaitai.py + +To turn an annotated fixture into a binary suitable for the Kaitai Web IDE: + + src/terminal/snapshot/verify-kaitai.py \ + --write-binary \ + src/terminal/snapshot/testdata/complete-v1.hex \ + /tmp/complete-v1.bin + +Then load `snapshot.ksy` and the generated binary into +https://ide.kaitai.io/. + +The generated Python parser exists only in a temporary directory. This keeps +snapshot.ksy as the source of truth and ensures this check cannot accidentally +pass against stale generated code. After structural parsing, the script checks +record CRC32C values, checkpoint BLAKE3 digests, and cross-record invariants +that portable Kaitai expressions cannot represent. +""" + +from __future__ import annotations + +import argparse +import importlib +import io +import shutil +import struct +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +try: + from blake3 import blake3 + from kaitaistruct import KaitaiStream +except ImportError as error: + raise SystemExit( + "missing Kaitai verifier dependencies; run this inside " + "`nix develop`" + ) from error + + +SNAPSHOT_DIR = Path(__file__).resolve().parent +SCHEMA_PATH = SNAPSHOT_DIR / "snapshot.ksy" +TESTDATA_DIR = SNAPSHOT_DIR / "testdata" +SNAPSHOT_ENVELOPE_SIZE = 10 +RECORD_HEADER_SIZE = struct.calcsize(" Fixture: + """Load one self-describing annotated hexadecimal fixture.""" + metadata: dict[str, str] = {} + chunks: list[str] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + content, separator, comment = line.partition("#") + chunks.extend(content.split()) + + if not separator: + continue + comment = comment.strip() + if not comment.startswith("Kaitai "): + continue + key, colon, value = comment.removeprefix("Kaitai ").partition(":") + if not colon or key not in {"type", "params", "offset"}: + raise ValueError(f"{path}: invalid Kaitai metadata: {comment}") + if key in metadata: + raise ValueError(f"{path}: duplicate Kaitai {key} metadata") + metadata[key] = value.strip() + + try: + data = bytes.fromhex(" ".join(chunks)) + except ValueError as error: + raise ValueError(f"{path}: invalid annotated hexadecimal") from error + + required = {"type", "params", "offset"} + if metadata.keys() != required: + missing = sorted(required - metadata.keys()) + extra = sorted(metadata.keys() - required) + raise ValueError( + f"{path}: Kaitai metadata drift: missing={missing}, extra={extra}" + ) + if not metadata["type"]: + raise ValueError(f"{path}: empty Kaitai type") + + params: list[object] = [] + for value in metadata["params"].split(): + if value == "true": + params.append(True) + elif value == "false": + params.append(False) + else: + try: + params.append(int(value, 0)) + except ValueError as error: + raise ValueError( + f"{path}: invalid Kaitai parameter {value!r}" + ) from error + + try: + offset = int(metadata["offset"], 0) + except ValueError as error: + raise ValueError(f"{path}: invalid Kaitai offset") from error + if offset < 0 or offset > len(data): + raise ValueError(f"{path}: Kaitai offset is outside the fixture") + + return Fixture( + path=path, + type_name=metadata["type"], + params=tuple(params), + offset=offset, + data=data, + ) + + +def parse_type( + parser_type: type[Any], + data: bytes, + *params: object, +) -> Any: + """Construct one generated Kaitai type and require exact exhaustion.""" + stream = KaitaiStream(io.BytesIO(data)) + result = parser_type(*params, stream) + if not stream.is_eof(): + raise ValueError( + f"{parser_type.__name__} left " + f"{stream.size() - stream.pos()} trailing bytes" + ) + return result + + +def crc32c(data: bytes) -> int: + """Return CRC32C using the reflected Castagnoli polynomial.""" + result = 0xFFFFFFFF + for byte in data: + result ^= byte + for _ in range(8): + result = ( + (result >> 1) ^ 0x82F63B78 + if result & 1 + else result >> 1 + ) + return result ^ 0xFFFFFFFF + + +def validate_record(record: Any, data: bytes, offset: int) -> int: + """Validate one parsed record's source bytes and CRC32C.""" + payload = record._raw_payload + header = record.header + if header.payload_length != len(payload): + raise ValueError( + f"record at 0x{offset:x}: declared {header.payload_length} " + f"payload bytes but parsed {len(payload)}" + ) + + encoded_header = data[offset : offset + RECORD_HEADER_SIZE] + expected_header = struct.pack( + " list[Any]: + """Return complete-snapshot records in their authenticated wire order.""" + records = [snapshot.terminal] + for sequence in snapshot.screens: + records.append(sequence.screen) + records.extend(sequence.pages) + records.append(snapshot.ready) + for sequence in snapshot.histories: + records.append(sequence.history) + records.extend(sequence.pages) + records.append(snapshot.finish) + return records + + +def validate_complete_snapshot(snapshot: Any, data: bytes) -> None: + """Validate ordering relationships, record CRCs, and checkpoints.""" + screen_keys = [ + sequence.screen.payload.header.key for sequence in snapshot.screens + ] + history_keys = [ + sequence.history.payload.key for sequence in snapshot.histories + ] + if len(set(screen_keys)) != len(screen_keys): + raise ValueError("complete snapshot contains a duplicate SCREEN key") + if len(set(history_keys)) != len(history_keys): + raise ValueError("complete snapshot contains a duplicate HISTORY key") + if set(screen_keys) != set(history_keys): + raise ValueError("SCREEN and HISTORY keys do not match") + + terminal_header = snapshot.terminal.payload.header + expected_key_values = ( + {0} if terminal_header.screen_count == 1 else {0, 1} + ) + screen_key_values = {key.value for key in screen_keys} + if screen_key_values != expected_key_values: + raise ValueError( + f"TERMINAL declares screen keys {expected_key_values}, " + f"but SCREEN records contain {screen_key_values}" + ) + + screens_by_key = { + sequence.screen.payload.header.key: sequence + for sequence in snapshot.screens + } + for sequence in snapshot.screens: + header = sequence.screen.payload.header + if ( + header.cursor_x >= terminal_header.columns + or header.cursor_y >= terminal_header.rows + or ( + header.cursor_flags.pending_wrap + and header.cursor_x != terminal_header.columns - 1 + ) + ): + raise ValueError(f"SCREEN key {header.key}: invalid cursor position") + + for sequence in snapshot.histories: + payload = sequence.history.payload + screen = screens_by_key[payload.key] + screen_rows = sum( + page.payload.header.rows for page in screen.pages + ) + if screen_rows < terminal_header.rows: + raise ValueError( + f"SCREEN key {payload.key}: PAGE rows do not cover " + "the active area" + ) + overlap_rows = screen_rows - terminal_header.rows + if payload.screen_overlap_rows != overlap_rows: + raise ValueError( + f"HISTORY key {payload.key}: screen_overlap_rows " + f"{payload.screen_overlap_rows} does not match {overlap_rows}" + ) + + total_rows = payload.screen_overlap_rows + sum( + page.payload.header.rows for page in sequence.pages + ) + if total_rows != payload.total_rows: + raise ValueError( + f"HISTORY key {payload.key}: total_rows " + f"{payload.total_rows} does not match {total_rows}" + ) + + for record in all_snapshot_records(snapshot): + if not hasattr(record, "payload"): + continue + payload = record.payload + if not hasattr(payload, "styles"): + continue + style_ids = [entry.encoded_id for entry in payload.styles] + hyperlink_ids = [entry.encoded_id for entry in payload.hyperlinks] + if len(set(style_ids)) != len(style_ids): + raise ValueError("PAGE contains a duplicate style ID") + if len(set(hyperlink_ids)) != len(hyperlink_ids): + raise ValueError("PAGE contains a duplicate hyperlink ID") + + offset = SNAPSHOT_ENVELOPE_SIZE + for record in all_snapshot_records(snapshot): + if record is snapshot.ready: + expected = blake3(data[:offset]).digest() + if record.payload.prefix_digest != expected: + raise ValueError("READY BLAKE3-256 digest does not match") + elif record is snapshot.finish: + expected = blake3(data[:offset]).digest() + if record.payload.prefix_digest != expected: + raise ValueError("FINISH BLAKE3-256 digest does not match") + offset = validate_record(record, data, offset) + + if offset != len(data): + raise ValueError( + f"complete snapshot accounted for {offset} of {len(data)} bytes" + ) + + +def compile_schema(output_dir: Path) -> type[Any]: + """Compile snapshot.ksy and import its temporary Python parser.""" + compiler = shutil.which("kaitai-struct-compiler") or shutil.which("ksc") + if compiler is None: + raise SystemExit( + "kaitai-struct-compiler is not available; run this inside " + "`nix develop`" + ) + + subprocess.run( + [ + compiler, + "--target=python", + f"--outdir={output_dir}", + str(SCHEMA_PATH), + ], + check=True, + ) + + sys.path.insert(0, str(output_dir)) + try: + module = importlib.import_module("ghostty_snapshot") + finally: + sys.path.pop(0) + return module.GhosttySnapshot + + +def resolve_type(parser: type[Any], type_name: str) -> type[Any]: + """Resolve one KSY snake-case type name on the generated parser.""" + root_type_name = "".join( + f"_{character.lower()}" if character.isupper() else character + for character in parser.__name__ + ).lstrip("_") + if type_name == root_type_name: + return parser + + class_name = "".join( + part[:1].upper() + part[1:] for part in type_name.split("_") + ) + try: + return getattr(parser, class_name) + except AttributeError as error: + raise ValueError(f"snapshot.ksy has no type {type_name!r}") from error + + +def main() -> int: + cli = argparse.ArgumentParser(description=__doc__) + cli.add_argument( + "--write-binary", + nargs=2, + metavar=("FIXTURE", "OUTPUT"), + type=Path, + help="decode one annotated fixture into a raw binary", + ) + args = cli.parse_args() + + if args.write_binary: + fixture_path, output_path = args.write_binary + fixture = load_fixture(fixture_path) + output_path.write_bytes(fixture.data) + print(f"wrote {len(fixture.data)} bytes to {output_path}") + return 0 + + with tempfile.TemporaryDirectory(prefix="ghostty-kaitai-") as temp: + parser = compile_schema(Path(temp)) + + fixture_paths = sorted(TESTDATA_DIR.rglob("*.hex")) + if not fixture_paths: + raise ValueError(f"{TESTDATA_DIR}: no snapshot fixtures") + + for path in fixture_paths: + fixture = load_fixture(path) + parser_type = resolve_type(parser, fixture.type_name) + fixture_label = path.relative_to(TESTDATA_DIR) + try: + parsed = parse_type( + parser_type, + fixture.data[fixture.offset:], + *fixture.params, + ) + except Exception as error: + raise ValueError( + f"{fixture_label}: Kaitai parse failed" + ) from error + + if parser_type is parser: + if fixture.offset != 0: + raise ValueError( + f"{fixture_label}: root parser must begin at offset zero" + ) + validate_complete_snapshot(parsed, fixture.data) + elif hasattr(parsed, "_raw_payload"): + record_end = validate_record( + parsed, + fixture.data, + fixture.offset, + ) + if record_end != len(fixture.data): + raise ValueError( + f"{fixture_label}: record has trailing data" + ) + if hasattr(parsed.payload, "prefix_digest"): + expected = blake3( + fixture.data[:fixture.offset] + ).digest() + if parsed.payload.prefix_digest != expected: + raise ValueError( + f"{fixture_label}: checkpoint digest does not match" + ) + + print(f"ok {fixture_label}") + + print(f"validated {len(fixture_paths)} snapshot fixtures") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 66ea61dd9d6f62995451ad92a3f499839ab7db7a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Jul 2026 21:10:14 -0700 Subject: [PATCH 23/35] ci: verify snapshot kaitai Run the snapshot Kaitai verifier in its own required xsm job. This gives schema, fixture, checksum, and cross-record validation a distinct CI result without coupling it to the libghostty-vt test suite. --- .github/workflows/test.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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 From f8ac0ca98fcc12c1970560dd40f7bb2e39dc3ba4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 08:16:35 -0700 Subject: [PATCH 24/35] terminal/snapshot: accept kitty placeholder cells, track rows Treat Kitty virtual placeholder codepoints as ordinary valid grid content during snapshot restore and derive the native row lookup hint from decoded cells. Image and placement registries remain intentionally omitted. Cover the behavior with a complete snapshot round trip containing a real virtual placement and its grapheme diacritics. --- src/terminal/snapshot/grid.zig | 20 ++++---- src/terminal/snapshot/snapshot.zig | 73 ++++++++++++++++++++++++++++++ src/terminal/snapshot/terminal.zig | 10 ++-- 3 files changed, 90 insertions(+), 13 deletions(-) diff --git a/src/terminal/snapshot/grid.zig b/src/terminal/snapshot/grid.zig index 4d598aa46..8225cd262 100644 --- a/src/terminal/snapshot/grid.zig +++ b/src/terminal/snapshot/grid.zig @@ -40,6 +40,9 @@ //! //! Value 3 is invalid in snapshot version 1. //! +//! 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: @@ -233,9 +236,6 @@ pub const DecodeError = CellHeader.DecodeError || error{ /// The hyperlink map cannot hold the encoded cell references. InvalidHyperlinkCapacity, - - /// PAGE records do not support Kitty graphics placeholders. - UnsupportedKittyGraphics, }; /// Decode every row and cell directly into an initialized, empty page. @@ -306,13 +306,18 @@ pub fn decode( { return error.InvalidCodepoint; } - if (cp == kitty.graphics.unicode.placeholder) { - return error.UnsupportedKittyGraphics; - } if (header.grapheme_count > 0 and cp == 0) { return error.InvalidGrapheme; } cell.content = .{ .codepoint = .{ .data = cp } }; + + // 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; @@ -371,9 +376,6 @@ pub fn decode( { return error.InvalidCodepoint; } - if (suffix == kitty.graphics.unicode.placeholder) { - return error.UnsupportedKittyGraphics; - } page.appendGrapheme(row, cell, suffix) catch return error.InvalidGraphemeCapacity; } diff --git a/src/terminal/snapshot/snapshot.zig b/src/terminal/snapshot/snapshot.zig index cd7215ab4..90d1c8402 100644 --- a/src/terminal/snapshot/snapshot.zig +++ b/src/terminal/snapshot/snapshot.zig @@ -11,6 +11,7 @@ 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; @@ -363,6 +364,78 @@ test "complete snapshot round trip with history and alternate screen" { ); } +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(&t, &encoded); + + var encoded_source: std.Io.Reader = .fixed(encoded.written()); + var restored = try decode( + &encoded_source, + testing.io, + testing.allocator, + ); + 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 is transactional" { const testing = std.testing; diff --git a/src/terminal/snapshot/terminal.zig b/src/terminal/snapshot/terminal.zig index d0fd0e49f..9bfda575f 100644 --- a/src/terminal/snapshot/terminal.zig +++ b/src/terminal/snapshot/terminal.zig @@ -228,10 +228,12 @@ //! hover state, and incremental compression state are presentation or cache //! state and reset during restore. //! -//! Kitty image state, virtual placeholders, and glyph glossary registrations -//! are unsupported by this snapshot version and are ignored during capture. -//! Build-time terminal behavior, Unicode width policy, parser continuation, and -//! external callbacks are version-level or caller-local requirements. +//! 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 From a508720a89c5e899e6bc8883e1cc23851ea4ddb8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 09:29:10 -0700 Subject: [PATCH 25/35] terminal/snapshot: grid decode robustness principle Keep snapshot grid encoding strict by rejecting malformed wide-cell relationships before they can produce invalid wire data. Decode untrusted grids liberally while preserving record alignment. Unknown semantic values and content kinds degrade to safe defaults, optional graphemes and hyperlinks are dropped when invalid or over capacity, and malformed wide-cell markers normalize to narrow cells. --- src/terminal/snapshot/AGENTS.md | 7 + src/terminal/snapshot/grid.zig | 445 +++++++++++++++++++------------- src/terminal/snapshot/page.zig | 149 +++++++---- 3 files changed, 374 insertions(+), 227 deletions(-) create mode 100644 src/terminal/snapshot/AGENTS.md 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/grid.zig b/src/terminal/snapshot/grid.zig index 8225cd262..18e7955ba 100644 --- a/src/terminal/snapshot/grid.zig +++ b/src/terminal/snapshot/grid.zig @@ -38,7 +38,7 @@ //! | 1 | Prompt | //! | 2 | Prompt continuation | //! -//! Value 3 is invalid in snapshot version 1. +//! 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. @@ -97,7 +97,7 @@ //! | 1 | Input | //! | 2 | Prompt | //! -//! Value 3 is invalid in snapshot version 1. +//! 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 @@ -135,18 +135,21 @@ pub const StyleRemap = std.AutoHashMap(TerminalStyleId, TerminalStyleId); /// 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; +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 { - // Encoding assumes a structurally valid page. This assertion performs the - // comprehensive check in slow-safety builds without adding an allocator to - // the encoder API. - page.assertIntegrity(); - + defer page.assertIntegrity(); for (0..page.size.rows) |y| { // Row header const row = page.getRow(y); @@ -159,7 +162,26 @@ pub fn encode( // Cells const cells = page.getCells(row); - for (cells) |*cell| { + 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 @@ -212,39 +234,13 @@ pub fn encode( } } -pub const DecodeError = CellHeader.DecodeError || error{ - /// A row contains undefined flag values. - InvalidRow, - - /// A cell contains an undefined kind, width, flag, or reserved value. - InvalidCell, - - /// A grapheme suffix is invalid or attached to non-codepoint content. - InvalidGrapheme, - - /// The advertised grapheme capacity cannot hold the encoded suffixes. - InvalidGraphemeCapacity, - - /// A codepoint is not a Unicode scalar value. - InvalidCodepoint, - - /// A packed background color has nonzero reserved bytes. - InvalidColor, - - /// Wide and spacer cells do not form a valid row. - InvalidWideCell, - - /// The hyperlink map cannot hold the encoded cell references. - InvalidHyperlinkCapacity, -}; - /// 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. The page -/// must also have enough grapheme and hyperlink-map capacity for the decoded -/// contents. +/// 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 @@ -252,143 +248,170 @@ pub const DecodeError = CellHeader.DecodeError || error{ /// 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, -) DecodeError!void { +) std.Io.Reader.Error!void { for (0..page.size.rows) |y| { const row_raw = try reader.takeByte(); - - // Validate the enum field before bitcasting into the packed native - // representation. The remaining value is checked through its named - // padding field below. - const semantic_prompt_raw: u2 = @truncate(row_raw >> 2); - _ = std.enums.fromInt( - TerminalRow.SemanticPrompt, - semantic_prompt_raw, - ) orelse return error.InvalidRow; - const row_header: RowHeader = @bitCast(row_raw); - if (row_header._padding != 0) return error.InvalidRow; + 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 = row_header.semantic_prompt; + row.semantic_prompt = std.enums.fromInt( + TerminalRow.SemanticPrompt, + semantic_prompt_raw, + ) orelse .none; const cells = page.getCells(row); for (cells, 0..) |*cell, x| { - const header = try CellHeader.decode(reader); - - // 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; + const decoded_header = try CellHeader.decode(reader); cell.* = .init(0); - switch (header.content_kind) { - .codepoint => { - const cp = std.math.cast(u21, header.value.codepoint) orelse - return error.InvalidCodepoint; - if (cp > 0x10FFFF or - (cp >= 0xD800 and cp <= 0xDFFF)) - { - return error.InvalidCodepoint; - } - if (header.grapheme_count > 0 and cp == 0) { - return error.InvalidGrapheme; - } - cell.content = .{ .codepoint = .{ .data = cp } }; + 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, - // 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; + .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; }, - .bg_color_palette => { - const palette = header.value.bg_color_palette; - if (palette._padding != 0) return error.InvalidColor; - if (header.grapheme_count != 0) { - return error.InvalidGrapheme; - } - cell.content_tag = .bg_color_palette; - cell.content = .{ - .color_palette = .{ .data = palette.index }, - }; - }, - .bg_color_rgb => { - const rgb = header.value.bg_color_rgb; - if (rgb._padding != 0) return error.InvalidColor; - if (header.grapheme_count != 0) { - return error.InvalidGrapheme; - } - 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. - page.hyperlink_set.use(page.memory, hyperlink_id); - page.setHyperlink(row, cell, hyperlink_id) catch - return error.InvalidHyperlinkCapacity; - } - - // Append directly into page-owned grapheme storage so no - // intermediate suffix buffer is needed. - for (0..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); - const suffix = std.math.cast(u21, suffix_raw) orelse - return error.InvalidCodepoint; + if (!accept_graphemes) continue; + + const suffix = std.math.cast(u21, suffix_raw) orelse continue; if (suffix > 0x10FFFF or (suffix >= 0xD800 and suffix <= 0xDFFF)) { - return error.InvalidCodepoint; + continue; } - page.appendGrapheme(row, cell, suffix) catch - return error.InvalidGraphemeCapacity; + page.appendGrapheme(row, cell, suffix) catch { + accept_graphemes = false; + }; } - switch (header.width) { - .narrow, .wide => {}, + // 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) { - return error.InvalidWideCell; + cell.wide = .narrow; }, + .spacer_head => if (x + 1 != cells.len or !row.wrap) { - return error.InvalidWideCell; + cell.wide = .narrow; }, } } @@ -439,9 +462,6 @@ const CellHeader = struct { /// Number of grapheme suffix codepoints immediately following the header. grapheme_count: u32 = 0, - /// Errors possible while decoding a fixed cell header. - pub const DecodeError = std.Io.Reader.Error || error{InvalidCell}; - /// Encode the fixed cell header. pub fn encode( self: CellHeader, @@ -467,45 +487,63 @@ const CellHeader = struct { try io.writeInt(writer, u32, self.grapheme_count); } - /// Decode and validate the fixed cell header. - pub fn decode(reader: *std.Io.Reader) CellHeader.DecodeError!CellHeader { - const content_kind = std.enums.fromInt( - Kind, - try reader.takeByte(), - ) orelse return error.InvalidCell; + /// A valid header, or the suffix count from an uninterpretable header. + const DecodeResult = union(enum) { + valid: CellHeader, + invalid: u32, + }; - const width = std.enums.fromInt( - TerminalCell.Wide, - try reader.takeByte(), - ) orelse return error.InvalidCell; + /// 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_raw = try reader.takeByte(); + const flags: Flags = @bitCast(try reader.takeByte()); - // As with the row header, validate the exhaustive native enum before - // bitcasting the complete packed byte. - const semantic_content_raw: u2 = @truncate(flags_raw >> 1); - _ = std.enums.fromInt( - TerminalCell.SemanticContent, - semantic_content_raw, - ) orelse return error.InvalidCell; - - const flags: Flags = @bitCast(flags_raw); - if (flags._padding != 0) return error.InvalidCell; + // 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. - if (try reader.takeByte() != 0) return error.InvalidCell; + // 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(); - return .{ + 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 = flags.semantic_content, - .style_id = try io.readInt(reader, TerminalStyleId), - .hyperlink_id = try io.readInt(reader, TerminalHyperlinkId), - .value = @bitCast(try io.readInt(reader, u32)), - .grapheme_count = try io.readInt(reader, u32), - }; + .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. @@ -707,3 +745,60 @@ test "grid golden encoding and decoding" { 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/page.zig b/src/terminal/snapshot/page.zig index 05ad3c03a..5d7583f59 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -120,7 +120,6 @@ const TerminalStyleSet = terminal_style.Set; const PayloadEncodeError = hyperlink.EncodeError || grid.EncodeError; const PayloadDecodeError = style.DecodeError || - grid.DecodeError || Header.CapacityError || error{ /// The hyperlink kind is not defined by snapshot version 1. @@ -829,6 +828,35 @@ test "framed PAGE golden empty record" { ); } +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"); + try testing.expectError( + error.InvalidWideCell, + encode(&page, &destination), + ); + 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( @@ -1044,9 +1072,9 @@ test "decode defaults missing sparse cell references" { ); } -test "decode rejects undefined row and cell values" { +test "decode normalizes invalid grid semantics" { const header: Header = .{ - .columns = 1, + .columns = 3, .rows = 1, .style_count = 0, .hyperlink_count = 0, @@ -1056,62 +1084,79 @@ test "decode rejects undefined row and cell values" { .string_capacity_bytes = 0, }; - var invalid_row: [Header.len + 1]u8 = undefined; - var row_writer: std.Io.Writer = .fixed(&invalid_row); - try header.encode(&row_writer); - try row_writer.writeByte(0x10); + var encoded: [Header.len + 1 + 3 * 16 + 12]u8 = undefined; + var writer: std.Io.Writer = .fixed(&encoded); + try header.encode(&writer); - var row_reader: std.Io.Reader = .fixed(row_writer.buffered()); - try std.testing.expectError( - error.InvalidRow, - decodePayload(&row_reader, std.testing.allocator), + // 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, ); - - var invalid_cell: [Header.len + 2]u8 = undefined; - var cell_writer: std.Io.Writer = .fixed(&invalid_cell); - try header.encode(&cell_writer); - try cell_writer.writeByte(0); - try cell_writer.writeByte(3); - - var cell_reader: std.Io.Reader = .fixed(cell_writer.buffered()); - try std.testing.expectError( - error.InvalidCell, - decodePayload(&cell_reader, std.testing.allocator), + try std.testing.expectEqual(@as(u21, 0), first.cell.codepoint()); + try std.testing.expectEqual( + terminal_page.Cell.Wide.narrow, + first.cell.wide, ); - - var invalid_codepoint: [Header.len + 1 + 16]u8 = undefined; - var codepoint_writer: std.Io.Writer = .fixed(&invalid_codepoint); - try header.encode(&codepoint_writer); - try codepoint_writer.writeByte(0); - try codepoint_writer.writeAll(&.{ 0, 0, 0, 0 }); - try io.writeInt(&codepoint_writer, TerminalStyleId, 0); - try io.writeInt(&codepoint_writer, TerminalHyperlinkId, 0); - try io.writeInt(&codepoint_writer, u32, 0xD800); - try io.writeInt(&codepoint_writer, u32, 0); - - var codepoint_reader: std.Io.Reader = .fixed( - codepoint_writer.buffered(), - ); - try std.testing.expectError( - error.InvalidCodepoint, - decodePayload(&codepoint_reader, std.testing.allocator), + 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()); - var invalid_color: [Header.len + 1 + 16]u8 = undefined; - var color_writer: std.Io.Writer = .fixed(&invalid_color); - try header.encode(&color_writer); - try color_writer.writeByte(0); - try color_writer.writeAll(&.{ 1, 0, 0, 0 }); - try io.writeInt(&color_writer, TerminalStyleId, 0); - try io.writeInt(&color_writer, TerminalHyperlinkId, 0); - try io.writeInt(&color_writer, u32, 0x100); - try io.writeInt(&color_writer, u32, 0); + const second = page.getRowAndCell(1, 0).cell; + try std.testing.expectEqual(@as(u21, 0xFFFD), second.codepoint()); + try std.testing.expect(!second.hasGrapheme()); - var color_reader: std.Io.Reader = .fixed(color_writer.buffered()); - try std.testing.expectError( - error.InvalidColor, - decodePayload(&color_reader, std.testing.allocator), + 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 and native table capacities" { From 465488d6b4f7a0eb7fac09e37aff8078f28b472a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 09:41:04 -0700 Subject: [PATCH 26/35] terminal/snapshot: screen robustness Keep SCREEN encoding strict while allowing decoding to recover from unknown or noncanonical semantic state. Cursor positions now clamp to the restored active area, and invalid enum values, reserved bits, and optional state degrade to native defaults. --- src/terminal/snapshot/screen.zig | 473 ++++++++++++++++--------------- 1 file changed, 245 insertions(+), 228 deletions(-) diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index 505c9c5ff..e65c6d75d 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -297,9 +297,6 @@ pub const DecodeError = PayloadDecodeError || /// A SCREEN must declare at least one PAGE. InvalidPageCount, - - /// The cursor is outside the restored active area. - InvalidCursorPosition, }; /// One decoded SCREEN sequence and the native screen identified by its header. @@ -359,13 +356,6 @@ pub fn decode( if (options.cols == 0 or options.rows == 0) { return error.InvalidDimensions; } - if (header.cursor_x >= options.cols or - header.cursor_y >= options.rows or - (header.cursor_flags.pending_wrap and - header.cursor_x != options.cols - 1)) - { - return error.InvalidCursorPosition; - } // Build the native page list transactionally. Alternate screens never // retain scrollback, while primary screens inherit the caller's limits. @@ -400,12 +390,38 @@ pub fn decode( // Convert the payload-relative cursor position into the tracked native // pin and page-local row/cell pointers required by TerminalScreen. - const cursor_pin_value = pages.pin(.{ .active = .{ - .x = header.cursor_x, - .y = header.cursor_y, - } }) orelse return error.InvalidCursorPosition; - const cursor_pin = try pages.trackPin(cursor_pin_value); - const cursor_rac = cursor_pin.rowAndCell(); + 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. @@ -415,21 +431,7 @@ pub fn decode( .pages = pages, .no_scrollback = key == .alternate or options.max_scrollback_bytes == 0, - .cursor = .{ - .x = header.cursor_x, - .y = header.cursor_y, - .cursor_style = header.cursor_style, - .pending_wrap = header.cursor_flags.pending_wrap, - .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 = cursor_pin, - .page_row = cursor_rac.row, - .page_cell = cursor_rac.cell, - }, + .cursor = cursor, .saved_cursor = if (saved_cursor) |value| value.terminal() else @@ -513,19 +515,7 @@ pub fn decode( } /// Errors possible while decoding fixed SCREEN payload fields. -const PayloadDecodeError = style.DecodeError || error{ - InvalidKey, - InvalidCursorStyle, - InvalidCursorFlags, - InvalidSemanticContent, - InvalidCharsetState, - InvalidProtectedMode, - InvalidKittyKeyboardIndex, - InvalidKittyKeyboardFlags, - InvalidSemanticClick, - InvalidSavedCursorPresent, - InvalidSavedCursorFlags, -}; +const PayloadDecodeError = style.DecodeError || error{InvalidKey}; /// Flags encoded after the cursor's visual shape. pub const CursorFlags = packed struct(u8) { @@ -545,18 +535,22 @@ pub const CursorFlags = packed struct(u8) { }; } - /// Decode and validate the cursor flag registry. - pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!CursorFlags { + /// 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 semantic_content_raw: u2 = @truncate(raw >> 2); - _ = std.enums.fromInt( + 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 return error.InvalidSemanticContent; + ) orelse .output; - const result: CursorFlags = @bitCast(raw); - if (result._padding != 0) return error.InvalidCursorFlags; - return result; + return .{ + .pending_wrap = bits.pending_wrap, + .protected = bits.protected, + .semantic_content = semantic_content, + .semantic_content_clear_eol = bits.semantic_content_clear_eol, + }; } }; @@ -631,56 +625,54 @@ fn encodeCharsetState( fn decodeCharsetState( raw: u16, -) error{InvalidCharsetState}!TerminalScreen.CharsetState { +) TerminalScreen.CharsetState { const bits: CharsetBits = @bitCast(raw); - if (bits._padding != 0 or bits.single_shift > 4) { - return error.InvalidCharsetState; - } - var result: TerminalScreen.CharsetState = .{ - .gl = enumFromInt( + 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.gl, - ) orelse return error.InvalidCharsetState, - .gr = enumFromInt( - terminal_charsets.Slots, - bits.gr, - ) orelse return error.InvalidCharsetState, - .single_shift = if (bits.single_shift == 0) - null - else - enumFromInt( - terminal_charsets.Slots, - bits.single_shift - 1, - ) orelse return error.InvalidCharsetState, - }; + bits.single_shift - 1, + ) + else + null; result.charsets.set( .G0, enumFromInt( terminal_charsets.Charset, bits.g0, - ) orelse return error.InvalidCharsetState, + ) orelse .utf8, ); result.charsets.set( .G1, enumFromInt( terminal_charsets.Charset, bits.g1, - ) orelse return error.InvalidCharsetState, + ) orelse .utf8, ); result.charsets.set( .G2, enumFromInt( terminal_charsets.Charset, bits.g2, - ) orelse return error.InvalidCharsetState, + ) orelse .utf8, ); result.charsets.set( .G3, enumFromInt( terminal_charsets.Charset, bits.g3, - ) orelse return error.InvalidCharsetState, + ) orelse .utf8, ); return result; } @@ -699,12 +691,10 @@ pub const KittyKeyboard = struct { report_associated: bool = false, _padding: u3 = 0, - /// Decode and validate one Kitty keyboard flag byte. - pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!Flags { - const result: Flags = @bitCast(try reader.takeByte()); - if (result._padding != 0) { - return error.InvalidKittyKeyboardFlags; - } + /// 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; } }; @@ -739,10 +729,10 @@ pub const KittyKeyboard = struct { return result; } - /// Decode and validate the complete fixed Kitty keyboard stack. - pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!KittyKeyboard { - const index = try reader.takeByte(); - if (index >= 8) return error.InvalidKittyKeyboardIndex; + /// 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); @@ -750,29 +740,26 @@ pub const KittyKeyboard = struct { } }; -/// Decode and validate the semantic-click kind and value bytes. +/// Decode semantic-click state, falling back to disabled for unknown values. fn decodeSemanticClick( reader: *std.Io.Reader, -) PayloadDecodeError!TerminalScreen.SemanticPrompt.SemanticClick { +) std.Io.Reader.Error!TerminalScreen.SemanticPrompt.SemanticClick { const kind = enumFromInt( TerminalScreen.SemanticPrompt.SemanticClickKind, try reader.takeByte(), - ) orelse return error.InvalidSemanticClick; + ); const value = try reader.takeByte(); - return switch (kind) { - .none => if (value == 0) - .none - else - error.InvalidSemanticClick, + return switch (kind orelse return .none) { + .none => .none, .click_events => .{ .click_events = enumFromInt( terminal_osc.semantic_prompt.ClickEvents, value, - ) orelse return error.InvalidSemanticClick }, + ) orelse return .none }, .cl => .{ .cl = enumFromInt( terminal_osc.semantic_prompt.Click, value, - ) orelse return error.InvalidSemanticClick }, + ) orelse return .none }, }; } @@ -798,10 +785,10 @@ pub const SavedCursor = struct { origin: bool = false, _padding: u5 = 0, - /// Decode and validate saved cursor flags. - pub fn decode(reader: *std.Io.Reader) PayloadDecodeError!Flags { - const result: Flags = @bitCast(try reader.takeByte()); - if (result._padding != 0) return error.InvalidSavedCursorFlags; + /// 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; } }; @@ -856,7 +843,7 @@ pub const SavedCursor = struct { .y = try io.readInt(reader, u16), .pen = try style.decode(reader), .flags = try Flags.decode(reader), - .charset = try decodeCharsetState( + .charset = decodeCharsetState( try io.readInt(reader, u16), ), }; @@ -987,7 +974,10 @@ pub const Header = struct { try writer.writeByte(@intFromBool(self.saved_cursor_present)); } - /// Decode and validate the fixed SCREEN payload header. + /// 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 and cursor position. const key = enumFromInt( @@ -1002,30 +992,26 @@ pub const Header = struct { const cursor_style = enumFromInt( TerminalScreen.CursorStyle, try reader.takeByte(), - ) orelse return error.InvalidCursorStyle; + ) orelse .block; const cursor_flags = try CursorFlags.decode(reader); const cursor_pen = try style.decode(reader); const hyperlink_implicit_id = try io.readInt(reader, u32); // Charset and selective-erase state. - const charset = try decodeCharsetState( + const charset = decodeCharsetState( try io.readInt(reader, u16), ); const protected_mode = enumFromInt( terminal_ansi.ProtectedMode, try reader.takeByte(), - ) orelse return error.InvalidProtectedMode; + ) 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 = switch (try reader.takeByte()) { - 0 => false, - 1 => true, - else => return error.InvalidSavedCursorPresent, - }; + const saved_cursor_present = try reader.takeByte() != 0; return .{ .key = key, @@ -1434,7 +1420,7 @@ test "header encoding rejects invalid state" { ); } -test "header decoding rejects invalid values" { +test "header decoding rejects structural values" { const valid = [_]u8{0} ** Header.len; var invalid_key = valid; @@ -1442,32 +1428,6 @@ test "header decoding rejects invalid values" { var key_reader: std.Io.Reader = .fixed(&invalid_key); try std.testing.expectError(error.InvalidKey, Header.decode(&key_reader)); - var invalid_cursor_style = valid; - invalid_cursor_style[8] = 4; - var cursor_style_reader: std.Io.Reader = .fixed(&invalid_cursor_style); - try std.testing.expectError( - error.InvalidCursorStyle, - Header.decode(&cursor_style_reader), - ); - - var invalid_cursor_flags = valid; - invalid_cursor_flags[9] = 1 << 5; - var cursor_flags_reader: std.Io.Reader = .fixed(&invalid_cursor_flags); - try std.testing.expectError( - error.InvalidCursorFlags, - Header.decode(&cursor_flags_reader), - ); - - var invalid_semantic_content = valid; - invalid_semantic_content[9] = 3 << 2; - var semantic_content_reader: std.Io.Reader = .fixed( - &invalid_semantic_content, - ); - try std.testing.expectError( - error.InvalidSemanticContent, - Header.decode(&semantic_content_reader), - ); - var invalid_style = valid; invalid_style[10] = 3; var style_reader: std.Io.Reader = .fixed(&invalid_style); @@ -1475,88 +1435,78 @@ test "header decoding rejects invalid values" { error.InvalidColorKind, Header.decode(&style_reader), ); +} - var invalid_charset_reserved = valid; - invalid_charset_reserved[31] = 1 << 7; - var charset_reserved_reader: std.Io.Reader = .fixed( - &invalid_charset_reserved, - ); - try std.testing.expectError( - error.InvalidCharsetState, - Header.decode(&charset_reserved_reader), - ); +test "header decoding normalizes unknown semantic values" { + var fixture = [_]u8{0} ** Header.len; - var invalid_single_shift = valid; - invalid_single_shift[31] = 5 << 4; - var single_shift_reader: std.Io.Reader = .fixed(&invalid_single_shift); - try std.testing.expectError( - error.InvalidCharsetState, - Header.decode(&single_shift_reader), - ); + fixture[8] = 4; // Unknown cursor style. + fixture[9] = 0xFF; // Known flags, unknown semantic value, reserved bits. + fixture[31] = 0xD0; // Invalid single shift plus a reserved bit. + fixture[32] = 3; // Unknown protected mode. + fixture[33] = 8; // Out-of-range Kitty keyboard index. + @memset(fixture[34..42], 0xFF); // Known flags plus reserved bits. + fixture[42] = 3; // Unknown semantic-click kind. + fixture[43] = 0xFF; + fixture[44] = 2; // Noncanonical true. - var invalid_protected_mode = valid; - invalid_protected_mode[32] = 3; - var protected_mode_reader: std.Io.Reader = .fixed( - &invalid_protected_mode, - ); - try std.testing.expectError( - error.InvalidProtectedMode, - Header.decode(&protected_mode_reader), - ); + var reader: std.Io.Reader = .fixed(&fixture); + const decoded = try Header.decode(&reader); - var invalid_kitty_index = valid; - invalid_kitty_index[33] = 8; - var kitty_index_reader: std.Io.Reader = .fixed(&invalid_kitty_index); - try std.testing.expectError( - error.InvalidKittyKeyboardIndex, - Header.decode(&kitty_index_reader), + try std.testing.expectEqual( + TerminalScreen.CursorStyle.block, + decoded.cursor_style, ); + 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); - for (34..42) |offset| { - var invalid_kitty_flags = valid; - invalid_kitty_flags[offset] = 1 << 5; - var reader: std.Io.Reader = .fixed(&invalid_kitty_flags); - try std.testing.expectError( - error.InvalidKittyKeyboardFlags, - Header.decode(&reader), - ); + 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); } - - var invalid_semantic_click_kind = valid; - invalid_semantic_click_kind[42] = 3; - var semantic_click_kind_reader: std.Io.Reader = .fixed( - &invalid_semantic_click_kind, - ); - try std.testing.expectError( - error.InvalidSemanticClick, - Header.decode(&semantic_click_kind_reader), + 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 fixture = valid; - fixture[42] = invalid[0]; - fixture[43] = invalid[1]; - var reader: std.Io.Reader = .fixed(&fixture); - try std.testing.expectError( - error.InvalidSemanticClick, - Header.decode(&reader), + var semantic_fixture = [_]u8{0} ** Header.len; + semantic_fixture[42] = invalid[0]; + semantic_fixture[43] = 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, ); } - - var invalid_saved_cursor_present = valid; - invalid_saved_cursor_present[44] = 2; - var saved_cursor_present_reader: std.Io.Reader = .fixed( - &invalid_saved_cursor_present, - ); - try std.testing.expectError( - error.InvalidSavedCursorPresent, - Header.decode(&saved_cursor_present_reader), - ); } test "header decoding rejects every truncation" { @@ -1617,7 +1567,7 @@ test "saved cursor golden encoding and decoding" { ); } -test "saved cursor rejects invalid state" { +test "saved cursor encoding rejects and decoding normalizes invalid state" { var encoded: [SavedCursor.len]u8 = undefined; var invalid_flags = testSavedCursor(); @@ -1628,32 +1578,20 @@ test "saved cursor rejects invalid state" { invalid_flags.encode(&flags_writer), ); - const valid = [_]u8{0} ** SavedCursor.len; + var invalid = [_]u8{0} ** SavedCursor.len; + 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); - var invalid_flags_bytes = valid; - invalid_flags_bytes[20] = 1 << 3; - var flags_reader: std.Io.Reader = .fixed(&invalid_flags_bytes); - try std.testing.expectError( - error.InvalidSavedCursorFlags, - SavedCursor.decode(&flags_reader), - ); - - var invalid_charset_reserved = valid; - invalid_charset_reserved[22] = 1 << 7; - var charset_reserved_reader: std.Io.Reader = .fixed( - &invalid_charset_reserved, - ); - try std.testing.expectError( - error.InvalidCharsetState, - SavedCursor.decode(&charset_reserved_reader), - ); - - var invalid_single_shift = valid; - invalid_single_shift[22] = 5 << 4; - var single_shift_reader: std.Io.Reader = .fixed(&invalid_single_shift); - try std.testing.expectError( - error.InvalidCharsetState, - SavedCursor.decode(&single_shift_reader), + 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, ); } @@ -1971,6 +1909,85 @@ test "SCREEN encodes the minimal complete-page active suffix" { 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 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; + + var screen_writer = try record.Writer.init(&destination, .screen); + try header.encode(screen_writer.payloadWriter()); + try screen_writer.payloadWriter().writeByte(0); + try screen_writer.finish(); + + const native_page = screen.pages.getTopLeft(.active).node + .pageAssumeResident(); + try page.encode(native_page, &destination); + + 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, From a44eb8335884b104f2d44c4a613161db595e457f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 09:59:42 -0700 Subject: [PATCH 27/35] terminal/snapshot: style/hyperlink robustness in page and screen Keep the standalone style and hyperlink codecs strict while allowing PAGE and SCREEN decoders to discard invalid optional data at boundaries they own. Normalize invalid styles to defaults, ignore unrepresentable hyperlinks, reuse duplicate entries, and validate hyperlink values before encoding. --- src/terminal/snapshot/hyperlink.zig | 130 ++++++-- src/terminal/snapshot/page.zig | 479 ++++++++++++++++------------ src/terminal/snapshot/screen.zig | 86 +++-- src/terminal/snapshot/style.zig | 25 ++ 4 files changed, 447 insertions(+), 273 deletions(-) diff --git a/src/terminal/snapshot/hyperlink.zig b/src/terminal/snapshot/hyperlink.zig index ec3b14efe..b10d0dc8d 100644 --- a/src/terminal/snapshot/hyperlink.zig +++ b/src/terminal/snapshot/hyperlink.zig @@ -10,8 +10,8 @@ //! 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, so standalone decoding rejects zero lengths and -//! PAGE decoding consumes but ignores the invalid table entry. +//! 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. //! @@ -54,7 +54,16 @@ const Kind = enum(u8) { }; /// Errors possible while encoding one hyperlink entry. -pub const EncodeError = std.Io.Writer.Error; +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{ @@ -69,21 +78,26 @@ pub const DecodeError = std.Io.Reader.Error || Allocator.Error || error{ }; /// Errors possible while decoding directly into a native page. -pub const DecodePageError = std.Io.Reader.Error || - terminal_page.Page.InsertHyperlinkError || - error{ - /// The hyperlink kind is not defined by snapshot version 1. - InvalidKind, - - /// The hyperlink value already exists in the page. - DuplicateHyperlink, - }; +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)); @@ -156,8 +170,8 @@ pub fn decode( /// 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 URI or explicit ID is empty because native page storage cannot -/// represent empty hyperlink strings. +/// 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, @@ -172,11 +186,17 @@ pub fn decodePage( const uri_len: usize = @intCast(try io.readInt(reader, u32)); if (uri_len == 0) return 0; - const uri = try decodePageString( + 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 }, @@ -194,11 +214,21 @@ pub fn decodePage( return 0; } - const id = try decodePageString( + 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), @@ -213,11 +243,21 @@ pub fn decodePage( return 0; } - const uri = try decodePageString( + 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 }, @@ -225,23 +265,15 @@ pub fn decodePage( }; }, }; - errdefer entry.free(page); - - if (page.hyperlink_set.lookupContext( - page.memory, - entry, - .{ .page = page }, - ) != null) { - return error.DuplicateHyperlink; - } - return page.hyperlink_set.addContext( page.memory, entry, .{ .page = page }, - ) catch |err| switch (err) { - error.OutOfMemory => error.SetOutOfMemory, - error.NeedsRehash => error.SetNeedsRehash, + ) 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; }; } @@ -322,6 +354,36 @@ test "golden explicit encoding" { 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, @@ -376,7 +438,7 @@ test "decodePage ignores empty strings" { } } -test "reject invalid kinds" { +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); diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 5d7583f59..335d74127 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -108,39 +108,21 @@ const terminal_style = @import("../style.zig"); const TerminalHyperlink = terminal_hyperlink.Hyperlink; const TerminalHyperlinkId = terminal_hyperlink.Id; const TerminalHyperlinkPageEntry = terminal_hyperlink.PageEntry; -const TerminalHyperlinkSet = terminal_hyperlink.Set; 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 TerminalStyleSet = terminal_style.Set; const PayloadEncodeError = hyperlink.EncodeError || grid.EncodeError; -const PayloadDecodeError = style.DecodeError || +const PayloadDecodeError = std.Io.Reader.Error || Header.CapacityError || error{ /// The hyperlink kind is not defined by snapshot version 1. InvalidKind, - /// The advertised string capacity cannot hold the encoded hyperlinks. - InvalidStringCapacity, - - /// A non-default style was encoded more than once. - DuplicateStyle, - - /// A hyperlink was encoded more than once. - DuplicateHyperlink, - - /// The default style cannot appear in the non-default style table. - DefaultStyle, - - /// An encoded table ID is zero or appears more than once. - InvalidStyleId, - InvalidHyperlinkId, - /// Native page backing memory could not be allocated. OutOfMemory, @@ -315,34 +297,26 @@ fn decodePayloadBody( // Styles for (0..header.style_count) |_| { - // Zero denotes the implicit default style. Reusing an encoded ID would - // make cell references ambiguous because it would name multiple table - // entries. const native_id = try io.readInt(reader, TerminalStyleId); - if (native_id == 0 or style_remap.contains(native_id)) { - return error.InvalidStyleId; - } + const value: ?TerminalStyle = style.decodeOrDiscard(reader) catch null; - // Decode the style itself. It must never be the default style. - const value = try style.decode(reader); - if (value.default()) return error.DefaultStyle; + // 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; - // If we already have the style, its invalid. - if (page.styles.lookup( - page.memory, - value, - ) != null) return error.DuplicateStyle; - - // Add our style, get our real ID on this side, and store it in the - // remap table. - const decoded_id = page.styles.add( - page.memory, - value, - ) catch |err| switch (err) { - error.OutOfMemory, - error.NeedsRehash, - => return error.InvalidStyleCapacity, - }; + // 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, @@ -351,31 +325,20 @@ fn decodePayloadBody( // Hyperlinks for (0..header.hyperlink_count) |_| { - // Zero denotes no hyperlink. As with styles, a repeated encoded ID - // would make cell references ambiguous. 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)) { - return error.InvalidHyperlinkId; + if (decoded_id != 0) { + page.hyperlink_set.release(page.memory, decoded_id); + } + continue; } - const decoded_id = hyperlink.decodePage( - page, - reader, - ) catch |err| switch (err) { - error.StringsOutOfMemory => return error.InvalidStringCapacity, - error.SetOutOfMemory, - error.SetNeedsRehash, - => return error.InvalidHyperlinkCapacity, - - error.DuplicateHyperlink => return error.DuplicateHyperlink, - error.InvalidKind => return error.InvalidKind, - error.EndOfStream => return error.EndOfStream, - error.ReadFailed => return error.ReadFailed, - }; - - // Zero records an ignored table entry. Keeping that mapping preserves - // encoded-ID uniqueness while grid decoding treats every reference to - // the invalid hyperlink as no hyperlink. + // Zero records an ignored table entry. Grid decoding treats every + // reference to it as no hyperlink. hyperlink_remap.putAssumeCapacityNoClobber( native_id, decoded_id, @@ -479,8 +442,6 @@ pub const Header = struct { pub const CapacityError = error{ InvalidDimensions, - InvalidStyleCapacity, - InvalidHyperlinkCapacity, }; /// Validate native allocation requirements and produce the page capacity. @@ -489,22 +450,6 @@ pub const Header = struct { return error.InvalidDimensions; } - const style_layout: TerminalStyleSet.Layout = .init(self.style_capacity); - if (self.style_count > style_layout.cap -| 1) { - return error.InvalidStyleCapacity; - } - - const hyperlink_capacity_count = @divFloor( - self.hyperlink_capacity_bytes, - @sizeOf(TerminalHyperlinkSet.Item), - ); - const hyperlink_layout: TerminalHyperlinkSet.Layout = .init( - hyperlink_capacity_count, - ); - if (self.hyperlink_count > hyperlink_layout.cap -| 1) { - return error.InvalidHyperlinkCapacity; - } - return .{ .cols = self.columns, .rows = self.rows, @@ -891,7 +836,7 @@ test "decode sparse page rejects every truncation" { } } -test "decode accepts unordered sparse style IDs and rejects zero" { +test "decode accepts unordered sparse style IDs and ignores zero" { const header: Header = .{ .columns = 1, .rows = 1, @@ -939,17 +884,23 @@ test "decode accepts unordered sparse style IDs and rejects zero" { .string_capacity_bytes = 0, }; - var zero: [Header.len + 2 + style.len]u8 = undefined; + 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()); - try std.testing.expectError( - error.InvalidStyleId, - decodePayload(&zero_reader, std.testing.allocator), + 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" { @@ -1159,83 +1110,47 @@ test "decode normalizes invalid grid semantics" { try std.testing.expect(!third.hasGrapheme()); } -test "decode validates dimensions and native table capacities" { - const Case = struct { - expected: anyerror, - header: Header, - }; - const cases = [_]Case{ +test "decode validates dimensions" { + const cases = [_]Header{ .{ - .expected = error.InvalidDimensions, - .header = 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 = 0, + .rows = 24, + .style_count = 0, + .hyperlink_count = 0, + .style_capacity = 0, + .hyperlink_capacity_bytes = 0, + .grapheme_capacity_bytes = 0, + .string_capacity_bytes = 0, }, .{ - .expected = error.InvalidDimensions, - .header = Header{ - .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, - }, - }, - .{ - .expected = error.InvalidStyleCapacity, - .header = Header{ - .columns = 80, - .rows = 24, - .style_count = 1, - .hyperlink_count = 0, - .style_capacity = 0, - .hyperlink_capacity_bytes = 0, - .grapheme_capacity_bytes = 0, - .string_capacity_bytes = 0, - }, - }, - .{ - .expected = error.InvalidHyperlinkCapacity, - .header = Header{ - .columns = 80, - .rows = 24, - .style_count = 0, - .hyperlink_count = 1, - .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) |case| { + for (cases) |header| { var encoded: [Header.len]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); - try case.header.encode(&writer); + try header.encode(&writer); var reader: std.Io.Reader = .fixed(writer.buffered()); try std.testing.expectError( - case.expected, + error.InvalidDimensions, decodePayload(&reader, std.testing.allocator), ); } } -test "decode rejects duplicate and default style entries" { +test "decode normalizes duplicate default and invalid style entries" { const header: Header = .{ - .columns = 80, - .rows = 24, + .columns = 1, + .rows = 1, .style_count = 2, .hyperlink_count = 0, .style_capacity = 16, @@ -1247,23 +1162,66 @@ test "decode rejects duplicate and default style entries" { .flags = .{ .bold = true }, }; - var encoded: [Header.len + 2 * (2 + style.len)]u8 = undefined; - var writer: std.Io.Writer = .fixed(&encoded); - try header.encode(&writer); - try io.writeInt(&writer, TerminalStyleId, 1); - try style.encode(duplicate_style, &writer); - try io.writeInt(&writer, TerminalStyleId, 3); - try style.encode(duplicate_style, &writer); + // 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 reader: std.Io.Reader = .fixed(writer.buffered()); - try std.testing.expectError( - error.DuplicateStyle, - decodePayload(&reader, std.testing.allocator), + 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 = 80, - .rows = 24, + .columns = 1, + .rows = 1, .style_count = 1, .hyperlink_count = 0, .style_capacity = 16, @@ -1271,20 +1229,62 @@ test "decode rejects duplicate and default style entries" { .grapheme_capacity_bytes = 0, .string_capacity_bytes = 0, }; - var default_encoded: [Header.len + 2 + style.len]u8 = undefined; - var default_writer: std.Io.Writer = .fixed(&default_encoded); - try default_header.encode(&default_writer); - try io.writeInt(&default_writer, TerminalStyleId, 1); - try style.encode(.{}, &default_writer); + 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_writer.buffered()); - try std.testing.expectError( - error.DefaultStyle, - decodePayload(&default_reader, std.testing.allocator), + 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 rejects duplicate hyperlinks" { +test "decode reuses duplicate hyperlinks" { const header: Header = .{ .columns = 1, .rows = 1, @@ -1300,18 +1300,74 @@ test "decode rejects duplicate hyperlinks" { .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, 3); + 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()); - try std.testing.expectError( - error.DuplicateHyperlink, - decodePayload(&reader, std.testing.allocator), + 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), ); } @@ -1322,50 +1378,61 @@ test "decode ignores empty hyperlink strings" { .style_count = 0, .hyperlink_count = 1, .style_capacity = 0, - .hyperlink_capacity_bytes = 512, + .hyperlink_capacity_bytes = 0, .grapheme_capacity_bytes = 0, .string_capacity_bytes = 16, }; - const cases = [_]struct { - value: TerminalHyperlink, - }{ - .{ - .value = .{ - .id = .{ .implicit = 1 }, - .uri = "", - }, - }, - .{ - .value = .{ - .id = .{ .explicit = "" }, - .uri = "uri", - }, - }, - .{ - .value = .{ - .id = .{ .explicit = "id" }, - .uri = "", - }, - }, + 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", }; - for (cases) |case| { + // 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, 1); - try hyperlink.encode(case.value, &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.writeByte(0); - try encoded.writer.writeAll(&.{ 0, 0, 0, 0 }); - try io.writeInt(&encoded.writer, TerminalStyleId, 0); - try io.writeInt(&encoded.writer, TerminalHyperlinkId, 1); - try io.writeInt(&encoded.writer, u32, 'A'); - try io.writeInt(&encoded.writer, u32, 0); + try encoded.writer.writeAll(grid_writer.buffered()); var reader: std.Io.Reader = .fixed(encoded.written()); var decoded = try decodePayload( diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index e65c6d75d..c2ef53f1c 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -221,7 +221,7 @@ const TerminalHyperlink = terminal_hyperlink.Hyperlink; const TerminalStyle = terminal_style.Style; /// Errors possible while encoding fixed SCREEN payload fields. -const PayloadEncodeError = std.Io.Writer.Error || error{ +const PayloadEncodeError = hyperlink.EncodeError || error{ InvalidCursorFlags, InvalidCharsetState, InvalidKittyKeyboardIndex, @@ -285,7 +285,6 @@ pub fn encode( /// Errors possible while restoring a SCREEN and its declared PAGE sequence. pub const DecodeError = PayloadDecodeError || - hyperlink.DecodeError || page.DecodeError || record.Reader.InitError || record.Reader.FinishError || @@ -515,7 +514,7 @@ pub fn decode( } /// Errors possible while decoding fixed SCREEN payload fields. -const PayloadDecodeError = style.DecodeError || error{InvalidKey}; +const PayloadDecodeError = std.Io.Reader.Error || error{InvalidKey}; /// Flags encoded after the cursor's visual shape. pub const CursorFlags = packed struct(u8) { @@ -841,7 +840,7 @@ pub const SavedCursor = struct { return .{ .x = try io.readInt(reader, u16), .y = try io.readInt(reader, u16), - .pen = try style.decode(reader), + .pen = style.decodeOrDiscard(reader) catch .{}, .flags = try Flags.decode(reader), .charset = decodeCharsetState( try io.readInt(reader, u16), @@ -994,7 +993,7 @@ pub const Header = struct { try reader.takeByte(), ) orelse .block; const cursor_flags = try CursorFlags.decode(reader); - const cursor_pen = try style.decode(reader); + const cursor_pen: TerminalStyle = style.decodeOrDiscard(reader) catch .{}; const hyperlink_implicit_id = try io.readInt(reader, u32); // Charset and selective-erase state. @@ -1095,12 +1094,28 @@ pub fn encodeCursorHyperlink( pub fn decodeCursorHyperlink( reader: *std.Io.Reader, alloc: Allocator, -) hyperlink.DecodeError!CursorHyperlink { +) std.Io.Reader.Error!CursorHyperlink { if (try reader.peekByte() == 0) { _ = try reader.takeByte(); return null; } - return try hyperlink.decode(reader, alloc); + + 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")); @@ -1427,14 +1442,6 @@ test "header decoding rejects structural values" { invalid_key[0] = 2; var key_reader: std.Io.Reader = .fixed(&invalid_key); try std.testing.expectError(error.InvalidKey, Header.decode(&key_reader)); - - var invalid_style = valid; - invalid_style[10] = 3; - var style_reader: std.Io.Reader = .fixed(&invalid_style); - try std.testing.expectError( - error.InvalidColorKind, - Header.decode(&style_reader), - ); } test "header decoding normalizes unknown semantic values" { @@ -1442,6 +1449,7 @@ test "header decoding normalizes unknown semantic values" { fixture[8] = 4; // Unknown cursor style. fixture[9] = 0xFF; // Known flags, unknown semantic value, reserved bits. + fixture[10] = 3; // Unknown cursor foreground color kind. fixture[31] = 0xD0; // Invalid single shift plus a reserved bit. fixture[32] = 3; // Unknown protected mode. fixture[33] = 8; // Out-of-range Kitty keyboard index. @@ -1457,6 +1465,7 @@ test "header decoding normalizes unknown semantic values" { 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( @@ -1579,11 +1588,13 @@ test "saved cursor encoding rejects and decoding normalizes invalid state" { ); 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); @@ -2091,7 +2102,14 @@ test "SCREEN sequence failure preserves preceding bytes" { try std.testing.expectEqualStrings("prefix", destination.written()); } -test "SCREEN decode rejects an empty cursor hyperlink URI" { +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, ); @@ -2107,22 +2125,25 @@ test "SCREEN decode rejects an empty cursor hyperlink URI" { var record_writer = try record.Writer.init(&destination, .screen); try header.encode(record_writer.payloadWriter()); - try hyperlink.encode(.{ - .id = .{ .implicit = 1 }, - .uri = "", - }, record_writer.payloadWriter()); + try record_writer.payloadWriter().writeByte(1); // Implicit hyperlink. + try io.writeInt(record_writer.payloadWriter(), u32, 1); + try io.writeInt(record_writer.payloadWriter(), u32, 0); // Empty URI. try record_writer.finish(); - var source: std.Io.Reader = .fixed(destination.written()); - try std.testing.expectError( - error.InvalidUri, - decode( - &source, - std.testing.io, - std.testing.allocator, - .{ .cols = 1, .rows = 1 }, - ), + try page.encode( + screen.pages.getTopLeft(.active).node.pageAssumeResident(), + &destination, ); + + 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" { @@ -2161,10 +2182,9 @@ test "SCREEN decode ignores a PAGE with an empty hyperlink URI" { terminal_hyperlink.Id, 1, ); - try hyperlink.encode(.{ - .id = .{ .implicit = 1 }, - .uri = "", - }, page_writer.payloadWriter()); + try page_writer.payloadWriter().writeByte(1); // Implicit hyperlink. + try io.writeInt(page_writer.payloadWriter(), u32, 1); + try io.writeInt(page_writer.payloadWriter(), 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. diff --git a/src/terminal/snapshot/style.zig b/src/terminal/snapshot/style.zig index 69e0973c2..6df8ba03c 100644 --- a/src/terminal/snapshot/style.zig +++ b/src/terminal/snapshot/style.zig @@ -157,6 +157,21 @@ pub fn decode(reader: *std.Io.Reader) DecodeError!terminal_style.Style { }; } +/// 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, @@ -358,6 +373,16 @@ test "reject invalid flags and reserved field" { ); } +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| { From 7c64181b69df1ac6e7c1883aa939cfc2bfccde41 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 10:47:05 -0700 Subject: [PATCH 28/35] terminal/snapshot: history robustness Treat HISTORY row counts as canonical metadata rather than a reason to reject otherwise usable history. Restore topology from the declared PAGE sequence while keeping record framing, routing keys, and sequence boundaries strict. --- src/terminal/snapshot/history.zig | 117 ++++++++++++++++-------------- 1 file changed, 61 insertions(+), 56 deletions(-) diff --git a/src/terminal/snapshot/history.zig b/src/terminal/snapshot/history.zig index d4ea5a60d..7f375affd 100644 --- a/src/terminal/snapshot/history.zig +++ b/src/terminal/snapshot/history.zig @@ -12,9 +12,10 @@ //! //! The first SCREEN page may begin above the active area. Those incidental //! history rows are counted by `screen_overlap_rows`; they are not repeated -//! after HISTORY. `total_rows` is the authoritative number of logical rows -//! before the active area, including both the SCREEN overlap and the rows in -//! the following PAGE records. +//! after HISTORY. `total_rows` is the canonical number of logical rows before +//! the active area, including both the SCREEN overlap and the rows in the +//! following PAGE records. Decoders derive native row totals from the restored +//! pages and may ignore inconsistent row metadata. //! //! All integers are unsigned and little-endian. //! @@ -59,9 +60,10 @@ //! 16 +--------------------------------+ //! ``` //! -//! The sum of `screen_overlap_rows` and the logical row counts of all -//! following PAGE records must equal `total_rows`. A decoder uses `page_count`, -//! rather than another record tag, to find the end of the page sequence. +//! A canonical encoder sets `total_rows` to the sum of `screen_overlap_rows` +//! and the logical row counts of all following PAGE records. 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; @@ -92,7 +94,7 @@ pub const Header = struct { /// Number of complete PAGE records immediately following this record. page_count: u32, - /// Authoritative number of logical rows before the active area. + /// Canonical number of logical rows before the active area. total_rows: u64, /// History rows already included at the start of the first SCREEN page. @@ -236,12 +238,6 @@ pub const DecodeError = Allocator.Error || /// The Screen already contains complete pages before its active page. ExistingHistory, - - /// The claimed overlap does not match the restored SCREEN sequence. - InvalidScreenOverlap, - - /// The declared total does not match the restored historical rows. - InvalidHistoryRows, }; /// Restore one HISTORY and its declared PAGE records into a native Screen. @@ -275,14 +271,11 @@ pub fn decode( if (terminal_screen.pages.getTopLeft(.screen).node != active_top.node) { return error.ExistingHistory; } - if (header.screen_overlap_rows != active_top.y) { - return error.InvalidScreenOverlap; - } - if (header.total_rows < header.screen_overlap_rows) { - return error.InvalidHistoryRows; - } - var restored_rows: u64 = header.screen_overlap_rows; + // Row metadata is redundant with the restored SCREEN and PAGE topology. + // Consume the declared number of PAGE records, but derive all native row + // totals from their actual dimensions instead of rejecting inconsistent + // metadata from another implementation. for (0..header.page_count) |_| { // PAGE exposes its exact capacity before decoding the payload, allowing // the destination PageList to allocate the final backing memory once. @@ -292,26 +285,11 @@ pub fn decode( defer allocation.deinit(); try decoder.decode(allocation.page(), alloc); - // Validate the authoritative row total before publishing this page. - const page_rows = allocation.page().size.rows; - restored_rows = std.math.add( - u64, - restored_rows, - page_rows, - ) catch return error.InvalidHistoryRows; - if (restored_rows > header.total_rows) { - return error.InvalidHistoryRows; - } - const contains_prompt = hasSemanticPrompt(allocation.page()); try allocation.finalize(.prepend); if (contains_prompt) terminal_screen.semantic_prompt.seen = true; } - if (restored_rows != header.total_rows) { - return error.InvalidHistoryRows; - } - terminal_screen.pages.assertIntegrity(); terminal_screen.assertIntegrity(); } @@ -363,6 +341,14 @@ test "HISTORY header golden encoding and decoding" { 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" { @@ -657,7 +643,7 @@ test "HISTORY encodes and restores an empty sequence" { try std.testing.expectError(error.EndOfStream, decode_source.takeByte()); } -test "HISTORY rejects manifest mismatches" { +test "HISTORY accepts row metadata mismatches and rejects structural ones" { var terminal_screen = try TerminalScreen.init( std.testing.io, std.testing.allocator, @@ -669,8 +655,45 @@ test "HISTORY rejects manifest mismatches" { ); defer terminal_screen.deinit(); - // Encode a manifest without PAGE records and vary only the fixed fields so - // each structural validation boundary is exercised in isolation. + // Row counts are redundant metadata. Even internally inconsistent values + // do not prevent restoring the topology declared by page_count. + const accepted = [_]Header{ + .{ + .key = .primary, + .page_count = 0, + .total_rows = 0, + .screen_overlap_rows = 1, + }, + .{ + .key = .primary, + .page_count = 0, + .total_rows = std.math.maxInt(u64), + .screen_overlap_rows = std.math.maxInt(u16), + }, + }; + for (accepted) |header| { + var destination: std.Io.Writer.Allocating = .init( + std.testing.allocator, + ); + defer destination.deinit(); + var record_writer = try record.Writer.init(&destination, .history); + try header.encode(record_writer.payloadWriter()); + try record_writer.finish(); + + var source: std.Io.Reader = .fixed(destination.written()); + try decode( + &source, + std.testing.allocator, + .primary, + &terminal_screen, + ); + try std.testing.expectError(error.EndOfStream, source.takeByte()); + terminal_screen.pages.assertIntegrity(); + terminal_screen.assertIntegrity(); + } + + // 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, @@ -685,24 +708,6 @@ test "HISTORY rejects manifest mismatches" { }, .expected = error.UnexpectedScreenKey, }, - .{ - .header = .{ - .key = .primary, - .page_count = 0, - .total_rows = 1, - .screen_overlap_rows = 1, - }, - .expected = error.InvalidScreenOverlap, - }, - .{ - .header = .{ - .key = .primary, - .page_count = 0, - .total_rows = 1, - .screen_overlap_rows = 0, - }, - .expected = error.InvalidHistoryRows, - }, .{ .header = .{ .key = .primary, From 9d1c6a9217bc79e9598bacdf3c8160bfaddf6be7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 10:53:23 -0700 Subject: [PATCH 29/35] terminal/snapshot: terminal robustness Normalize unknown terminal-wide semantic fields during restore while keeping dimensions and screen count structural. Preserve canonical encoding, ignore reserved mode and tab-stop bits, reset invalid color and scrolling state, and clamp finite scrollback policies to the native range. --- src/terminal/snapshot/terminal.zig | 345 ++++++++++++++++++----------- 1 file changed, 211 insertions(+), 134 deletions(-) diff --git a/src/terminal/snapshot/terminal.zig b/src/terminal/snapshot/terminal.zig index 9bfda575f..343392fa4 100644 --- a/src/terminal/snapshot/terminal.zig +++ b/src/terminal/snapshot/terminal.zig @@ -114,6 +114,11 @@ //! 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 @@ -124,7 +129,7 @@ //! //! 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 zero. +//! byte are canonically zero and ignored by decoders. //! //! ### Dynamic RGB //! @@ -143,7 +148,8 @@ //! ``` //! //! Presence values are zero or one. The corresponding RGB bytes must be zero -//! when a value is absent. +//! when a value is absent. Decoders treat every other presence value as absent +//! and ignore RGB bytes belonging to an absent value. //! //! ### Palette //! @@ -205,7 +211,7 @@ //! //! 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. +//! therefore requires a snapshot version bump. Decoders ignore reserved bits. //! //! ## Field classification //! @@ -290,22 +296,12 @@ const PayloadEncodeError = std.Io.Writer.Error || /// Errors possible while decoding a TERMINAL payload. const PayloadDecodeError = std.Io.Reader.Error || Allocator.Error || - ValidationError || error{ - InvalidStatusDisplay, - InvalidCursorIsDefault, - InvalidCursorStyle, - InvalidCursorBlink, - InvalidShellRedraw, - InvalidModifyOtherKeys, - InvalidMouseEvent, - InvalidMouseFormat, - InvalidMouseShiftCapture, - InvalidMouseShape, - InvalidPasswordInput, - InvalidModes, - InvalidDynamicRGB, - InvalidTabStops, + /// 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. @@ -504,7 +500,7 @@ pub const Header = struct { ); } - /// Decode and validate the fixed TERMINAL payload header. + /// 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); @@ -520,77 +516,78 @@ pub const Header = struct { const status_display = enumFromInt( terminal_ansi.StatusDisplay, try reader.takeByte(), - ) orelse return error.InvalidStatusDisplay; - const active_screen_key = enumFromInt( - TerminalScreenKey, - try io.readInt(reader, u16), - ) orelse return error.InvalidActiveScreenKey; + ) 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 = - if (previous_raw == std.math.maxInt(u32)) - null - else - std.math.cast(u21, previous_raw) orelse - return error.InvalidPreviousCodepoint; + 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 => return error.InvalidCursorIsDefault, + else => true, }; const cursor_default_style = enumFromInt( TerminalScreen.CursorStyle, try reader.takeByte(), - ) orelse return error.InvalidCursorStyle; + ) orelse .block; const cursor_default_blink: ?bool = switch (try reader.takeByte()) { 0 => null, 1 => false, 2 => true, - else => return error.InvalidCursorBlink, + else => null, }; // Terminal input, semantic redraw, and pointer behavior. const shell_redraw = enumFromInt( terminal_osc.semantic_prompt.Redraw, try reader.takeByte(), - ) orelse return error.InvalidShellRedraw; + ) orelse .true; const modify_other_keys_2 = switch (try reader.takeByte()) { 0 => false, 1 => true, - else => return error.InvalidModifyOtherKeys, + else => false, }; const mouse_event = enumFromInt( terminal_mouse.Event, try reader.takeByte(), - ) orelse return error.InvalidMouseEvent; + ) orelse .none; const mouse_format = enumFromInt( terminal_mouse.Format, try reader.takeByte(), - ) orelse return error.InvalidMouseFormat; + ) orelse .x10; const mouse_shift_capture: ?bool = switch (try reader.takeByte()) { 0 => null, 1 => false, 2 => true, - else => return error.InvalidMouseShiftCapture, + else => null, }; const mouse_shape = enumFromInt( terminal_mouse.Shape, try reader.takeByte(), - ) orelse return error.InvalidMouseShape; + ) orelse .text; const password_input = switch (try reader.takeByte()) { 0 => false, 1 => true, - else => return error.InvalidPasswordInput, + 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 = std.math.cast(ModeBits, raw) orelse - return error.InvalidModes; + const bits: ModeBits = @truncate(raw); value.* = @bitCast(bits); } @@ -607,16 +604,52 @@ pub const Header = struct { 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 = scrolling_region_top, - .scrolling_region_bottom = scrolling_region_bottom, - .scrolling_region_left = scrolling_region_left, - .scrolling_region_right = scrolling_region_right, + .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, @@ -646,7 +679,6 @@ pub const Header = struct { .max_scrollback_bytes = max_scrollback_bytes, .max_scrollback_rows = max_scrollback_rows, }; - try result.validate(); return result; } @@ -828,9 +860,7 @@ fn decodePayload( for (0..8) |bit| { const mask = @as(u8, 1) << @intCast(bit); const column = byte_index * 8 + bit; - if (column >= header.columns) { - if (raw & mask != 0) return error.InvalidTabStops; - } else if (raw & mask != 0) { + if (column < header.columns and raw & mask != 0) { tabstops.set(column); } } @@ -908,9 +938,6 @@ pub const DecodeError = PayloadDecodeError || error{ /// The next record is valid but is not a TERMINAL. UnexpectedRecordTag, - - /// A wire scrollback policy does not fit the native platform. - ScrollbackLimitOverflow, }; /// Decode one framed TERMINAL record directly into native terminal state. @@ -940,10 +967,10 @@ pub fn decode( defer payload.deinit(alloc); const header = payload.header; - const max_scrollback_bytes = try nativeScrollbackLimit( + const max_scrollback_bytes = nativeScrollbackLimit( header.max_scrollback_bytes, ); - const max_scrollback_lines = try nativeScrollbackLimit( + const max_scrollback_lines = nativeScrollbackLimit( header.max_scrollback_rows, ); @@ -1077,28 +1104,14 @@ fn encodeDynamicRGB( fn decodeDynamicRGB( reader: *std.Io.Reader, -) (std.Io.Reader.Error || error{InvalidDynamicRGB})!DynamicRGB { +) std.Io.Reader.Error!DynamicRGB { const default_present = try reader.takeByte(); const default_rgb = try decodeRGB(reader); - const default: ?RGB = switch (default_present) { - 0 => if (default_rgb.eql(.{})) - null - else - return error.InvalidDynamicRGB, - 1 => default_rgb, - else => return error.InvalidDynamicRGB, - }; + 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 = switch (override_present) { - 0 => if (override_rgb.eql(.{})) - null - else - return error.InvalidDynamicRGB, - 1 => override_rgb, - else => return error.InvalidDynamicRGB, - }; + const override: ?RGB = if (override_present == 1) override_rgb else null; return .{ .default = default, .override = override }; } @@ -1119,10 +1132,12 @@ fn encodeScrollbackLimit(value: usize) HeaderInitError!?u64 { fn nativeScrollbackLimit( value: ?u64, -) error{ScrollbackLimitOverflow}!?usize { +) ?usize { const present = value orelse return null; - return std.math.cast(usize, present) orelse - error.ScrollbackLimitOverflow; + return @intCast(@min( + present, + @as(u64, std.math.maxInt(usize) - 1), + )); } const test_header: Header = header: { @@ -1256,7 +1271,7 @@ test "TERMINAL header golden encoding and decoding" { } } -test "TERMINAL header rejects invalid values" { +test "TERMINAL header encoding rejects noncanonical values" { const testing = std.testing; // Semantic values are validated before encoding writes anything. @@ -1278,39 +1293,20 @@ test "TERMINAL header rejects invalid values" { invalid_header.encode(&writer), ); try testing.expectEqual(@as(usize, 0), writer.end); +} - // Single-byte registries and boolean encodings reject every value just - // beyond their current snapshot range. - const ByteCase = struct { - offset: usize, - value: u8, - expected: anyerror, - }; - const byte_cases = [_]ByteCase{ - .{ .offset = 20, .value = 2, .expected = error.InvalidStatusDisplay }, - .{ .offset = 21, .value = 2, .expected = error.InvalidActiveScreenKey }, - .{ .offset = 29, .value = 2, .expected = error.InvalidCursorIsDefault }, - .{ .offset = 30, .value = 4, .expected = error.InvalidCursorStyle }, - .{ .offset = 31, .value = 3, .expected = error.InvalidCursorBlink }, - .{ .offset = 32, .value = 3, .expected = error.InvalidShellRedraw }, - .{ .offset = 33, .value = 2, .expected = error.InvalidModifyOtherKeys }, - .{ .offset = 34, .value = 5, .expected = error.InvalidMouseEvent }, - .{ .offset = 35, .value = 5, .expected = error.InvalidMouseFormat }, - .{ .offset = 36, .value = 3, .expected = error.InvalidMouseShiftCapture }, - .{ .offset = 37, .value = 34, .expected = error.InvalidMouseShape }, - .{ .offset = 38, .value = 2, .expected = error.InvalidPasswordInput }, - .{ .offset = 44, .value = 4, .expected = error.InvalidModes }, - .{ .offset = 63, .value = 2, .expected = error.InvalidDynamicRGB }, - .{ .offset = 68, .value = 1, .expected = error.InvalidDynamicRGB }, - }; - for (byte_cases) |case| { - var fixture = test_header_fixture; - fixture[case.offset] = case.value; - var reader: std.Io.Reader = .fixed(&fixture); - try testing.expectError(case.expected, Header.decode(&reader)); - } +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), + ); - // Cross-field and multi-byte invariants are validated after decoding. var invalid_screen_count = test_header_fixture; invalid_screen_count[23] = 3; var screen_count_reader: std.Io.Reader = .fixed(&invalid_screen_count); @@ -1318,35 +1314,109 @@ test "TERMINAL header rejects invalid values" { 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.expectError( - error.InvalidActiveScreenKey, - Header.decode(&active_screen_reader), + try testing.expectEqual( + TerminalScreenKey.primary, + (try Header.decode(&active_screen_reader)).active_screen_key, ); - var invalid_scrolling_region = test_header_fixture; - invalid_scrolling_region[14] = 0x04; - invalid_scrolling_region[15] = 0x03; - var scrolling_region_reader: std.Io.Reader = .fixed( - &invalid_scrolling_region, - ); - try testing.expectError( - error.InvalidScrollingRegion, - Header.decode(&scrolling_region_reader), - ); - - var invalid_codepoint = test_header_fixture; - invalid_codepoint[25] = 0x00; - invalid_codepoint[26] = 0xd8; - invalid_codepoint[27] = 0x00; - invalid_codepoint[28] = 0x00; - var codepoint_reader: std.Io.Reader = .fixed(&invalid_codepoint); - try testing.expectError( - error.InvalidPreviousCodepoint, - Header.decode(&codepoint_reader), + 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).?, ); } @@ -1469,7 +1539,7 @@ test "TERMINAL payload rejects noncanonical state" { try testing.expectEqual(@as(usize, 0), writer.end); } -test "TERMINAL payload rejects tab-stop padding" { +test "TERMINAL payload ignores tab-stop padding" { const testing = std.testing; var tabstops = try TerminalTabstops.init( @@ -1504,10 +1574,17 @@ test "TERMINAL payload rejects tab-stop padding" { defer testing.allocator.free(invalid_padding); invalid_padding[last_tabstop] |= 1 << 7; var padding_reader: std.Io.Reader = .fixed(invalid_padding); - try testing.expectError( - error.InvalidTabStops, - decodePayload(&padding_reader, testing.allocator), + 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" { From 58e92098a209a137ff6af376086ae965124ec5f1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 10:57:28 -0700 Subject: [PATCH 30/35] terminal/snapshot: snapshot robustness Route HISTORY sequences by their encoded screen key so both keyed sequence groups can arrive in either order. Keep undeclared and duplicate routing strict, separate HISTORY manifest parsing from page restoration, and clear decoder-only generation state before returning the terminal. --- src/terminal/snapshot/history.zig | 133 ++++++++++++++++++----------- src/terminal/snapshot/main.zig | 21 ++--- src/terminal/snapshot/snapshot.zig | 94 +++++++++++++++++--- 3 files changed, 173 insertions(+), 75 deletions(-) diff --git a/src/terminal/snapshot/history.zig b/src/terminal/snapshot/history.zig index 7f375affd..9de7b4c3f 100644 --- a/src/terminal/snapshot/history.zig +++ b/src/terminal/snapshot/history.zig @@ -223,23 +223,88 @@ pub fn encode( } /// Errors possible while restoring one HISTORY and its PAGE sequence. -pub const DecodeError = Allocator.Error || - Header.DecodeError || - page.DecodeError || - record.Reader.InitError || - record.Reader.FinishError || - TerminalPageList.PageAllocation.FinalizeError || +pub const DecodeError = Decoder.InitError || + Decoder.RestoreError || error{ - /// The next record is valid but is not a HISTORY. - UnexpectedRecordTag, - /// The HISTORY key does not match the caller-selected screen. UnexpectedScreenKey, - - /// The Screen already contains complete pages before its active page. - ExistingHistory, }; +/// 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; + } + + // Row metadata is redundant with the restored SCREEN and PAGE + // topology. Consume the declared PAGE records, but derive all native + // row totals from their actual 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 @@ -252,46 +317,10 @@ pub fn decode( expected_key: TerminalScreenKey, terminal_screen: *TerminalScreen, ) DecodeError!void { - // Decode and finish the self-contained HISTORY manifest before consuming - // any of its following PAGE records. - const header: Header = header: { - 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(); - break :header header; - }; - - if (header.key != expected_key) return error.UnexpectedScreenKey; - - // 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; - } - - // Row metadata is redundant with the restored SCREEN and PAGE topology. - // Consume the declared number of PAGE records, but derive all native row - // totals from their actual dimensions instead of rejecting inconsistent - // metadata from another implementation. - for (0..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(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(); + 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 { diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index c08c437fd..2c7d58335 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -55,23 +55,20 @@ //! +----------------------------------------+ //! | READY | //! +----------------------------------------+ -//! | HISTORY (primary) | -//! | PAGE * history.page_count | -//! +----------------------------------------+ -//! | HISTORY (alternate, when present) | -//! | PAGE * history.page_count | +//! | HISTORY * terminal.screen_count | +//! | PAGE * each history.page_count | //! +----------------------------------------+ //! | FINISH | //! +----------------------------------------+ //! ``` //! -//! The SCREEN sequences may appear in any key order. Each key must be unique -//! and must identify one of the screens declared by TERMINAL. They contain the -//! complete pages needed to restore each active area. A HISTORY sequence -//! 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 is followed by -//! end-of-file. +//! 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 is followed by end-of-file. //! //! READY and FINISH contain BLAKE3-256 digests of all preceding snapshot bytes. //! READY therefore validates the renderable active-state prefix. FINISH covers diff --git a/src/terminal/snapshot/snapshot.zig b/src/terminal/snapshot/snapshot.zig index 90d1c8402..0f3635487 100644 --- a/src/terminal/snapshot/snapshot.zig +++ b/src/terminal/snapshot/snapshot.zig @@ -93,13 +93,21 @@ pub const DecodeError = envelope.DecodeError || /// 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. /// /// The reader must contain exactly one snapshot through FINISH. Restoration is /// transactional: the returned terminal is either complete and ready or -/// not (error return). +/// 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( source: *std.Io.Reader, io_: std.Io, @@ -175,13 +183,25 @@ pub fn decode( hashing.hasher.final(&digest); try checkpoint.decode(.ready, digest, reader); - // HISTORY mutates only the restored terminal owned by this function. - // Although an individual history decoder may publish recent pages as they - // validate, any later full-snapshot failure deinitializes the whole result. - const keys = [_]TerminalScreenKey{ .primary, .alternate }; - for (keys) |key| { - const restored = result.screens.get(key) orelse continue; - try history.decode(reader, alloc, key, restored); + // 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 @@ -189,6 +209,7 @@ pub fn decode( hashing.hasher.final(&digest); try checkpoint.decode(.finish, digest, source); + const keys = [_]TerminalScreenKey{ .primary, .alternate }; if (comptime build_options.slow_runtime_safety) { for (keys) |key| { const restored = result.screens.get(key) orelse continue; @@ -196,6 +217,11 @@ pub fn decode( 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; } @@ -334,8 +360,7 @@ test "complete snapshot round trip with history and alternate screen" { reencoded.written(), ); - // SCREEN keys make their order independent. Keep HISTORY canonical here; - // only the active-state screen sequences are intentionally reversed. + // SCREEN and HISTORY keys make both sequence groups order independent. var reversed: std.Io.Writer.Allocating = .init(testing.allocator); defer reversed.deinit(); try envelope.encode(&reversed.writer); @@ -343,12 +368,12 @@ test "complete snapshot round trip with history and alternate screen" { try screen.encode(t.screens.get(.alternate).?, .alternate, &reversed); try screen.encode(primary, .primary, &reversed); try checkpoint.encode(.ready, &reversed); - try history.encode(primary, .primary, &reversed); try history.encode( t.screens.get(.alternate).?, .alternate, &reversed, ); + try history.encode(primary, .primary, &reversed); try checkpoint.encode(.finish, &reversed); var reversed_source: std.Io.Reader = .fixed(reversed.written()); @@ -362,6 +387,14 @@ test "complete snapshot round trip with history and alternate screen" { 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" { @@ -535,6 +568,23 @@ test "complete snapshot rejects ordering checkpoints and trailing data" { decode(&undeclared_source, testing.io, testing.allocator), ); + // 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(); + try envelope.encode(&undeclared_history.writer); + try terminal.encode(&t, &undeclared_history); + try screen.encode(primary, .primary, &undeclared_history); + try checkpoint.encode(.ready, &undeclared_history); + try history.encode(primary, .alternate, &undeclared_history); + var undeclared_history_source: std.Io.Reader = .fixed( + undeclared_history.written(), + ); + try testing.expectError( + error.UnexpectedHistoryKey, + decode(&undeclared_history_source, testing.io, testing.allocator), + ); + // The declared count cannot be satisfied by repeating the same key. _ = try t.switchScreen(.alternate); var duplicate: std.Io.Writer.Allocating = .init(testing.allocator); @@ -548,4 +598,26 @@ test "complete snapshot rejects ordering checkpoints and trailing data" { error.DuplicateScreen, decode(&duplicate_source, testing.io, testing.allocator), ); + + // 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(); + try envelope.encode(&duplicate_history.writer); + try terminal.encode(&t, &duplicate_history); + try screen.encode(primary, .primary, &duplicate_history); + try screen.encode( + t.screens.get(.alternate).?, + .alternate, + &duplicate_history, + ); + try checkpoint.encode(.ready, &duplicate_history); + try history.encode(primary, .primary, &duplicate_history); + try history.encode(primary, .primary, &duplicate_history); + var duplicate_history_source: std.Io.Reader = .fixed( + duplicate_history.written(), + ); + try testing.expectError( + error.DuplicateHistory, + decode(&duplicate_history_source, testing.io, testing.allocator), + ); } From f0fe788fcc7ef47f7e59745e7884c9bcff295f17 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 11:17:56 -0700 Subject: [PATCH 31/35] terminal/snapshot: less buffering, better stream writing Stream complete snapshot records to any std.Io.Writer while retaining one reusable payload buffer for length and CRC calculation. Update BLAKE3 incrementally so checkpoints no longer require rehashing an allocating destination. Wrap decode hashing in StreamReader to enforce exact checkpoint boundaries. Preserve v1 bytes while allowing snapshots to begin at the current writer position and retaining only valid prefixes on failures. --- src/terminal/snapshot/checkpoint.zig | 149 +++++++++++------ src/terminal/snapshot/history.zig | 57 ++++--- src/terminal/snapshot/main.zig | 16 +- src/terminal/snapshot/page.zig | 29 ++-- src/terminal/snapshot/record.zig | 210 +++++++++++++++++++----- src/terminal/snapshot/screen.zig | 152 ++++++++++------- src/terminal/snapshot/snapshot.zig | 235 ++++++++++++++++++--------- src/terminal/snapshot/terminal.zig | 59 +++++-- 8 files changed, 622 insertions(+), 285 deletions(-) diff --git a/src/terminal/snapshot/checkpoint.zig b/src/terminal/snapshot/checkpoint.zig index 7d8f0f097..dabf37773 100644 --- a/src/terminal/snapshot/checkpoint.zig +++ b/src/terminal/snapshot/checkpoint.zig @@ -30,7 +30,7 @@ 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 = [Blake3.digest_length]u8; +pub const Digest = record.PrefixDigest; comptime { std.debug.assert(@sizeOf(Digest) == 32); @@ -52,21 +52,20 @@ pub const Kind = enum { pub const EncodeError = std.Io.Writer.Error || record.Writer.FinishError; -/// Append one checkpoint covering every byte already in `destination`. +/// Append one checkpoint covering every byte already emitted by `stream`. /// -/// If writing fails, the incomplete checkpoint is removed while all covered -/// prefix bytes remain unchanged. +/// Finalizing does not consume the running hasher. READY is therefore included +/// as the stream continues toward FINISH. pub fn encode( kind: Kind, - destination: *std.Io.Writer.Allocating, + stream: *record.Writer, ) EncodeError!void { - var digest: Digest = undefined; - Blake3.hash(destination.written(), &digest, .{}); + const digest = stream.prefixDigest(); - var record_writer = try record.Writer.init(destination, kind.tag()); - errdefer record_writer.cancel(); - try record_writer.payloadWriter().writeAll(&digest); - try record_writer.finish(); + const payload = stream.begin(kind.tag()); + errdefer stream.cancel(); + try payload.writeAll(&digest); + try stream.finish(); } pub const DecodeError = record.Reader.InitError || @@ -82,15 +81,20 @@ pub const DecodeError = record.Reader.InitError || TrailingData, }; -/// Decode and validate one checkpoint against its preceding-byte digest. +/// Decode and validate one checkpoint against the running prefix digest. /// -/// `expected` must be finalized before any byte of this record is included in -/// the caller's running digest. FINISH additionally requires end-of-file. +/// 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, - expected: Digest, - source: *std.Io.Reader, + 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()) { @@ -120,8 +124,13 @@ test "READY golden encoding and BLAKE3-256 registry" { var snapshot: std.Io.Writer.Allocating = .init(std.testing.allocator); defer snapshot.deinit(); - try snapshot.writer.writeAll(prefix); - try encode(.ready, &snapshot); + 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, @@ -158,8 +167,10 @@ test "READY golden encoding and BLAKE3-256 registry" { ); // Decode the checked-in record rather than the generated candidate. - var source: std.Io.Reader = .fixed(test_ready_fixture[prefix.len..]); - try decode(.ready, actual, &source); + 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" { @@ -167,30 +178,38 @@ test "READY and FINISH checkpoint coverage" { var snapshot: std.Io.Writer.Allocating = .init(testing.allocator); defer snapshot.deinit(); - try snapshot.writer.writeAll("prefix"); + 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; - var ready_digest: Digest = undefined; - Blake3.hash(snapshot.written(), &ready_digest, .{}); - try encode(.ready, &snapshot); + const ready_digest = stream.prefixDigest(); + try encode(.ready, &stream); // History follows READY and is included by FINISH. - try snapshot.writer.writeAll("history"); + try stream.writer().writeAll("history"); const finish_offset = snapshot.written().len; - var finish_digest: Digest = undefined; - Blake3.hash(snapshot.written(), &finish_digest, .{}); - try encode(.finish, &snapshot); + const finish_digest = stream.prefixDigest(); + try encode(.finish, &stream); - var ready_source: std.Io.Reader = .fixed( - snapshot.written()[ready_offset..], - ); - try decode(.ready, ready_digest, &ready_source); + // 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); - var finish_source: std.Io.Reader = .fixed( - snapshot.written()[finish_offset..], + try testing.expectEqual( + snapshot.written().len - finish_offset, + record.Header.len + @sizeOf(Digest), ); - try decode(.finish, finish_digest, &finish_source); } test "checkpoint rejects wrong tags, digests, and FINISH trailing data" { @@ -198,42 +217,68 @@ test "checkpoint rejects wrong tags, digests, and FINISH trailing data" { var snapshot: std.Io.Writer.Allocating = .init(testing.allocator); defer snapshot.deinit(); - try snapshot.writer.writeAll("prefix"); + var stream: record.Writer = .init( + testing.allocator, + &snapshot.writer, + ); + defer stream.deinit(); + try stream.writer().writeAll("prefix"); const checkpoint_offset = snapshot.written().len; - var digest: Digest = undefined; - Blake3.hash(snapshot.written(), &digest, .{}); - try encode(.ready, &snapshot); + try encode(.ready, &stream); - var wrong_tag: std.Io.Reader = .fixed( + 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, digest, &wrong_tag), + decode(.finish, &wrong_tag), ); - var invalid_digest = digest; - invalid_digest[0] ^= 1; - var invalid_digest_source: std.Io.Reader = .fixed( - snapshot.written()[checkpoint_offset..], + // 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, &invalid_digest_source), + decode(.ready, &invalid_digest_stream), ); var finished: std.Io.Writer.Allocating = .init(testing.allocator); defer finished.deinit(); - try finished.writer.writeAll("prefix"); - const finish_offset = finished.written().len; - try encode(.finish, &finished); + 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()[finish_offset..], + 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 testing.expectError( error.TrailingData, - decode(.finish, digest, &trailing_source), + decode(.finish, &trailing_stream), ); } diff --git a/src/terminal/snapshot/history.zig b/src/terminal/snapshot/history.zig index 9de7b4c3f..b49c05081 100644 --- a/src/terminal/snapshot/history.zig +++ b/src/terminal/snapshot/history.zig @@ -162,16 +162,13 @@ pub const EncodeError = Allocator.Error || /// 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. On failure, the entire sequence is -/// removed while earlier destination bytes remain. +/// 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: *std.Io.Writer.Allocating, + destination: *record.Writer, ) EncodeError!void { - const sequence_start = destination.written().len; - errdefer destination.shrinkRetainingCapacity(sequence_start); - // 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. @@ -205,10 +202,10 @@ pub fn encode( // HISTORY declares exactly how many PAGE records follow and how their rows // combine with the overlap already carried by SCREEN. { - var record_writer = try record.Writer.init(destination, .history); - errdefer record_writer.cancel(); - try header.encode(record_writer.payloadWriter()); - try record_writer.finish(); + 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. @@ -432,13 +429,18 @@ test "HISTORY encodes newest first and restores complete history" { std.testing.allocator, ); defer destination.deinit(); - try screen.encode(&source_screen, .primary, &destination); + 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, &destination); + try encode(&source_screen, .primary, &stream); try std.testing.expectEqual(oldest_storage, oldest_history.storage()); try std.testing.expectEqual(newest_storage, newest_history.storage()); @@ -645,7 +647,12 @@ test "HISTORY encodes and restores an empty sequence" { std.testing.allocator, ); defer destination.deinit(); - try encode(&terminal_screen, .primary, &destination); + 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; @@ -705,9 +712,15 @@ test "HISTORY accepts row metadata mismatches and rejects structural ones" { std.testing.allocator, ); defer destination.deinit(); - var record_writer = try record.Writer.init(&destination, .history); - try header.encode(record_writer.payloadWriter()); - try record_writer.finish(); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + const payload = stream.begin(.history); + errdefer stream.cancel(); + try header.encode(payload); + try stream.finish(); var source: std.Io.Reader = .fixed(destination.written()); try decode( @@ -752,9 +765,15 @@ test "HISTORY accepts row metadata mismatches and rejects structural ones" { std.testing.allocator, ); defer destination.deinit(); - var record_writer = try record.Writer.init(&destination, .history); - try case.header.encode(record_writer.payloadWriter()); - try record_writer.finish(); + 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( diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index 2c7d58335..188cab684 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -77,20 +77,26 @@ //! //! ## Encoding //! -//! Encode a complete snapshot into an empty allocating writer: +//! Encode a complete snapshot into any writer: //! //! ```zig //! var output: std.Io.Writer.Allocating = .init(alloc); //! defer output.deinit(); //! -//! try snapshot.encode(&terminal, &output); +//! try snapshot.encode(alloc, &output.writer, &terminal); //! //! const bytes = output.written(); //! ``` //! -//! We have to use an allocating writer because record formats require -//! encoding the length and CRC in the header, so we need a seekable -//! format. +//! 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`. diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 335d74127..e71bc53c4 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -135,17 +135,16 @@ pub const EncodeError = PayloadEncodeError || record.Writer.FinishError; /// Encode one complete PAGE record from a native page. /// -/// The record is appended to `destination`. Its header is reserved before the -/// payload is encoded, then backpatched with the payload length and CRC32C. If -/// encoding fails, the partial record is removed while earlier bytes remain. +/// 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: *std.Io.Writer.Allocating, + destination: *record.Writer, ) EncodeError!void { - var record_writer = try record.Writer.init(destination, .page); - errdefer record_writer.cancel(); - try encodePayload(page, record_writer.payloadWriter()); - try record_writer.finish(); + 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. @@ -755,7 +754,12 @@ test "framed PAGE golden empty record" { var destination: std.Io.Writer.Allocating = .init(std.testing.allocator); defer destination.deinit(); - try encode(&page, &destination); + 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", @@ -794,9 +798,14 @@ test "framed PAGE rejects incomplete wide cells transactionally" { 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, &destination), + encode(&page, &stream), ); try testing.expectEqualStrings("prefix", destination.written()); } diff --git a/src/terminal/snapshot/record.zig b/src/terminal/snapshot/record.zig index 19c9285a5..0126c2bcd 100644 --- a/src/terminal/snapshot/record.zig +++ b/src/terminal/snapshot/record.zig @@ -19,9 +19,16 @@ //! 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; @@ -57,7 +64,7 @@ pub const Header = struct { 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 == 10); + assert(len == 10); } /// Determines how the payload is decoded. @@ -142,71 +149,140 @@ pub const Checksum = struct { } }; -/// Builds one complete record. +/// Streams complete records while retaining only one payload at a time. /// -/// This Writer requires an Allocating std.Io.Writer because the record -/// format requires reading the full payload and rewinding in order to -/// write the length + CRC without encoding twice. +/// 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 { - destination: *std.Io.Writer.Allocating, - tag: Tag, - record_start: usize, + hashing: std.Io.Writer.Hashed(Blake3), + scratch: std.Io.Writer.Allocating, + active_tag: ?Tag, - /// Reserve space for a record at the current end of `destination`. - /// Once this is called, callers MUST NOT write anything else to - /// the writer until `finish` or `cancel` is called. pub fn init( - destination: *std.Io.Writer.Allocating, - tag: Tag, - ) std.Io.Writer.Error!Writer { - const record_start = destination.written().len; - errdefer destination.shrinkRetainingCapacity(record_start); - try destination.writer.splatByteAll(0, Header.len); + alloc: Allocator, + destination: *std.Io.Writer, + ) Writer { return .{ - .destination = destination, - .tag = tag, - .record_start = record_start, + .hashing = destination.hashed(Blake3.init(.{}), &.{}), + .scratch = .init(alloc), + .active_tag = null, }; } - /// Return the writer through which the payload is encoded exactly once. - pub fn payloadWriter(self: *Writer) *std.Io.Writer { - return &self.destination.writer; + pub fn deinit(self: *Writer) void { + assert(self.active_tag == null); + assert(self.scratch.writer.end == 0); + self.scratch.deinit(); + self.* = undefined; } - pub const FinishError = error{ + /// 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, }; - /// Marked the completed record with the payload length and CRC32C. + /// 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 { - const bytes = self.destination.written(); - const payload_start = self.record_start + Header.len; + 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, - bytes.len - payload_start, + payload.len, ) orelse return error.PayloadTooLarge; - const payload = bytes[payload_start..]; - // Calculate our CRC - var checksum: Checksum = .init(self.tag, payload_len); + // 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; - // Build the header and encode it directly into the header const header: Header = .{ - .tag = self.tag, + .tag = tag, .payload_len = payload_len, .crc32c = checksum.final(), }; - var header_writer: std.Io.Writer = .fixed(bytes[self.record_start..payload_start]); + 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 this record. This makes it safe to use the underlying - /// alloating writer again as if nothing happened. + /// 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.destination.shrinkRetainingCapacity(self.record_start); + 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; } }; @@ -437,14 +513,20 @@ test "payload limit does not consume the next record" { try std.testing.expectEqualStrings("next", try source.take(4)); } -test "record writer appends and backpatches framing" { +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(); - var record_writer = try Writer.init(&destination, .page); - try record_writer.payloadWriter().writeAll("payload"); - try record_writer.finish(); + 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]); @@ -468,10 +550,50 @@ 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(); - var record_writer = try Writer.init(&destination, .page); - try record_writer.payloadWriter().writeAll("partial"); - record_writer.cancel(); + 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 index c2ef53f1c..2fc3d0d8b 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -238,16 +238,13 @@ pub const EncodeError = PayloadEncodeError || page.EncodeError || error{ /// 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. If encoding any record fails, the entire sequence -/// is removed while earlier destination bytes remain. +/// 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: *std.Io.Writer.Allocating, + destination: *record.Writer, ) EncodeError!void { - const sequence_start = destination.written().len; - errdefer destination.shrinkRetainingCapacity(sequence_start); - // 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; @@ -262,15 +259,15 @@ pub fn encode( // SCREEN declares exactly how many immediately following PAGE records // belong to it. { - var record_writer = try record.Writer.init(destination, .screen); - errdefer record_writer.cancel(); + const payload = destination.begin(.screen); + errdefer destination.cancel(); try encodePayload( screen, key, encoded_page_count, - record_writer.payloadWriter(), + payload, ); - try record_writer.finish(); + try destination.finish(); } // PageList never compresses the active-boundary page or any later page. @@ -1705,7 +1702,12 @@ test "framed native SCREEN and PAGE sequence" { ); defer destination.deinit(); try destination.writer.writeAll("prefix"); - try encode(&screen, .alternate, &destination); + var stream: record.Writer = .init( + std.testing.allocator, + &destination.writer, + ); + defer stream.deinit(); + try encode(&screen, .alternate, &stream); try std.testing.expectEqualStrings( "prefix", @@ -1849,7 +1851,12 @@ test "SCREEN encodes the minimal complete-page active suffix" { std.testing.allocator, ); defer destination.deinit(); - try encode(&screen, .primary, &destination); + 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; @@ -1964,6 +1971,11 @@ test "SCREEN restoration normalizes invalid cursor positions" { 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; @@ -1971,14 +1983,15 @@ test "SCREEN restoration normalizes invalid cursor positions" { header.cursor_flags.pending_wrap = case.pending_wrap; header.saved_cursor_present = false; - var screen_writer = try record.Writer.init(&destination, .screen); - try header.encode(screen_writer.payloadWriter()); - try screen_writer.payloadWriter().writeByte(0); - try screen_writer.finish(); + 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, &destination); + try page.encode(native_page, &stream); var source: std.Io.Reader = .fixed(destination.written()); var decoded = try decode( @@ -2011,7 +2024,12 @@ test "SCREEN restoration rejects invalid and incomplete sequences" { std.testing.allocator, ); defer destination.deinit(); - try encode(&screen, .primary, &destination); + 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. @@ -2052,16 +2070,19 @@ test "SCREEN restoration rejects invalid and incomplete sequences" { std.testing.allocator, ); defer empty_sequence.deinit(); + var empty_stream: record.Writer = .init( + std.testing.allocator, + &empty_sequence.writer, + ); + defer empty_stream.deinit(); { - var record_writer = try record.Writer.init( - &empty_sequence, - .screen, - ); + const payload = empty_stream.begin(.screen); + errdefer empty_stream.cancel(); try Header.init(&screen, .primary, 0).encode( - record_writer.payloadWriter(), + payload, ); - try record_writer.payloadWriter().writeByte(0); - try record_writer.finish(); + try payload.writeByte(0); + try empty_stream.finish(); } var empty_source: std.Io.Reader = .fixed(empty_sequence.written()); try std.testing.expectError( @@ -2083,21 +2104,25 @@ test "SCREEN sequence failure preserves preceding bytes" { ); defer screen.deinit(); - var failing = std.testing.FailingAllocator.init( + var destination: std.Io.Writer.Allocating = .init( std.testing.allocator, - .{}, - ); - var destination = try std.Io.Writer.Allocating.initCapacity( - failing.allocator(), - 6 + record.Header.len + Header.len + 1, ); defer destination.deinit(); try destination.writer.writeAll("prefix"); - failing.fail_index = failing.alloc_index; + 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, &destination), + encode(&screen, .primary, &stream), ); try std.testing.expectEqualStrings("prefix", destination.written()); } @@ -2114,6 +2139,11 @@ test "SCREEN decode ignores an invalid cursor hyperlink" { 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; @@ -2123,16 +2153,17 @@ test "SCREEN decode ignores an invalid cursor hyperlink" { header.cursor_flags.pending_wrap = false; header.saved_cursor_present = false; - var record_writer = try record.Writer.init(&destination, .screen); - try header.encode(record_writer.payloadWriter()); - try record_writer.payloadWriter().writeByte(1); // Implicit hyperlink. - try io.writeInt(record_writer.payloadWriter(), u32, 1); - try io.writeInt(record_writer.payloadWriter(), u32, 0); // Empty URI. - try record_writer.finish(); + 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(), - &destination, + &stream, ); var source: std.Io.Reader = .fixed(destination.written()); @@ -2151,6 +2182,11 @@ test "SCREEN decode ignores a PAGE with an empty hyperlink URI" { 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; @@ -2160,10 +2196,11 @@ test "SCREEN decode ignores a PAGE with an empty hyperlink URI" { header.cursor_flags.pending_wrap = false; header.saved_cursor_present = false; - var screen_writer = try record.Writer.init(&destination, .screen); - try header.encode(screen_writer.payloadWriter()); - try screen_writer.payloadWriter().writeByte(0); - try screen_writer.finish(); + 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, @@ -2175,34 +2212,35 @@ test "SCREEN decode ignores a PAGE with an empty hyperlink URI" { .grapheme_capacity_bytes = 0, .string_capacity_bytes = 0, }; - var page_writer = try record.Writer.init(&destination, .page); - try page_header.encode(page_writer.payloadWriter()); + const page_payload = stream.begin(.page); + errdefer stream.cancel(); + try page_header.encode(page_payload); try io.writeInt( - page_writer.payloadWriter(), + page_payload, terminal_hyperlink.Id, 1, ); - try page_writer.payloadWriter().writeByte(1); // Implicit hyperlink. - try io.writeInt(page_writer.payloadWriter(), u32, 1); - try io.writeInt(page_writer.payloadWriter(), u32, 0); // Empty URI. + 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_writer.payloadWriter().writeByte(0); - try page_writer.payloadWriter().writeAll(&.{ 0, 0, 0, 0 }); + try page_payload.writeByte(0); + try page_payload.writeAll(&.{ 0, 0, 0, 0 }); try io.writeInt( - page_writer.payloadWriter(), + page_payload, terminal_style.Id, 0, ); try io.writeInt( - page_writer.payloadWriter(), + page_payload, terminal_hyperlink.Id, 1, ); - try io.writeInt(page_writer.payloadWriter(), u32, 'A'); - try io.writeInt(page_writer.payloadWriter(), u32, 0); - try page_writer.finish(); + 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( diff --git a/src/terminal/snapshot/snapshot.zig b/src/terminal/snapshot/snapshot.zig index 0f3635487..d083b54d5 100644 --- a/src/terminal/snapshot/snapshot.zig +++ b/src/terminal/snapshot/snapshot.zig @@ -24,61 +24,58 @@ const test_complete_fixture = test_fixture.parse( pub const EncodeError = terminal.EncodeError || screen.EncodeError || history.EncodeError || - checkpoint.EncodeError || - error{ - /// A snapshot envelope must begin at byte zero. - DestinationNotEmpty, - }; + checkpoint.EncodeError; /// Encode one complete terminal snapshot. /// -/// `destination` must be empty because checkpoint digests cover every byte -/// from the snapshot envelope onward. The operation is transactional: any -/// failure restores the destination to empty. +/// 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, - destination: *std.Io.Writer.Allocating, ) EncodeError!void { - // We require empty for checkpoint digests - if (destination.written().len != 0) return error.DestinationNotEmpty; - errdefer destination.shrinkRetainingCapacity(0); + var stream: record.Writer = .init(alloc, destination); + defer stream.deinit(); // 1. Envelope - try envelope.encode(&destination.writer); + try envelope.encode(stream.writer()); // 2. Terminal - try terminal.encode(t, destination); + try terminal.encode(t, &stream); // 3. Primary and alt screen try screen.encode( t.screens.get(.primary).?, .primary, - destination, + &stream, ); if (t.screens.get(.alternate)) |alternate| try screen.encode( alternate, .alternate, - destination, + &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, destination); + try checkpoint.encode(.ready, &stream); // 5. History try history.encode( t.screens.get(.primary).?, .primary, - destination, + &stream, ); if (t.screens.get(.alternate)) |alternate| try history.encode( alternate, .alternate, - destination, + &stream, ); // 6. Finish - try checkpoint.encode(.finish, destination); + try checkpoint.encode(.finish, &stream); } /// Errors possible while restoring one complete terminal snapshot. @@ -113,11 +110,10 @@ pub fn decode( io_: std.Io, alloc: Allocator, ) DecodeError!Terminal { - // Keep this reader unbuffered so the hasher never reads past a checkpoint - // boundary. Record readers provide their own bounded buffers. - const Blake3 = std.crypto.hash.Blake3; - var hashing = source.hashed(Blake3.init(.{}), &.{}); - const reader = &hashing.reader; + // 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); @@ -179,9 +175,7 @@ pub fn decode( // READY covers the exact envelope-through-SCREEN prefix. Finalizing does // not consume the hasher, so the same stream continues toward FINISH. - var digest: checkpoint.Digest = undefined; - hashing.hasher.final(&digest); - try checkpoint.decode(.ready, digest, reader); + 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 @@ -206,8 +200,7 @@ pub fn decode( // FINISH authenticates READY and all history. Decode it directly from the // underlying reader so the digest does not include FINISH itself. - hashing.hasher.final(&digest); - try checkpoint.decode(.finish, digest, source); + try checkpoint.decode(.finish, &stream); const keys = [_]TerminalScreenKey{ .primary, .alternate }; if (comptime build_options.slow_runtime_safety) { @@ -311,7 +304,7 @@ test "complete snapshot round trip with history and alternate screen" { var encoded: std.Io.Writer.Allocating = .init(testing.allocator); defer encoded.deinit(); - try encode(&t, &encoded); + try encode(testing.allocator, &encoded.writer, &t); try testing.expectEqualDeep(source_memory, primary.pages.memoryStats()); try test_fixture.expectEqual( .snapshot, @@ -321,6 +314,29 @@ test "complete snapshot round trip with history and alternate screen" { 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; @@ -354,7 +370,7 @@ test "complete snapshot round trip with history and alternate screen" { // SCREEN, PAGE, and HISTORY fields and both checkpoint boundaries. var reencoded: std.Io.Writer.Allocating = .init(testing.allocator); defer reencoded.deinit(); - try encode(&restored, &reencoded); + try encode(testing.allocator, &reencoded.writer, &restored); try testing.expectEqualStrings( &test_complete_fixture, reencoded.written(), @@ -363,18 +379,27 @@ test "complete snapshot round trip with history and alternate screen" { // SCREEN and HISTORY keys make both sequence groups order independent. var reversed: std.Io.Writer.Allocating = .init(testing.allocator); defer reversed.deinit(); - try envelope.encode(&reversed.writer); - try terminal.encode(&t, &reversed); - try screen.encode(t.screens.get(.alternate).?, .alternate, &reversed); - try screen.encode(primary, .primary, &reversed); - try checkpoint.encode(.ready, &reversed); + 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, + &reversed_stream, ); - try history.encode(primary, .primary, &reversed); - try checkpoint.encode(.finish, &reversed); + 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( @@ -440,7 +465,7 @@ test "complete snapshot preserves Kitty virtual placeholders" { var encoded: std.Io.Writer.Allocating = .init(testing.allocator); defer encoded.deinit(); - try encode(&t, &encoded); + try encode(testing.allocator, &encoded.writer, &t); var encoded_source: std.Io.Reader = .fixed(encoded.written()); var restored = try decode( @@ -469,7 +494,7 @@ test "complete snapshot preserves Kitty virtual placeholders" { ); } -test "complete snapshot encoding is transactional" { +test "complete snapshot encoding streams from the current writer position" { const testing = std.testing; var t = try Terminal.init(testing.io, testing.allocator, .{ @@ -478,27 +503,44 @@ test "complete snapshot encoding is transactional" { }); defer t.deinit(testing.allocator); - // A complete snapshot cannot be appended after unrelated bytes because - // its envelope and checkpoint coverage both begin at byte zero. + // 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"); - try testing.expectError( - error.DestinationNotEmpty, - encode(&t, &nonempty), + const snapshot_offset = nonempty.written().len; + try encode(testing.allocator, &nonempty.writer, &t); + try testing.expectEqualStrings( + "prefix", + nonempty.written()[0..snapshot_offset], ); - try testing.expectEqualStrings("prefix", nonempty.written()); + var appended_source: std.Io.Reader = .fixed( + nonempty.written()[snapshot_offset..], + ); + var appended = try decode( + &appended_source, + testing.io, + testing.allocator, + ); + appended.deinit(testing.allocator); - // A failure after validation enters a record codec still rolls back every - // preceding record in this complete snapshot operation. + // 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(&t, &destination), + 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..], ); - try testing.expectEqual(@as(usize, 0), destination.written().len); } test "complete snapshot rejects ordering checkpoints and trailing data" { @@ -515,9 +557,14 @@ test "complete snapshot rejects ordering checkpoints and trailing data" { // primary SCREEN before READY. var reordered: std.Io.Writer.Allocating = .init(testing.allocator); defer reordered.deinit(); - try envelope.encode(&reordered.writer); - try terminal.encode(&t, &reordered); - try history.encode(primary, .primary, &reordered); + 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, @@ -528,15 +575,21 @@ test "complete snapshot rejects ordering checkpoints and trailing data" { // 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(); - try envelope.encode(&invalid_ready.writer); - try terminal.encode(&t, &invalid_ready); - try screen.encode(primary, .primary, &invalid_ready); - var record_writer = try record.Writer.init(&invalid_ready, .ready); - try record_writer.payloadWriter().splatByteAll( + 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 record_writer.finish(); + try invalid_ready_stream.finish(); var invalid_ready_source: std.Io.Reader = .fixed( invalid_ready.written(), ); @@ -548,7 +601,7 @@ test "complete snapshot rejects ordering checkpoints and trailing data" { // FINISH is the exact end of a complete snapshot. var trailing: std.Io.Writer.Allocating = .init(testing.allocator); defer trailing.deinit(); - try encode(&t, &trailing); + try encode(testing.allocator, &trailing.writer, &t); try trailing.writer.writeByte(0); var trailing_source: std.Io.Reader = .fixed(trailing.written()); try testing.expectError( @@ -559,9 +612,14 @@ test "complete snapshot rejects ordering checkpoints and trailing data" { // A SCREEN key must name one of the slots declared by TERMINAL. var undeclared: std.Io.Writer.Allocating = .init(testing.allocator); defer undeclared.deinit(); - try envelope.encode(&undeclared.writer); - try terminal.encode(&t, &undeclared); - try screen.encode(primary, .alternate, &undeclared); + 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, @@ -572,11 +630,16 @@ test "complete snapshot rejects ordering checkpoints and trailing data" { // screen even when the sequence contains no PAGE records. var undeclared_history: std.Io.Writer.Allocating = .init(testing.allocator); defer undeclared_history.deinit(); - try envelope.encode(&undeclared_history.writer); - try terminal.encode(&t, &undeclared_history); - try screen.encode(primary, .primary, &undeclared_history); - try checkpoint.encode(.ready, &undeclared_history); - try history.encode(primary, .alternate, &undeclared_history); + 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(), ); @@ -589,10 +652,15 @@ test "complete snapshot rejects ordering checkpoints and trailing data" { _ = try t.switchScreen(.alternate); var duplicate: std.Io.Writer.Allocating = .init(testing.allocator); defer duplicate.deinit(); - try envelope.encode(&duplicate.writer); - try terminal.encode(&t, &duplicate); - try screen.encode(primary, .primary, &duplicate); - try screen.encode(primary, .primary, &duplicate); + 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, @@ -602,17 +670,22 @@ test "complete snapshot rejects ordering checkpoints and trailing data" { // 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(); - try envelope.encode(&duplicate_history.writer); - try terminal.encode(&t, &duplicate_history); - try screen.encode(primary, .primary, &duplicate_history); + 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, + &duplicate_history_stream, ); - try checkpoint.encode(.ready, &duplicate_history); - try history.encode(primary, .primary, &duplicate_history); - try history.encode(primary, .primary, &duplicate_history); + 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(), ); diff --git a/src/terminal/snapshot/terminal.zig b/src/terminal/snapshot/terminal.zig index 343392fa4..f20b97707 100644 --- a/src/terminal/snapshot/terminal.zig +++ b/src/terminal/snapshot/terminal.zig @@ -909,26 +909,25 @@ pub const EncodeError = HeaderInitError || /// Encode terminal-wide native state as one framed TERMINAL record. /// -/// State not represented by this snapshot version is ignored. Any failure -/// removes the incomplete record while preserving bytes that were already -/// present in `destination`. +/// 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: *std.Io.Writer.Allocating, + destination: *record.Writer, ) EncodeError!void { const header = try Header.init(terminal); - var record_writer = try record.Writer.init(destination, .terminal); - errdefer record_writer.cancel(); + const payload = destination.begin(.terminal); + errdefer destination.cancel(); try encodePayload( header, &terminal.tabstops, &terminal.colors.palette, terminal.getPwd() orelse "", terminal.getTitle() orelse "", - record_writer.payloadWriter(), + payload, ); - try record_writer.finish(); + try destination.finish(); } /// Errors possible while decoding one native TERMINAL record. @@ -1638,7 +1637,12 @@ test "TERMINAL record encodes native terminal state" { // Encode and restore through the public record codec. var destination: std.Io.Writer.Allocating = .init(testing.allocator); defer destination.deinit(); - try encode(&terminal, &destination); + var 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( @@ -1714,7 +1718,12 @@ test "TERMINAL record declares an initialized alternate screen" { var destination: std.Io.Writer.Allocating = .init(testing.allocator); defer destination.deinit(); - try encode(&terminal, &destination); + 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( @@ -1737,7 +1746,7 @@ test "TERMINAL record declares an initialized alternate screen" { ); } -test "TERMINAL record encoding is transactional" { +test "TERMINAL payload failure emits no incomplete record" { const testing = std.testing; var terminal = try Terminal.init(testing.io, testing.allocator, .{ @@ -1749,13 +1758,18 @@ test "TERMINAL record encoding is transactional" { 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 after framing is reserved, so this covers the - // record writer's rollback path. + // 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, &destination), + encode(&terminal, &stream), ); try testing.expectEqualStrings("prefix", destination.written()); } @@ -1766,9 +1780,15 @@ test "TERMINAL record decoding rejects malformed input transactionally" { // 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(); { - var record_writer = try record.Writer.init(&wrong_tag, .screen); - try record_writer.finish(); + _ = 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( @@ -1788,7 +1808,12 @@ test "TERMINAL record decoding rejects malformed input transactionally" { var encoded: std.Io.Writer.Allocating = .init(testing.allocator); defer encoded.deinit(); - try encode(&terminal, &encoded); + 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( From d37e1fe184f0486e155d61b1cee1c6d82c8d199f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 11:46:07 -0700 Subject: [PATCH 32/35] terminal/snapshot: format doesn't require EOF Treat FINISH as the self-delimiting snapshot boundary instead of peeking for end-of-file. Normal decoding now leaves continuation bytes unread, allowing snapshots and live protocol data to share a stream without waiting for closure. Add decodeExact for bounded files that still require strict exhaustion, and update the Kaitai schema, documentation, and tests for continuation and sequential snapshot decoding. --- src/terminal/snapshot/checkpoint.zig | 27 +++---- src/terminal/snapshot/main.zig | 15 +++- src/terminal/snapshot/snapshot.ksy | 7 +- src/terminal/snapshot/snapshot.zig | 111 +++++++++++++++++++++++---- 4 files changed, 119 insertions(+), 41 deletions(-) diff --git a/src/terminal/snapshot/checkpoint.zig b/src/terminal/snapshot/checkpoint.zig index dabf37773..79b04242f 100644 --- a/src/terminal/snapshot/checkpoint.zig +++ b/src/terminal/snapshot/checkpoint.zig @@ -6,8 +6,8 @@ //! //! 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 must be followed -//! immediately by end-of-file; READY may be followed by history. +//! 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. @@ -76,9 +76,6 @@ pub const DecodeError = record.Reader.InitError || /// The checkpoint does not describe the preceding snapshot bytes. InvalidDigest, - - /// FINISH was followed by additional bytes. - TrailingData, }; /// Decode and validate one checkpoint against the running prefix digest. @@ -105,14 +102,6 @@ pub fn decode( try record_reader.payloadReader().readSliceAll(&actual); try record_reader.finish(); if (!std.mem.eql(u8, &expected, &actual)) return error.InvalidDigest; - - if (kind == .finish) { - _ = source.peekByte() catch |err| switch (err) { - error.EndOfStream => return, - else => return err, - }; - return error.TrailingData; - } } const test_ready_fixture = test_fixture.parse( @@ -212,7 +201,7 @@ test "READY and FINISH checkpoint coverage" { ); } -test "checkpoint rejects wrong tags, digests, and FINISH trailing data" { +test "checkpoint rejects wrong tags and digests" { const testing = std.testing; var snapshot: std.Io.Writer.Allocating = .init(testing.allocator); @@ -260,6 +249,10 @@ test "checkpoint rejects wrong tags, digests, and FINISH trailing data" { 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(); @@ -277,8 +270,6 @@ test "checkpoint rejects wrong tags, digests, and FINISH trailing data" { &trailing_source, ); try trailing_stream.reader().discardAll("prefix".len); - try testing.expectError( - error.TrailingData, - decode(.finish, &trailing_stream), - ); + try decode(.finish, &trailing_stream); + try testing.expectEqual(@as(u8, 0), try trailing_source.takeByte()); } diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index 188cab684..d1d092d59 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -68,7 +68,8 @@ //! 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 is followed by end-of-file. +//! 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 @@ -100,6 +101,18 @@ //! //! 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. +//! +//! 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"); diff --git a/src/terminal/snapshot/snapshot.ksy b/src/terminal/snapshot/snapshot.ksy index a800b2782..b7f8eb1bf 100644 --- a/src/terminal/snapshot/snapshot.ksy +++ b/src/terminal/snapshot/snapshot.ksy @@ -10,7 +10,8 @@ doc: | 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. + newest-to-oldest. FINISH terminates the snapshot; bytes that follow belong to + the containing transport and are outside this schema. Record CRC32C values and checkpoint BLAKE3-256 digests are represented here but cannot be calculated by portable Kaitai Struct expressions. The adjacent @@ -32,10 +33,6 @@ seq: repeat-expr: terminal.payload.header.screen_count - id: finish type: checkpoint_record(6) - - id: trailing_data - size-eos: true - valid: - expr: _.size == 0 enums: record_tag: 1: terminal diff --git a/src/terminal/snapshot/snapshot.zig b/src/terminal/snapshot/snapshot.zig index d083b54d5..15234d101 100644 --- a/src/terminal/snapshot/snapshot.zig +++ b/src/terminal/snapshot/snapshot.zig @@ -100,11 +100,12 @@ pub const DecodeError = envelope.DecodeError || /// Restore one complete snapshot into a native terminal. /// -/// The reader must contain exactly one snapshot through FINISH. 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. +/// 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( source: *std.Io.Reader, io_: std.Io, @@ -218,6 +219,31 @@ pub fn decode( 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( + source: *std.Io.Reader, + io_: std.Io, + alloc: Allocator, +) DecodeExactError!Terminal { + var result = try decode(source, io_, alloc); + 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; @@ -543,7 +569,7 @@ test "complete snapshot encoding streams from the current writer position" { ); } -test "complete snapshot rejects ordering checkpoints and trailing data" { +test "complete snapshot rejects ordering and invalid checkpoints" { const testing = std.testing; var t = try Terminal.init(testing.io, testing.allocator, .{ @@ -598,17 +624,6 @@ test "complete snapshot rejects ordering checkpoints and trailing data" { decode(&invalid_ready_source, testing.io, testing.allocator), ); - // FINISH is the exact end of a complete snapshot. - var trailing: std.Io.Writer.Allocating = .init(testing.allocator); - defer trailing.deinit(); - try encode(testing.allocator, &trailing.writer, &t); - try trailing.writer.writeByte(0); - var trailing_source: std.Io.Reader = .fixed(trailing.written()); - try testing.expectError( - error.TrailingData, - decode(&trailing_source, testing.io, testing.allocator), - ); - // A SCREEN key must name one of the slots declared by TERMINAL. var undeclared: std.Io.Writer.Allocating = .init(testing.allocator); defer undeclared.deinit(); @@ -694,3 +709,65 @@ test "complete snapshot rejects ordering checkpoints and trailing data" { decode(&duplicate_history_source, testing.io, testing.allocator), ); } + +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(&source, testing.io, testing.allocator); + 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(&exact_source, testing.io, testing.allocator), + ); + + var bounded_source: std.Io.Reader = .fixed( + encoded.written()[0..snapshot_len], + ); + var bounded = try decodeExact( + &bounded_source, + testing.io, + testing.allocator, + ); + 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(&source, testing.io, testing.allocator); + defer first.deinit(testing.allocator); + var second = try decode(&source, testing.io, testing.allocator); + defer second.deinit(testing.allocator); + try testing.expectError(error.EndOfStream, source.takeByte()); +} From e2e74fecbe08cd545dc748435ab20cec424756d5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 11:57:58 -0700 Subject: [PATCH 33/35] terminal/snapshot: move history size hints to screen record Publish each screen's logical history extent before READY so clients can size scrollbars while older pages are still arriving. Keep the value advisory and continue deriving native PageList totals from decoded pages. Reduce HISTORY to its structural screen key and page count, and update the format documentation, Kaitai schema, verifier, and versioned fixtures. --- src/terminal/snapshot/history.zig | 133 +++--------------- src/terminal/snapshot/main.zig | 4 +- src/terminal/snapshot/screen.zig | 123 ++++++++++------ src/terminal/snapshot/snapshot.ksy | 9 +- .../snapshot/testdata/complete-v1.hex | 114 ++++++++------- .../snapshot/testdata/history-header-v1.hex | 2 +- .../snapshot/testdata/screen-header-v1.hex | 7 +- src/terminal/snapshot/verify-kaitai.py | 15 +- 8 files changed, 172 insertions(+), 235 deletions(-) diff --git a/src/terminal/snapshot/history.zig b/src/terminal/snapshot/history.zig index b49c05081..dc5fcb65d 100644 --- a/src/terminal/snapshot/history.zig +++ b/src/terminal/snapshot/history.zig @@ -7,15 +7,13 @@ //! to oldest. //! //! Every encoded SCREEN has one corresponding HISTORY record. A screen with no -//! history uses zero for all three count fields and has no following PAGE -//! records. +//! 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 counted by `screen_overlap_rows`; they are not repeated -//! after HISTORY. `total_rows` is the canonical number of logical rows before -//! the active area, including both the SCREEN overlap and the rows in the -//! following PAGE records. Decoders derive native row totals from the restored -//! pages and may ignore inconsistent row metadata. +//! 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. //! @@ -53,17 +51,10 @@ //! 2 +--------------------------------+ //! | Following page count (u32) | //! 6 +--------------------------------+ -//! | Total logical history rows | -//! | u64 | -//! 14 +--------------------------------+ -//! | SCREEN overlap rows (u16) | -//! 16 +--------------------------------+ //! ``` //! -//! A canonical encoder sets `total_rows` to the sum of `screen_overlap_rows` -//! and the logical row counts of all following PAGE records. A decoder uses -//! `page_count`, rather than another record tag, to find the end of the page -//! sequence. +//! 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; @@ -85,7 +76,7 @@ pub const Header = struct { comptime { // This size is part of the wire format. If it changes, the snapshot // version must also change. - std.debug.assert(len == 16); + std.debug.assert(len == 6); } /// Identifies the previously encoded screen that owns this history. @@ -94,12 +85,6 @@ pub const Header = struct { /// Number of complete PAGE records immediately following this record. page_count: u32, - /// Canonical number of logical rows before the active area. - total_rows: u64, - - /// History rows already included at the start of the first SCREEN page. - screen_overlap_rows: u16, - /// Encode the fixed HISTORY payload. pub fn encode( self: Header, @@ -107,8 +92,6 @@ pub const Header = struct { ) std.Io.Writer.Error!void { try io.writeInt(writer, u16, @intCast(@intFromEnum(self.key))); try io.writeInt(writer, u32, self.page_count); - try io.writeInt(writer, u64, self.total_rows); - try io.writeInt(writer, u16, self.screen_overlap_rows); } pub const DecodeError = std.Io.Reader.Error || error{InvalidKey}; @@ -126,8 +109,6 @@ pub const Header = struct { return .{ .key = key, .page_count = try io.readInt(reader, u32), - .total_rows = try io.readInt(reader, u64), - .screen_overlap_rows = try io.readInt(reader, u16), }; } @@ -138,8 +119,6 @@ pub const Header = struct { const value: Header = .{ .key = .primary, .page_count = 0, - .total_rows = 0, - .screen_overlap_rows = 0, }; value.encode(&writer) catch unreachable; return writer.end; @@ -154,9 +133,6 @@ pub const EncodeError = Allocator.Error || error{ /// The complete historical prefix has more pages than the header fits. PageCountOverflow, - - /// The complete historical prefix has more rows than the header fits. - RowCountOverflow, }; /// Encode one screen's HISTORY and its complete historical pages. @@ -172,21 +148,14 @@ pub fn encode( // 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 active_top = terminal_screen.pages.getTopLeft(.active); - const page_count: usize, const total_rows: u64 = count: { + const first = terminal_screen.pages.getTopLeft(.active).node.prev; + const page_count: usize = count: { var page_count: usize = 0; - var total_rows: u64 = active_top.y; - var node = active_top.node.prev; + var node = first; while (node) |current| : (node = current.prev) { page_count += 1; - total_rows = std.math.add( - u64, - total_rows, - current.rows(), - ) catch return error.RowCountOverflow; } - - break :count .{ page_count, total_rows }; + break :count page_count; }; const header: Header = .{ @@ -195,12 +164,9 @@ pub fn encode( u32, page_count, ) orelse return error.PageCountOverflow, - .total_rows = total_rows, - .screen_overlap_rows = active_top.y, }; - // HISTORY declares exactly how many PAGE records follow and how their rows - // combine with the overlap already carried by SCREEN. + // HISTORY declares exactly how many PAGE records follow. { const payload = destination.begin(.history); errdefer destination.cancel(); @@ -211,7 +177,7 @@ pub fn encode( // 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 = active_top.node.prev; + var node = first; while (node) |current| : (node = current.prev) { var preserved = try current.pagePreservingState(terminal_screen.alloc); defer preserved.deinit(); @@ -277,9 +243,7 @@ pub const Decoder = struct { return error.ExistingHistory; } - // Row metadata is redundant with the restored SCREEN and PAGE - // topology. Consume the declared PAGE records, but derive all native - // row totals from their actual dimensions. + // 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 @@ -341,8 +305,6 @@ test "HISTORY header golden encoding and decoding" { const expected: Header = .{ .key = .alternate, .page_count = 0x01020304, - .total_rows = 0x0102030405060708, - .screen_overlap_rows = 0x090a, }; var encoded: [Header.len]u8 = undefined; var writer: std.Io.Writer = .fixed(&encoded); @@ -456,16 +418,6 @@ test "HISTORY encodes newest first and restores complete history" { try history_record.finish(); try std.testing.expectEqual(TerminalScreenKey.primary, header.key); try std.testing.expectEqual(@as(u32, 2), header.page_count); - try std.testing.expectEqual( - @as(u16, active_top.y), - header.screen_overlap_rows, - ); - try std.testing.expectEqual( - @as(u64, active_top.y) + - oldest_history.rows() + - newest_history.rows(), - header.total_rows, - ); var decoded_newest = try page.decode( &history_source, @@ -504,6 +456,10 @@ test "HISTORY encodes newest first and restores complete history" { 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( @@ -660,8 +616,6 @@ test "HISTORY encodes and restores an empty sequence" { 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.expectEqual(@as(u64, 0), header.total_rows); - try std.testing.expectEqual(@as(u16, 0), header.screen_overlap_rows); try std.testing.expectError(error.EndOfStream, inspect_source.takeByte()); var decode_source: std.Io.Reader = .fixed(destination.written()); @@ -679,7 +633,7 @@ test "HISTORY encodes and restores an empty sequence" { try std.testing.expectError(error.EndOfStream, decode_source.takeByte()); } -test "HISTORY accepts row metadata mismatches and rejects structural ones" { +test "HISTORY rejects invalid routing and incomplete sequences" { var terminal_screen = try TerminalScreen.init( std.testing.io, std.testing.allocator, @@ -691,49 +645,6 @@ test "HISTORY accepts row metadata mismatches and rejects structural ones" { ); defer terminal_screen.deinit(); - // Row counts are redundant metadata. Even internally inconsistent values - // do not prevent restoring the topology declared by page_count. - const accepted = [_]Header{ - .{ - .key = .primary, - .page_count = 0, - .total_rows = 0, - .screen_overlap_rows = 1, - }, - .{ - .key = .primary, - .page_count = 0, - .total_rows = std.math.maxInt(u64), - .screen_overlap_rows = std.math.maxInt(u16), - }, - }; - for (accepted) |header| { - 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 header.encode(payload); - try stream.finish(); - - var source: std.Io.Reader = .fixed(destination.written()); - try decode( - &source, - std.testing.allocator, - .primary, - &terminal_screen, - ); - try std.testing.expectError(error.EndOfStream, source.takeByte()); - terminal_screen.pages.assertIntegrity(); - terminal_screen.assertIntegrity(); - } - // Routing and sequence length still determine which native screen is // mutated and where the following record begins, so they remain strict. const Case = struct { @@ -745,8 +656,6 @@ test "HISTORY accepts row metadata mismatches and rejects structural ones" { .header = .{ .key = .alternate, .page_count = 0, - .total_rows = 0, - .screen_overlap_rows = 0, }, .expected = error.UnexpectedScreenKey, }, @@ -754,8 +663,6 @@ test "HISTORY accepts row metadata mismatches and rejects structural ones" { .header = .{ .key = .primary, .page_count = 1, - .total_rows = 1, - .screen_overlap_rows = 0, }, .expected = error.EndOfStream, }, diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index d1d092d59..6eb580f21 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -74,7 +74,9 @@ //! 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. +//! 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 //! diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index 2fc3d0d8b..875d2bb0e 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -9,13 +9,14 @@ //! //! 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. A client may expose or ignore those resident rows, but must not -//! require them or infer the complete history extent from them. +//! 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, authoritative history extent and -//! availability, scrollbar state, selection, viewport, dirty state, and derived -//! semantic-prompt state are also omitted. +//! 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. //! @@ -37,10 +38,9 @@ //! n = page_count //! ``` //! -//! The HISTORY record for this screen provides the authoritative history -//! manifest. It counts any incidental prefix above as the SCREEN overlap, then -//! sends the older complete pages after the terminal becomes ready. The prefix -//! is not sent again. +//! 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: @@ -48,7 +48,7 @@ //! ```text //! 0 +----------------------+ //! | Header | -//! 45 +----------------------+ +//! 53 +----------------------+ //! | Cursor hyperlink | //! | variable | //! end +----------------------+ @@ -59,9 +59,9 @@ //! ```text //! 0 +----------------------+ //! | Header | -//! 45 +----------------------+ +//! 53 +----------------------+ //! | Saved cursor | -//! 68 +----------------------+ +//! 76 +----------------------+ //! | Cursor hyperlink | //! | variable | //! end +----------------------+ @@ -79,33 +79,35 @@ //! 2 +----------------------------------+ //! | Page count (u16) | //! 4 +----------------------------------+ +//! | Logical history rows (u64) | +//! 12 +----------------------------------+ //! | Cursor x (u16) | -//! 6 +----------------------------------+ +//! 14 +----------------------------------+ //! | Cursor y (u16) | -//! 8 +----------------------------------+ +//! 16 +----------------------------------+ //! | Cursor visual style (u8) | -//! 9 +----------------------------------+ +//! 17 +----------------------------------+ //! | Cursor flags (u8) | -//! 10 +----------------------------------+ +//! 18 +----------------------------------+ //! | Concrete cursor pen (Style) | -//! 26 +----------------------------------+ -//! | Hyperlink implicit counter (u32) | -//! 30 +----------------------------------+ -//! | Current charset (CharsetState) | -//! 32 +----------------------------------+ -//! | Protected mode (u8) | -//! 33 +----------------------------------+ -//! | Kitty keyboard stack index (u8) | //! 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 | -//! 42 +----------------------------------+ +//! 50 +----------------------------------+ //! | Semantic-click kind (u8) | -//! 43 +----------------------------------+ +//! 51 +----------------------------------+ //! | Semantic-click value (u8) | -//! 44 +----------------------------------+ +//! 52 +----------------------------------+ //! | Saved-cursor-present (u8) | -//! 45 +----------------------------------+ +//! 53 +----------------------------------+ //! ``` //! //! The concrete cursor pen uses the fixed style encoding from `style.zig`. @@ -298,6 +300,8 @@ pub const DecodeError = PayloadDecodeError || /// 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. @@ -507,7 +511,11 @@ pub fn decode( // before transferring ownership to the caller. result.pages.assertIntegrity(); result.assertIntegrity(); - return .{ .key = key, .screen = result }; + return .{ + .key = key, + .history_rows = header.history_rows, + .screen = result, + }; } /// Errors possible while decoding fixed SCREEN payload fields. @@ -868,12 +876,15 @@ pub const Header = struct { pub const len = computeLen(); comptime { - std.debug.assert(len == 45); + 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, @@ -895,6 +906,9 @@ pub const Header = struct { 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, @@ -932,9 +946,10 @@ pub const Header = struct { } } - // Screen identity and cursor position. + // 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); @@ -975,12 +990,13 @@ pub const Header = struct { /// 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 and cursor position. + // 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); @@ -1012,6 +1028,7 @@ pub const Header = struct { return .{ .key = key, .page_count = page_count, + .history_rows = history_rows, .cursor_x = cursor_x, .cursor_y = cursor_y, @@ -1037,6 +1054,7 @@ pub const Header = struct { const value: Header = .{ .key = .primary, .page_count = 0, + .history_rows = 0, .cursor_x = 0, .cursor_y = 0, .cursor_style = .bar, @@ -1135,6 +1153,7 @@ fn testHeader() Header { return .{ .key = .alternate, .page_count = 0x0203, + .history_rows = 0x0102030405060708, .cursor_x = 0x0405, .cursor_y = 0x0607, .cursor_style = .block_hollow, @@ -1444,16 +1463,16 @@ test "header decoding rejects structural values" { test "header decoding normalizes unknown semantic values" { var fixture = [_]u8{0} ** Header.len; - fixture[8] = 4; // Unknown cursor style. - fixture[9] = 0xFF; // Known flags, unknown semantic value, reserved bits. - fixture[10] = 3; // Unknown cursor foreground color kind. - fixture[31] = 0xD0; // Invalid single shift plus a reserved bit. - fixture[32] = 3; // Unknown protected mode. - fixture[33] = 8; // Out-of-range Kitty keyboard index. - @memset(fixture[34..42], 0xFF); // Known flags plus reserved bits. - fixture[42] = 3; // Unknown semantic-click kind. - fixture[43] = 0xFF; - fixture[44] = 2; // Noncanonical true. + 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); @@ -1504,8 +1523,8 @@ test "header decoding normalizes unknown semantic values" { }; for (invalid_semantic_clicks) |invalid| { var semantic_fixture = [_]u8{0} ** Header.len; - semantic_fixture[42] = invalid[0]; - semantic_fixture[43] = invalid[1]; + 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( @@ -1541,6 +1560,7 @@ test "native SCREEN payload omits absent optional state" { 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, @@ -1867,6 +1887,10 @@ test "SCREEN encodes the minimal complete-page active suffix" { 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, @@ -1904,11 +1928,22 @@ test "SCREEN encodes the minimal complete-page active suffix" { ); 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 diff --git a/src/terminal/snapshot/snapshot.ksy b/src/terminal/snapshot/snapshot.ksy index b7f8eb1bf..ad8f6c3a0 100644 --- a/src/terminal/snapshot/snapshot.ksy +++ b/src/terminal/snapshot/snapshot.ksy @@ -11,7 +11,8 @@ doc: | 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. + 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 @@ -568,6 +569,8 @@ types: type: u2 valid: min: 1 + - id: history_rows + type: u8 - id: cursor_x type: u2 - id: cursor_y @@ -710,10 +713,6 @@ types: expr: _.to_i <= 1 - id: page_count type: u4 - - id: total_rows - type: u8 - - id: screen_overlap_rows - type: u2 - id: trailing_data size-eos: true valid: diff --git a/src/terminal/snapshot/testdata/complete-v1.hex b/src/terminal/snapshot/testdata/complete-v1.hex index 01a72f1ea..7731a9c9d 100644 --- a/src/terminal/snapshot/testdata/complete-v1.hex +++ b/src/terminal/snapshot/testdata/complete-v1.hex @@ -72,70 +72,68 @@ ee ee 80 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000037a 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 46 bytes -02 00 2e 00 00 00 dc 02 f1 37 00 00 01 00 00 00 # 0x000003cc -00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000003dc -00 00 00 00 00 00 00 00 00 08 00 00 00 00 00 00 # 0x000003ec -00 00 00 00 00 00 00 00 # 0x000003fc +# 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 0x00000404: page record, payload 119 bytes -03 00 77 00 00 00 48 b7 91 cb 02 00 03 00 00 00 # 0x00000404 -00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x00000414 -00 00 00 00 00 00 00 43 00 00 00 00 00 00 00 00 # 0x00000424 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000434 -00 00 00 00 00 00 00 00 44 00 00 00 00 00 00 00 # 0x00000444 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000454 -00 00 00 00 00 00 00 00 00 45 00 00 00 00 00 00 # 0x00000464 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000474 -00 # 0x00000484 +# 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 0x00000485: screen record, payload 46 bytes -02 00 2e 00 00 00 05 b9 5a 3a 01 00 01 00 01 00 # 0x00000485 -02 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000495 -00 00 00 00 00 00 00 00 00 08 00 00 00 00 00 00 # 0x000004a5 -00 00 00 00 00 00 00 00 # 0x000004b5 +# 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 0x000004bd: page record, payload 119 bytes -03 00 77 00 00 00 84 a6 e9 08 02 00 03 00 00 00 # 0x000004bd -00 00 80 00 c0 00 00 02 00 00 00 08 00 00 03 00 # 0x000004cd -00 00 00 00 00 00 00 72 00 00 00 00 00 00 00 00 # 0x000004dd -00 00 00 00 00 00 00 6e 00 00 00 00 00 00 00 03 # 0x000004ed -00 00 00 00 00 00 00 00 61 00 00 00 00 00 00 00 # 0x000004fd -00 00 00 00 00 00 00 00 74 00 00 00 00 00 00 00 # 0x0000050d -03 00 00 00 00 00 00 00 00 65 00 00 00 00 00 00 # 0x0000051d -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000052d -00 # 0x0000053d +# 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 0x0000053e: ready record, payload 32 bytes -05 00 20 00 00 00 29 ce 51 e1 28 ab 80 18 19 00 # 0x0000053e -ef b3 15 fa 9d 03 c3 42 7f 77 2d f3 0c 00 87 cc # 0x0000054e -a1 dc ac ce 6c fc fb 27 66 e1 # 0x0000055e +# 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 0x00000568: history record, payload 16 bytes -04 00 10 00 00 00 cc 85 a3 b6 00 00 02 00 00 00 # 0x00000568 -04 00 00 00 00 00 00 00 00 00 # 0x00000578 +# 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 0x00000582: page record, payload 86 bytes -03 00 56 00 00 00 52 3b 6e 8d 02 00 02 00 00 00 # 0x00000582 -00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x00000592 -00 00 00 00 00 00 00 42 00 00 00 00 00 00 00 00 # 0x000005a2 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005b2 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005c2 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005d2 +# 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 0x000005e2: page record, payload 86 bytes -03 00 56 00 00 00 fb bc 15 06 02 00 02 00 00 00 # 0x000005e2 -00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x000005f2 -00 00 00 00 00 00 00 41 00 00 00 00 00 00 00 00 # 0x00000602 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000612 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000622 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000632 +# 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 0x00000642: history record, payload 16 bytes -04 00 10 00 00 00 39 57 cc cf 01 00 00 00 00 00 # 0x00000642 -00 00 00 00 00 00 00 00 00 00 # 0x00000652 +# 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 0x0000065c: finish record, payload 32 bytes -06 00 20 00 00 00 05 71 15 22 3e 4d e9 7a c1 02 # 0x0000065c -0b a3 6a 10 a0 88 1c 07 03 2c 68 52 15 be 76 d0 # 0x0000066c -4c a9 a6 2b fd f4 ac 49 62 2b # 0x0000067c +# 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/history-header-v1.hex b/src/terminal/snapshot/testdata/history-header-v1.hex index 6e44379dc..dc8333e5b 100644 --- a/src/terminal/snapshot/testdata/history-header-v1.hex +++ b/src/terminal/snapshot/testdata/history-header-v1.hex @@ -7,4 +7,4 @@ # On mismatch, the candidate is copied to the repository root. # offset 0x00000000: encoded bytes -01 00 04 03 02 01 08 07 06 05 04 03 02 01 0a 09 # 0x00000000 +01 00 04 03 02 01 # 0x00000000 diff --git a/src/terminal/snapshot/testdata/screen-header-v1.hex b/src/terminal/snapshot/testdata/screen-header-v1.hex index 950d5736b..7a29df76c 100644 --- a/src/terminal/snapshot/testdata/screen-header-v1.hex +++ b/src/terminal/snapshot/testdata/screen-header-v1.hex @@ -7,6 +7,7 @@ # On mismatch, the candidate is copied to the repository root. # offset 0x00000000: encoded bytes -01 00 03 02 05 04 07 06 03 19 00 00 00 00 01 7f # 0x00000000 -00 00 02 12 34 56 ff 03 00 00 0d 0c 0b 0a e4 3d # 0x00000010 -02 07 01 02 04 08 10 1f 00 11 02 03 01 # 0x00000020 +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/verify-kaitai.py b/src/terminal/snapshot/verify-kaitai.py index 1a88ab41c..fb3c750fe 100755 --- a/src/terminal/snapshot/verify-kaitai.py +++ b/src/terminal/snapshot/verify-kaitai.py @@ -256,19 +256,14 @@ def validate_complete_snapshot(snapshot: Any, data: bytes) -> None: "the active area" ) overlap_rows = screen_rows - terminal_header.rows - if payload.screen_overlap_rows != overlap_rows: - raise ValueError( - f"HISTORY key {payload.key}: screen_overlap_rows " - f"{payload.screen_overlap_rows} does not match {overlap_rows}" - ) - - total_rows = payload.screen_overlap_rows + sum( + history_rows = overlap_rows + sum( page.payload.header.rows for page in sequence.pages ) - if total_rows != payload.total_rows: + if history_rows != screen.screen.payload.header.history_rows: raise ValueError( - f"HISTORY key {payload.key}: total_rows " - f"{payload.total_rows} does not match {total_rows}" + 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): From 05d4934848307cb3025702d480a3bde20d901250 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 13:16:42 -0700 Subject: [PATCH 34/35] terminal/snapshot: better root export Expose complete encode and decode entry points directly from terminal.snapshot instead of requiring terminal.snapshot.snapshot. Reorder the decode APIs to accept the allocator and I/O context before the reader. --- src/terminal/snapshot/main.zig | 14 ++++++++- src/terminal/snapshot/snapshot.zig | 50 +++++++++++++++--------------- 2 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index 6eb580f21..7e546a5e6 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -112,6 +112,11 @@ //! 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. @@ -124,10 +129,17 @@ 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 snapshot = @import("snapshot.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/snapshot.zig b/src/terminal/snapshot/snapshot.zig index 15234d101..ac76df44f 100644 --- a/src/terminal/snapshot/snapshot.zig +++ b/src/terminal/snapshot/snapshot.zig @@ -107,9 +107,9 @@ pub const DecodeError = envelope.DecodeError || /// checkpoints, declared sequence counts, and unique cross-record screen /// routing remain strict. pub fn decode( - source: *std.Io.Reader, - io_: std.Io, 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. @@ -230,11 +230,11 @@ pub const DecodeExactError = DecodeError || std.Io.Reader.Error || error{ /// 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( - source: *std.Io.Reader, - io_: std.Io, alloc: Allocator, + io_: std.Io, + source: *std.Io.Reader, ) DecodeExactError!Terminal { - var result = try decode(source, io_, alloc); + var result = try decode(alloc, io_, source); errdefer result.deinit(alloc); _ = source.peekByte() catch |err| switch (err) { @@ -368,9 +368,9 @@ test "complete snapshot round trip with history and alternate screen" { var source_buffer: [1]u8 = undefined; var limited = encoded_source.limited(.unlimited, &source_buffer); var restored = try decode( - &limited.interface, - testing.io, testing.allocator, + testing.io, + &limited.interface, ); defer restored.deinit(testing.allocator); @@ -429,9 +429,9 @@ test "complete snapshot round trip with history and alternate screen" { var reversed_source: std.Io.Reader = .fixed(reversed.written()); var reversed_restored = try decode( - &reversed_source, - testing.io, testing.allocator, + testing.io, + &reversed_source, ); defer reversed_restored.deinit(testing.allocator); try testing.expectEqual( @@ -495,9 +495,9 @@ test "complete snapshot preserves Kitty virtual placeholders" { var encoded_source: std.Io.Reader = .fixed(encoded.written()); var restored = try decode( - &encoded_source, - testing.io, testing.allocator, + testing.io, + &encoded_source, ); defer restored.deinit(testing.allocator); @@ -544,9 +544,9 @@ test "complete snapshot encoding streams from the current writer position" { nonempty.written()[snapshot_offset..], ); var appended = try decode( - &appended_source, - testing.io, testing.allocator, + testing.io, + &appended_source, ); appended.deinit(testing.allocator); @@ -594,7 +594,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" { var reordered_source: std.Io.Reader = .fixed(reordered.written()); try testing.expectError( error.UnexpectedRecordTag, - decode(&reordered_source, testing.io, testing.allocator), + decode(testing.allocator, testing.io, &reordered_source), ); // Construct a correctly framed READY with an intentionally unrelated @@ -621,7 +621,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" { ); try testing.expectError( error.InvalidDigest, - decode(&invalid_ready_source, testing.io, testing.allocator), + decode(testing.allocator, testing.io, &invalid_ready_source), ); // A SCREEN key must name one of the slots declared by TERMINAL. @@ -638,7 +638,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" { var undeclared_source: std.Io.Reader = .fixed(undeclared.written()); try testing.expectError( error.UnexpectedScreenKey, - decode(&undeclared_source, testing.io, testing.allocator), + decode(testing.allocator, testing.io, &undeclared_source), ); // HISTORY sequences are also routed by key, which must name a declared @@ -660,7 +660,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" { ); try testing.expectError( error.UnexpectedHistoryKey, - decode(&undeclared_history_source, testing.io, testing.allocator), + decode(testing.allocator, testing.io, &undeclared_history_source), ); // The declared count cannot be satisfied by repeating the same key. @@ -679,7 +679,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" { var duplicate_source: std.Io.Reader = .fixed(duplicate.written()); try testing.expectError( error.DuplicateScreen, - decode(&duplicate_source, testing.io, testing.allocator), + decode(testing.allocator, testing.io, &duplicate_source), ); // The declared count cannot be satisfied by repeating one HISTORY key. @@ -706,7 +706,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" { ); try testing.expectError( error.DuplicateHistory, - decode(&duplicate_history_source, testing.io, testing.allocator), + decode(testing.allocator, testing.io, &duplicate_history_source), ); } @@ -726,7 +726,7 @@ test "complete snapshot leaves continuation bytes unread" { try encoded.writer.writeAll("pty"); var source: std.Io.Reader = .fixed(encoded.written()); - var restored = try decode(&source, testing.io, testing.allocator); + var restored = try decode(testing.allocator, testing.io, &source); defer restored.deinit(testing.allocator); var continuation: [3]u8 = undefined; @@ -736,16 +736,16 @@ test "complete snapshot leaves continuation bytes unread" { var exact_source: std.Io.Reader = .fixed(encoded.written()); try testing.expectError( error.TrailingData, - decodeExact(&exact_source, testing.io, testing.allocator), + decodeExact(testing.allocator, testing.io, &exact_source), ); var bounded_source: std.Io.Reader = .fixed( encoded.written()[0..snapshot_len], ); var bounded = try decodeExact( - &bounded_source, - testing.io, testing.allocator, + testing.io, + &bounded_source, ); defer bounded.deinit(testing.allocator); } @@ -765,9 +765,9 @@ test "complete snapshots decode sequentially from one reader" { try encode(testing.allocator, &encoded.writer, &t); var source: std.Io.Reader = .fixed(encoded.written()); - var first = try decode(&source, testing.io, testing.allocator); + var first = try decode(testing.allocator, testing.io, &source); defer first.deinit(testing.allocator); - var second = try decode(&source, testing.io, testing.allocator); + var second = try decode(testing.allocator, testing.io, &source); defer second.deinit(testing.allocator); try testing.expectError(error.EndOfStream, source.takeByte()); } From 6b09bb3fcaad6805c56ed51528dd86df33d13030 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 13:21:53 -0700 Subject: [PATCH 35/35] terminal/snapshot: ignore hex files in typos Exclude annotated snapshot fixture hex files from typo checking. Their arbitrary binary byte sequences can otherwise be misidentified as misspelled words. --- typos.toml | 2 ++ 1 file changed, 2 insertions(+) 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",