From 70e41e96d334d02668f8fda139fea3c020cf11bf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 1 Aug 2026 15:13:18 -0700 Subject: [PATCH] terminal/snapshot: pty continuation Builds on #13544 This adds a new CONTINUATION record type that is sent before READY. CONTINUATION contains the bytes (if any) that will bring a ground-state VT state machine up to the same state. This allows snapshotting a terminal instance that is, for example, blocked waiting for a caller to complet an in-flight Kitty graphics protocol send. In practice, I think this will be rare. But in theory, it avoids a DoS-type attack. The continuation state must be the MINIMAL set of bytes that will move the virtual terminal state from a ground to non-ground state. The reason it must be minimal is because any extra bytes can duplicate work into the terminal that might already exist. --- src/terminal/snapshot/checkpoint.zig | 9 +- src/terminal/snapshot/continuation.zig | 320 +++++++++ src/terminal/snapshot/main.zig | 41 +- src/terminal/snapshot/record.zig | 5 +- src/terminal/snapshot/snapshot.ksy | 24 +- src/terminal/snapshot/snapshot.zig | 662 ++++++++++++++++-- .../snapshot/testdata/complete-v1.hex | 55 +- .../snapshot/testdata/continuation-apc-v1.hex | 8 + .../snapshot/testdata/continuation-csi-v1.hex | 8 + .../snapshot/testdata/continuation-dcs-v1.hex | 8 + .../snapshot/testdata/continuation-esc-v1.hex | 8 + .../testdata/continuation-ground-v1.hex | 8 + .../snapshot/testdata/continuation-osc-v1.hex | 8 + .../testdata/continuation-utf8-v1.hex | 8 + src/terminal/snapshot/verify-kaitai.py | 3 +- src/terminal/stream_continuation.zig | 66 ++ 16 files changed, 1140 insertions(+), 101 deletions(-) create mode 100644 src/terminal/snapshot/continuation.zig create mode 100644 src/terminal/snapshot/testdata/continuation-apc-v1.hex create mode 100644 src/terminal/snapshot/testdata/continuation-csi-v1.hex create mode 100644 src/terminal/snapshot/testdata/continuation-dcs-v1.hex create mode 100644 src/terminal/snapshot/testdata/continuation-esc-v1.hex create mode 100644 src/terminal/snapshot/testdata/continuation-ground-v1.hex create mode 100644 src/terminal/snapshot/testdata/continuation-osc-v1.hex create mode 100644 src/terminal/snapshot/testdata/continuation-utf8-v1.hex diff --git a/src/terminal/snapshot/checkpoint.zig b/src/terminal/snapshot/checkpoint.zig index 79b04242f..6281f36db 100644 --- a/src/terminal/snapshot/checkpoint.zig +++ b/src/terminal/snapshot/checkpoint.zig @@ -4,10 +4,11 @@ //! before that checkpoint's record header. This binds record order and detects //! omitted or duplicated records in ways that independent record CRCs cannot. //! -//! READY covers the envelope, TERMINAL, and all SCREEN/PAGE sequences. FINISH -//! covers that same prefix plus the complete READY record and all HISTORY/PAGE -//! sequences. Neither digest includes its own record. FINISH terminates one -//! snapshot; bytes after it belong to the containing transport. +//! READY covers the envelope, TERMINAL, all SCREEN/PAGE sequences, and the +//! required CONTINUATION record. FINISH covers that same prefix plus the +//! complete READY record and all HISTORY/PAGE sequences. Neither digest includes +//! its own record. FINISH terminates one snapshot; bytes after it belong to the +//! containing transport. //! //! The digest does not replace record framing. Its 32-byte payload is still //! protected by the record's CRC32C. diff --git a/src/terminal/snapshot/continuation.zig b/src/terminal/snapshot/continuation.zig new file mode 100644 index 000000000..92b52da04 --- /dev/null +++ b/src/terminal/snapshot/continuation.zig @@ -0,0 +1,320 @@ +//! Continuation record type. +//! +//! Continuation uses the standard record header. All trailing bytes +//! are payload bytes for the continuation state. +//! +//! A continuation's payload must be the minimal bytes to go from +//! a ground state to a non-ground state in a VT emulator. Any extra +//! bytes are treated as a validation error. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const record = @import("record.zig"); +const stream_continuation = @import("../stream_continuation.zig"); +const test_fixture = @import("fixture.zig"); + +/// A borrowed continuation value supplied to encode, or an allocator-owned +/// value stored in a decoded complete snapshot. +pub const Value = union(enum) { + /// Neither the VT parser nor UTF-8 decoder has unfinished state. + ground, + + /// Canonical replay-safe PTY bytes. Empty is equivalent to `ground`. + bytes: []const u8, +}; + +pub const ValidateError = stream_continuation.ValidateError || error{ + /// The common record's u32 payload length cannot represent these bytes. + Overflow, +}; + +/// Validate a continuation before any snapshot bytes are emitted. +pub fn validate(value: Value) ValidateError!void { + switch (value) { + .ground => {}, + .bytes => |bytes| { + if (std.math.cast(u32, bytes.len) == null) { + return error.Overflow; + } + try stream_continuation.validate(bytes); + }, + } +} + +pub const EncodeError = ValidateError || record.Writer.FinishError; + +/// Encode one complete CONTINUATION record. +pub fn encode( + value: Value, + stream: *record.Writer, +) EncodeError!void { + try validate(value); + + const payload = stream.begin(.continuation); + errdefer stream.cancel(); + switch (value) { + .ground => {}, + .bytes => |bytes| try payload.writeAll(bytes), + } + try stream.finish(); +} + +pub const DecodeError = Allocator.Error || + stream_continuation.ValidateError || + record.Reader.InitError || + record.Reader.FinishError || + std.Io.Reader.Error || + error{ + /// The next record is valid but is not CONTINUATION. + UnexpectedRecordTag, + + /// The declared payload exceeds caller policy. + ContinuationLimitExceeded, + }; + +/// Decode and validate one CONTINUATION record. +/// +/// A zero-length payload returns `ground` without allocating. A nonempty +/// payload returns allocator-owned bytes; although `Value.bytes` is a const +/// slice, the caller must eventually free it with the same allocator. +pub fn decode( + alloc: Allocator, + source: *std.Io.Reader, + max_bytes: usize, +) DecodeError!Value { + var record_reader: record.Reader = undefined; + try record_reader.init(source); + if (record_reader.header.tag != .continuation) { + return error.UnexpectedRecordTag; + } + + // Validate the header payload vs our max size since the header + // enforces it. + const len: usize = record_reader.header.payload_len; + if (len > max_bytes) return error.ContinuationLimitExceeded; + + if (len == 0) { + // Empty is the ground representation, but finish is still required: + // its CRC covers the tag and zero length even though there is no body. + try record_reader.finish(); + return .ground; + } + + // Consume the bytes + const bytes = try alloc.alloc(u8, len); + errdefer alloc.free(bytes); + try record_reader.payloadReader().readSliceAll(bytes); + try record_reader.finish(); + + // Framing is valid, so it is now safe to classify the bytes as replay + // state. Validation requires a minimal, unfinished, side-effect-free tail. + try stream_continuation.validate(bytes); + + // Ownership transfers to the returned Value only after all validation. + return .{ .bytes = bytes }; +} + +const test_ground_fixture = test_fixture.parse(@embedFile("testdata/continuation-ground-v1.hex")); +const test_utf8_fixture = test_fixture.parse(@embedFile("testdata/continuation-utf8-v1.hex")); +const test_esc_fixture = test_fixture.parse(@embedFile("testdata/continuation-esc-v1.hex")); +const test_csi_fixture = test_fixture.parse(@embedFile("testdata/continuation-csi-v1.hex")); +const test_osc_fixture = test_fixture.parse(@embedFile("testdata/continuation-osc-v1.hex")); +const test_dcs_fixture = test_fixture.parse(@embedFile("testdata/continuation-dcs-v1.hex")); +const test_apc_fixture = test_fixture.parse(@embedFile("testdata/continuation-apc-v1.hex")); + +test "continuation golden records" { + const Golden = struct { + path: []const u8, + candidate: []const u8, + value: Value, + expected: []const u8, + }; + const values = [_]Golden{ + .{ + .path = "src/terminal/snapshot/testdata/continuation-ground-v1.hex", + .candidate = "snapshot_fixture-continuation-ground-v1.hex", + .value = .ground, + .expected = &test_ground_fixture, + }, + .{ + .path = "src/terminal/snapshot/testdata/continuation-utf8-v1.hex", + .candidate = "snapshot_fixture-continuation-utf8-v1.hex", + .value = .{ .bytes = "\xF0\x9F\x98" }, + .expected = &test_utf8_fixture, + }, + .{ + .path = "src/terminal/snapshot/testdata/continuation-esc-v1.hex", + .candidate = "snapshot_fixture-continuation-esc-v1.hex", + .value = .{ .bytes = "\x1b" }, + .expected = &test_esc_fixture, + }, + .{ + .path = "src/terminal/snapshot/testdata/continuation-csi-v1.hex", + .candidate = "snapshot_fixture-continuation-csi-v1.hex", + .value = .{ .bytes = "\x1b[31" }, + .expected = &test_csi_fixture, + }, + .{ + .path = "src/terminal/snapshot/testdata/continuation-osc-v1.hex", + .candidate = "snapshot_fixture-continuation-osc-v1.hex", + .value = .{ .bytes = "\x1b]2;title" }, + .expected = &test_osc_fixture, + }, + .{ + .path = "src/terminal/snapshot/testdata/continuation-dcs-v1.hex", + .candidate = "snapshot_fixture-continuation-dcs-v1.hex", + .value = .{ .bytes = "\x1bPqdata" }, + .expected = &test_dcs_fixture, + }, + .{ + .path = "src/terminal/snapshot/testdata/continuation-apc-v1.hex", + .candidate = "snapshot_fixture-continuation-apc-v1.hex", + .value = .{ .bytes = "\x1b_Gdata" }, + .expected = &test_apc_fixture, + }, + }; + + for (values) |golden| { + var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer encoded.deinit(); + var stream: record.Writer = .init( + std.testing.allocator, + &encoded.writer, + ); + defer stream.deinit(); + try encode(golden.value, &stream); + try test_fixture.expectEqual( + .bytes, + golden.path, + golden.candidate, + golden.expected, + encoded.written(), + ); + } +} + +test "continuation validates supported unfinished states" { + const values = [_][]const u8{ + "\x1b", + "\x1b[31", + "\x1b]2;title", + "\x1bPqdata", + "\x1b_Gdata", + "\xF0\x9F\x98", + }; + for (values) |bytes| try validate(.{ .bytes = bytes }); +} + +test "empty continuation bytes encode and decode as ground" { + const testing = std.testing; + try validate(.{ .bytes = "" }); + + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + var stream: record.Writer = .init(testing.allocator, &encoded.writer); + defer stream.deinit(); + try encode(.{ .bytes = "" }, &stream); + try testing.expectEqualStrings(&test_ground_fixture, encoded.written()); + + var source: std.Io.Reader = .fixed(encoded.written()); + const decoded = try decode(testing.allocator, &source, 0); + try testing.expect(decoded == .ground); +} + +test "continuation rejects invalid semantic shapes" { + const testing = std.testing; + try testing.expectError( + error.NoPendingState, + validate(.{ .bytes = "\x1b[31m" }), + ); + try testing.expectError( + error.ReplayWouldCommit, + validate(.{ .bytes = "\x1b[31\x07" }), + ); + try testing.expectError( + error.NonCanonicalContinuation, + validate(.{ .bytes = "prefix\x1b[31" }), + ); + try testing.expectError( + error.NonCanonicalContinuation, + validate(.{ .bytes = "\x1b[31\x1b[4" }), + ); +} + +test "continuation record round trip and cap" { + const testing = std.testing; + const values = [_]Value{ + .ground, + .{ .bytes = "\x1b[31" }, + .{ .bytes = "\xF0\x9F" }, + }; + + for (values) |value| { + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + var stream: record.Writer = .init(testing.allocator, &encoded.writer); + defer stream.deinit(); + try encode(value, &stream); + + var source: std.Io.Reader = .fixed(encoded.written()); + const decoded = try decode(testing.allocator, &source, 1024); + defer switch (decoded) { + .ground => {}, + .bytes => |bytes| testing.allocator.free(bytes), + }; + switch (value) { + .ground => try testing.expect(decoded == .ground), + .bytes => |expected| try testing.expectEqualStrings( + expected, + decoded.bytes, + ), + } + } + + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + var stream: record.Writer = .init(testing.allocator, &encoded.writer); + defer stream.deinit(); + try encode(.{ .bytes = "\x1b[31" }, &stream); + + var capped_source: std.Io.Reader = .fixed(encoded.written()); + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + try testing.expectError( + error.ContinuationLimitExceeded, + decode(failing.allocator(), &capped_source, 3), + ); + try testing.expectEqual( + @as(usize, record.Header.len), + capped_source.seek, + ); +} + +test "continuation record rejects truncation, checksum, and tag" { + const testing = std.testing; + + for (0..test_csi_fixture.len) |len| { + var source: std.Io.Reader = .fixed(test_csi_fixture[0..len]); + try testing.expectError( + error.EndOfStream, + decode(testing.allocator, &source, 1024), + ); + } + + var invalid_checksum = test_csi_fixture; + invalid_checksum[6] ^= 1; + var checksum_source: std.Io.Reader = .fixed(&invalid_checksum); + try testing.expectError( + error.InvalidChecksum, + decode(testing.allocator, &checksum_source, 1024), + ); + + var wrong_tag = test_ground_fixture; + std.mem.writeInt(u16, wrong_tag[0..2], @intFromEnum(record.Tag.ready), .little); + var tag_source: std.Io.Reader = .fixed(&wrong_tag); + try testing.expectError( + error.UnexpectedRecordTag, + decode(testing.allocator, &tag_source, 1024), + ); +} diff --git a/src/terminal/snapshot/main.zig b/src/terminal/snapshot/main.zig index 7e546a5e6..ad6efdcf9 100644 --- a/src/terminal/snapshot/main.zig +++ b/src/terminal/snapshot/main.zig @@ -10,11 +10,11 @@ //! 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. +//! READY denotes that enough authenticated state is present to render the +//! terminal and reconstruct its unfinished standard Stream state. The current +//! synchronous decoder still returns only after FINISH. A caller moves the +//! Terminal into final storage, replays CONTINUATION once, and only then applies +//! PTY bytes belonging after the snapshot cut. //! //! After READY, we send history pages (scrollback). //! @@ -53,6 +53,8 @@ //! | SCREEN * terminal.screen_count | //! | PAGE * each screen.page_count | //! +----------------------------------------+ +//! | CONTINUATION | +//! +----------------------------------------+ //! | READY | //! +----------------------------------------+ //! | HISTORY * terminal.screen_count | @@ -71,12 +73,16 @@ //! is zero. FINISH terminates the snapshot. Bytes after FINISH belong to the //! containing transport and are not consumed by snapshot decoding. //! +//! CONTINUATION contains the bytes required to bring the terminal's +//! VT parser/stream up to the same state, or no bytes if it should be +//! in the ground state. +//! //! READY and FINISH contain BLAKE3-256 digests of all preceding snapshot bytes. -//! READY therefore validates the renderable active-state prefix. FINISH covers -//! READY and all history as well, validating the complete snapshot and its -//! record ordering. Each SCREEN declares its complete logical history extent, -//! allowing a client to size its scrollbar at READY even though older PAGE -//! records arrive afterward. +//! READY therefore validates the renderable active state and continuation. +//! FINISH covers READY and all history as well, validating the complete snapshot +//! and its record ordering. Each SCREEN declares its complete logical history +//! extent, allowing a client to size its scrollbar at READY even though older +//! PAGE records arrive afterward. //! //! ## Encoding //! @@ -86,7 +92,9 @@ //! var output: std.Io.Writer.Allocating = .init(alloc); //! defer output.deinit(); //! -//! try snapshot.encode(alloc, &output.writer, &terminal); +//! try snapshot.encode(alloc, &output.writer, &terminal, .{ +//! .continuation = .ground, +//! }); //! //! const bytes = output.written(); //! ``` @@ -113,7 +121,11 @@ //! outside this ordered snapshot record sequence. //! //! ```zig -//! var terminal = try snapshot.decode(alloc, io, &reader); +//! var decoded = try snapshot.decode(alloc, io, &reader, .{ +//! .max_continuation_bytes = 1024 * 1024, +//! }); +//! defer decoded.deinit(alloc); +//! var terminal = decoded.toOwned(); //! defer terminal.deinit(alloc); //! ``` //! @@ -122,6 +134,7 @@ //! block when used with a live stream. pub const checkpoint = @import("checkpoint.zig"); +pub const continuation = @import("continuation.zig"); pub const envelope = @import("envelope.zig"); pub const grid = @import("grid.zig"); pub const history = @import("history.zig"); @@ -136,6 +149,10 @@ const codec = @import("snapshot.zig"); pub const EncodeError = codec.EncodeError; pub const DecodeError = codec.DecodeError; pub const DecodeExactError = codec.DecodeExactError; +pub const Continuation = codec.Continuation; +pub const EncodeOptions = codec.EncodeOptions; +pub const DecodeOptions = codec.DecodeOptions; +pub const Decoded = codec.Decoded; pub const encode = codec.encode; pub const decode = codec.decode; pub const decodeExact = codec.decodeExact; diff --git a/src/terminal/snapshot/record.zig b/src/terminal/snapshot/record.zig index 0126c2bcd..fd69d0b41 100644 --- a/src/terminal/snapshot/record.zig +++ b/src/terminal/snapshot/record.zig @@ -53,6 +53,9 @@ pub const Tag = enum(u16) { /// Digest validating the complete snapshot blob. finish = 6, + + /// Canonical unfinished standard TerminalStream input. + continuation = 7, }; /// The fixed framing that precedes every record payload. @@ -408,7 +411,7 @@ test "golden PAGE record header and checksum" { } test "reject invalid tags" { - for ([_]u16{ 0, 7, std.math.maxInt(u16) }) |tag| { + for ([_]u16{ 0, 8, 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/snapshot.ksy b/src/terminal/snapshot/snapshot.ksy index ad8f6c3a0..6a31fccd6 100644 --- a/src/terminal/snapshot/snapshot.ksy +++ b/src/terminal/snapshot/snapshot.ksy @@ -8,11 +8,12 @@ doc: | Ghostty terminal snapshot format version 1. A complete snapshot contains an envelope, terminal-wide state, one or two - renderable screen sequences, a READY checkpoint, matching history sequences, - and a FINISH checkpoint. SCREEN pages are oldest-to-newest. HISTORY pages are - newest-to-oldest. FINISH terminates the snapshot; bytes that follow belong to - the containing transport and are outside this schema. Each SCREEN declares - its complete logical history extent before READY. + renderable screen sequences, one raw standard-Stream CONTINUATION, a READY + checkpoint, matching history sequences, and a FINISH checkpoint. SCREEN pages + are oldest-to-newest. HISTORY pages are newest-to-oldest. FINISH terminates the + snapshot; bytes that follow belong to the containing transport and are outside + this schema. Each SCREEN declares its complete logical history extent before + READY. Record CRC32C values and checkpoint BLAKE3-256 digests are represented here but cannot be calculated by portable Kaitai Struct expressions. The adjacent @@ -26,6 +27,8 @@ seq: type: screen_sequence repeat: expr repeat-expr: terminal.payload.header.screen_count + - id: continuation + type: continuation_record - id: ready type: checkpoint_record(5) - id: histories @@ -42,6 +45,7 @@ enums: 4: history 5: ready 6: finish + 7: continuation screen_key: 0: primary 1: alternate @@ -202,6 +206,16 @@ types: type: history_payload size: header.payload_length + continuation_record: + doc: | + Raw canonical standard TerminalStream continuation bytes. An empty + payload explicitly represents ground state. + seq: + - id: header + type: record_header(7) + - id: payload + size: header.payload_length + checkpoint_record: params: - id: expected_tag diff --git a/src/terminal/snapshot/snapshot.zig b/src/terminal/snapshot/snapshot.zig index ac76df44f..5095809a1 100644 --- a/src/terminal/snapshot/snapshot.zig +++ b/src/terminal/snapshot/snapshot.zig @@ -4,6 +4,7 @@ const std = @import("std"); const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; const checkpoint = @import("checkpoint.zig"); +const continuation = @import("continuation.zig"); const envelope = @import("envelope.zig"); const test_fixture = @import("fixture.zig"); const history = @import("history.zig"); @@ -11,20 +12,25 @@ const record = @import("record.zig"); const screen = @import("screen.zig"); const terminal = @import("terminal.zig"); const Terminal = @import("../Terminal.zig"); +const TerminalStream = @import("../stream_terminal.zig").Stream; const terminal_kitty = @import("../kitty.zig"); const TerminalPageList = @import("../PageList.zig"); const TerminalScreen = @import("../Screen.zig"); const TerminalScreenKey = @import("../ScreenSet.zig").Key; -const test_complete_fixture = test_fixture.parse( - @embedFile("testdata/complete-v1.hex"), -); +/// Re-export continuation to make it a bit more ergonomic to reference. +pub const Continuation = continuation.Value; /// Errors possible while encoding one complete terminal snapshot. pub const EncodeError = terminal.EncodeError || screen.EncodeError || history.EncodeError || - checkpoint.EncodeError; + checkpoint.EncodeError || + continuation.EncodeError; + +pub const EncodeOptions = struct { + continuation: Continuation, +}; /// Encode one complete terminal snapshot. /// @@ -36,7 +42,11 @@ pub fn encode( alloc: Allocator, destination: *std.Io.Writer, t: *const Terminal, + options: EncodeOptions, ) EncodeError!void { + // Continuation errors must not emit even the snapshot envelope. + try continuation.validate(options.continuation); + var stream: record.Writer = .init(alloc, destination); defer stream.deinit(); @@ -58,11 +68,13 @@ pub fn encode( &stream, ); - // 4. Ready checkpoint. In the future we'll put our continuation - // state before this so pty bytes can also flow. + // 4. Standard Stream continuation. + try continuation.encode(options.continuation, &stream); + + // 5. Ready checkpoint. try checkpoint.encode(.ready, &stream); - // 5. History + // 6. History try history.encode( t.screens.get(.primary).?, .primary, @@ -74,7 +86,7 @@ pub fn encode( &stream, ); - // 6. Finish + // 7. Finish try checkpoint.encode(.finish, &stream); } @@ -84,6 +96,7 @@ pub const DecodeError = envelope.DecodeError || screen.DecodeError || history.DecodeError || checkpoint.DecodeError || + continuation.DecodeError || error{ /// A SCREEN names a key not declared by TERMINAL. UnexpectedScreenKey, @@ -98,11 +111,69 @@ pub const DecodeError = envelope.DecodeError || DuplicateHistory, }; -/// Restore one complete snapshot into a native terminal. +pub const DecodeOptions = struct { + /// Largest non-ground continuation the decoder may allocate and return. + /// Set this to zero when only ground-state snapshots are acceptable. + max_continuation_bytes: usize, +}; + +/// One complete decoded Terminal and the bytes needed to resume its Stream. +/// +/// A successful decode owns both values. Keep this result alive until the +/// Terminal's Stream has been restored, and always finish by calling `deinit`. +/// The usual restoration sequence is: +/// +/// 1. Inspect `continuation` and use its length to size continuation tracking. +/// 2. Call `toOwned` once and store the returned Terminal at its final address. +/// 3. Create the persistent, read-only standard TerminalStream for that +/// address. If `continuation` contains bytes, feed them exactly once and +/// verify that re-exporting the continuation returns the same bytes. +/// 4. Call `deinit` to release the decoded continuation. The transferred +/// Terminal and its Stream remain owned by the caller. +/// 5. Process PTY bytes that came after the snapshot cut. +/// +/// Do not create a Stream against the address of `terminal` in this struct. +/// `toOwned` moves the Terminal, which would leave such a Stream pointing at +/// its old address. +pub const Decoded = struct { + /// Present until `toOwned` transfers the Terminal to the caller. Callers + /// may inspect it, but must transfer it before attaching a persistent + /// TerminalStream. + terminal: ?Terminal, + + /// For decoded results, nonempty bytes are allocator-owned and remain + /// valid until `deinit`. Ground needs no replay. Bytes must be replayed + /// exactly once after the Terminal has reached its final address. + continuation: Continuation, + + /// Destroy an untransferred Terminal and always free continuation bytes. + /// After `toOwned`, this leaves the caller-owned Terminal untouched. + pub fn deinit(self: *Decoded, alloc: Allocator) void { + if (self.terminal) |*value| value.deinit(alloc); + switch (self.continuation) { + .ground => {}, + .bytes => |bytes| alloc.free(bytes), + } + self.* = undefined; + } + + /// Transfer the Terminal while retaining the continuation for replay. + /// + /// This may be called exactly once. Store the returned value directly at + /// its final address before creating the TerminalStream that will replay + /// `continuation`. + pub fn toOwned(self: *Decoded) Terminal { + const result = self.terminal.?; + self.terminal = null; + return result; + } +}; + +/// Restore one complete snapshot into a native Terminal and continuation. /// /// 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). +/// returned result 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. @@ -110,7 +181,8 @@ pub fn decode( alloc: Allocator, io_: std.Io, source: *std.Io.Reader, -) DecodeError!Terminal { + options: DecodeOptions, +) DecodeError!Decoded { // 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); @@ -128,7 +200,7 @@ pub fn decode( // 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 screen_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; @@ -151,7 +223,7 @@ pub fn decode( reader, io_, alloc, - options, + screen_options, ); errdefer decoded.deinit(); @@ -174,8 +246,20 @@ pub fn decode( ); } - // READY covers the exact envelope-through-SCREEN prefix. Finalizing does - // not consume the hasher, so the same stream continues toward FINISH. + // CONTINUATION is required after every active screen. Nonempty bytes are + // owned locally until the complete snapshot validates. + const decoded_continuation = try continuation.decode( + alloc, + reader, + options.max_continuation_bytes, + ); + errdefer switch (decoded_continuation) { + .ground => {}, + .bytes => |bytes| alloc.free(bytes), + }; + + // READY covers the exact envelope-through-CONTINUATION prefix. Finalizing + // does not consume the hasher, so the stream continues toward FINISH. try checkpoint.decode(.ready, &stream); // HISTORY keys make this sequence order-independent just like SCREEN. @@ -216,7 +300,10 @@ pub fn decode( // 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; + return .{ + .terminal = result, + .continuation = decoded_continuation, + }; } /// Errors possible while restoring a snapshot that must end at end-of-file. @@ -233,8 +320,9 @@ pub fn decodeExact( alloc: Allocator, io_: std.Io, source: *std.Io.Reader, -) DecodeExactError!Terminal { - var result = try decode(alloc, io_, source); + options: DecodeOptions, +) DecodeExactError!Decoded { + var result = try decode(alloc, io_, source, options); errdefer result.deinit(alloc); _ = source.peekByte() catch |err| switch (err) { @@ -244,6 +332,10 @@ pub fn decodeExact( return error.TrailingData; } +const test_encode_options: EncodeOptions = .{ .continuation = .ground }; +const test_decode_options: DecodeOptions = .{ .max_continuation_bytes = 1024 }; +const test_complete_fixture = test_fixture.parse(@embedFile("testdata/complete-v1.hex")); + test "complete snapshot round trip with history and alternate screen" { const testing = std.testing; @@ -330,7 +422,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(testing.allocator, &encoded.writer, &t); + try encode(testing.allocator, &encoded.writer, &t, test_encode_options); try testing.expectEqualDeep(source_memory, primary.pages.memoryStats()); try test_fixture.expectEqual( .snapshot, @@ -348,7 +440,7 @@ test "complete snapshot round trip with history and alternate screen" { std.crypto.hash.Blake3.init(.{}), &.{}, ); - try encode(testing.allocator, &hashing.writer, &t); + try encode(testing.allocator, &hashing.writer, &t, test_encode_options); try testing.expectEqual( @as(u64, test_complete_fixture.len), discard.fullCount(), @@ -371,32 +463,42 @@ test "complete snapshot round trip with history and alternate screen" { testing.allocator, testing.io, &limited.interface, + test_decode_options, ); defer restored.deinit(testing.allocator); + const restored_terminal = &restored.terminal.?; - try testing.expectEqual(TerminalScreenKey.alternate, restored.screens.active_key); try testing.expectEqual( - restored.screens.get(.alternate).?, - restored.screens.active, + TerminalScreenKey.alternate, + restored_terminal.screens.active_key, + ); + try testing.expectEqual( + restored_terminal.screens.get(.alternate).?, + restored_terminal.screens.active, ); try testing.expectEqualStrings( "file:///tmp/snapshot", - restored.getPwd().?, + restored_terminal.getPwd().?, ); try testing.expectEqualStrings( "complete snapshot", - restored.getTitle().?, + restored_terminal.getTitle().?, ); try testing.expectEqual( primary.pages.scrollbar().total, - restored.screens.get(.primary).?.pages.scrollbar().total, + restored_terminal.screens.get(.primary).?.pages.scrollbar().total, ); // Re-encoding is a compact semantic equality check over all TERMINAL, // SCREEN, PAGE, and HISTORY fields and both checkpoint boundaries. var reencoded: std.Io.Writer.Allocating = .init(testing.allocator); defer reencoded.deinit(); - try encode(testing.allocator, &reencoded.writer, &restored); + try encode( + testing.allocator, + &reencoded.writer, + restored_terminal, + test_encode_options, + ); try testing.expectEqualStrings( &test_complete_fixture, reencoded.written(), @@ -418,6 +520,7 @@ test "complete snapshot round trip with history and alternate screen" { &reversed_stream, ); try screen.encode(primary, .primary, &reversed_stream); + try continuation.encode(.ground, &reversed_stream); try checkpoint.encode(.ready, &reversed_stream); try history.encode( t.screens.get(.alternate).?, @@ -432,22 +535,302 @@ test "complete snapshot round trip with history and alternate screen" { testing.allocator, testing.io, &reversed_source, + test_decode_options, ); defer reversed_restored.deinit(testing.allocator); + const reversed_terminal = &reversed_restored.terminal.?; try testing.expectEqual( TerminalScreenKey.alternate, - reversed_restored.screens.active_key, + reversed_terminal.screens.active_key, ); try testing.expectEqual( @as(usize, 0), - reversed_restored.screens.generation(.primary), + reversed_terminal.screens.generation(.primary), ); try testing.expectEqual( @as(usize, 0), - reversed_restored.screens.generation(.alternate), + reversed_terminal.screens.generation(.alternate), ); } +test "complete snapshot restores a canonical Stream continuation" { + const testing = std.testing; + + var source_terminal = try Terminal.init( + testing.io, + testing.allocator, + .{ .cols = 8, .rows = 2 }, + ); + defer source_terminal.deinit(testing.allocator); + var source_stream = TerminalStream.init(.{ + .allocator = testing.allocator, + .handler = .init(&source_terminal), + .continuation_max_bytes = 1024, + }); + defer source_stream.deinit(); + + source_stream.nextSlice("A\x1b[31"); + var exported_bytes: [1024]u8 = undefined; + var exported: std.Io.Writer = .fixed(&exported_bytes); + try source_stream.writeContinuation(&exported); + try testing.expectEqualStrings("\x1b[31", exported.buffered()); + + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &source_terminal, .{ + .continuation = .{ .bytes = exported.buffered() }, + }); + + var encoded_source: std.Io.Reader = .fixed(encoded.written()); + var decoded = try decode( + testing.allocator, + testing.io, + &encoded_source, + .{ .max_continuation_bytes = 1024 }, + ); + defer decoded.deinit(testing.allocator); + try testing.expectEqualStrings( + exported.buffered(), + decoded.continuation.bytes, + ); + + var restored_terminal = decoded.toOwned(); + defer restored_terminal.deinit(testing.allocator); + try testing.expect(decoded.terminal == null); + var restored_stream = TerminalStream.init(.{ + .allocator = testing.allocator, + .handler = .init(&restored_terminal), + .continuation_max_bytes = 1024, + }); + defer restored_stream.deinit(); + + restored_stream.nextSlice(decoded.continuation.bytes); + var reexported_bytes: [1024]u8 = undefined; + var reexported: std.Io.Writer = .fixed(&reexported_bytes); + try restored_stream.writeContinuation(&reexported); + try testing.expectEqualStrings(exported.buffered(), reexported.buffered()); + + // The identical post-cut bytes may use different feed chunking without + // changing terminal semantics. + source_stream.nextSlice("mB"); + restored_stream.next('m'); + restored_stream.next('B'); + const source_text = try source_terminal.plainString(testing.allocator); + defer testing.allocator.free(source_text); + const restored_text = try restored_terminal.plainString(testing.allocator); + defer testing.allocator.free(restored_text); + try testing.expectEqualStrings(source_text, restored_text); + try testing.expectEqual( + source_terminal.screens.active.cursor.style_id, + restored_terminal.screens.active.cursor.style_id, + ); +} + +test "complete snapshot validates continuation before writing" { + const testing = std.testing; + var t = try Terminal.init( + testing.io, + testing.allocator, + .{ .cols = 2, .rows = 1 }, + ); + defer t.deinit(testing.allocator); + + var destination: std.Io.Writer.Allocating = .init(testing.allocator); + defer destination.deinit(); + try destination.writer.writeAll("prefix"); + try testing.expectError( + error.NoPendingState, + encode(testing.allocator, &destination.writer, &t, .{ + .continuation = .{ .bytes = "\x1b[31m" }, + }), + ); + try testing.expectEqualStrings("prefix", destination.written()); + + var tail = [_]u8{ 0x1b, '[', '3', '1' }; + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &t, .{ + .continuation = .{ .bytes = &tail }, + }); + tail[2] = '4'; + + var source: std.Io.Reader = .fixed(encoded.written()); + var decoded = try decode( + testing.allocator, + testing.io, + &source, + test_decode_options, + ); + defer decoded.deinit(testing.allocator); + try testing.expectEqualStrings("\x1b[31", decoded.continuation.bytes); +} + +test "complete snapshot waits for continuation tracking recovery" { + const testing = std.testing; + var t = try Terminal.init( + testing.io, + testing.allocator, + .{ .cols = 2, .rows = 1 }, + ); + defer t.deinit(testing.allocator); + var stream = TerminalStream.init(.{ + .allocator = testing.allocator, + .handler = .init(&t), + .continuation_max_bytes = 4, + }); + defer stream.deinit(); + + stream.nextSlice("\x1b[123"); + var unavailable_bytes: [4]u8 = undefined; + var unavailable: std.Io.Writer = .fixed(&unavailable_bytes); + try testing.expectError( + error.ContinuationUnavailable, + stream.writeContinuation(&unavailable), + ); + + // A new replay start replaces the lost suffix and makes a later cut + // publishable without affecting normal terminal parsing. + stream.nextSlice("\x1b["); + var recovered_bytes: [4]u8 = undefined; + var recovered: std.Io.Writer = .fixed(&recovered_bytes); + try stream.writeContinuation(&recovered); + try testing.expectEqualStrings("\x1b[", recovered.buffered()); + + var encoded: std.Io.Writer.Allocating = .init(testing.allocator); + defer encoded.deinit(); + try encode(testing.allocator, &encoded.writer, &t, .{ + .continuation = .{ .bytes = recovered.buffered() }, + }); +} + +test "complete snapshot preserves every supported continuation cut" { + const testing = std.testing; + const corpora = [_][]const u8{ + "plain \xF0\x9F\x98\x84 utf8", + "bad \xE0\xA0\xF0\x9F\x98\x84 utf8", + "\x1b[1\x07;2mstyled\x1b[0m", + "\x1b]2;window title\x1b\\text", + "\x1bP$qm\x1b\\text", + "\x1b_Ga=q;payload\x1b\\text", + "\x1b_25a1;s\x1b\\text", + "\x1b]2;first\x1b\\\x1b_Gsecond", + "\x1b[12\x9D2;title\x1b\\text", + "\x1b[12\x18text\x1b[1\x1Atext", + }; + + for (corpora) |corpus| for (0..corpus.len + 1) |cut| { + var source_terminal = try Terminal.init( + testing.io, + testing.allocator, + .{ .cols = 20, .rows = 4 }, + ); + defer source_terminal.deinit(testing.allocator); + var source_stream = TerminalStream.init(.{ + .allocator = testing.allocator, + .handler = .init(&source_terminal), + .continuation_max_bytes = 4096, + }); + defer source_stream.deinit(); + source_stream.nextSlice(corpus[0..cut]); + + var cut_bytes: [4096]u8 = undefined; + var cut_writer: std.Io.Writer = .fixed(&cut_bytes); + try source_stream.writeContinuation(&cut_writer); + const cut_continuation: Continuation = if (cut_writer.end == 0) + .ground + else + .{ .bytes = cut_writer.buffered() }; + + var snapshot_bytes: std.Io.Writer.Allocating = .init(testing.allocator); + defer snapshot_bytes.deinit(); + try encode( + testing.allocator, + &snapshot_bytes.writer, + &source_terminal, + .{ .continuation = cut_continuation }, + ); + + var snapshot_source: std.Io.Reader = .fixed(snapshot_bytes.written()); + var decoded = try decode( + testing.allocator, + testing.io, + &snapshot_source, + .{ .max_continuation_bytes = 4096 }, + ); + defer decoded.deinit(testing.allocator); + var restored_terminal = decoded.toOwned(); + defer restored_terminal.deinit(testing.allocator); + var restored_stream = TerminalStream.init(.{ + .allocator = testing.allocator, + .handler = .init(&restored_terminal), + .continuation_max_bytes = 4096, + }); + defer restored_stream.deinit(); + switch (decoded.continuation) { + .ground => {}, + .bytes => |bytes| restored_stream.nextSlice(bytes), + } + + var reexport_bytes: [4096]u8 = undefined; + var reexport_writer: std.Io.Writer = .fixed(&reexport_bytes); + try restored_stream.writeContinuation(&reexport_writer); + try testing.expectEqualStrings( + cut_writer.buffered(), + reexport_writer.buffered(), + ); + + source_stream.nextSlice(corpus[cut..]); + var offset = cut; + var partition = cut +% corpus.len +% 1; + while (offset < corpus.len) { + partition = partition *% 1664525 +% 1013904223; + const len = @min(1 + partition % 7, corpus.len - offset); + restored_stream.nextSlice(corpus[offset..][0..len]); + offset += len; + } + + var source_final_bytes: [4096]u8 = undefined; + var source_final_writer: std.Io.Writer = .fixed(&source_final_bytes); + try source_stream.writeContinuation(&source_final_writer); + var restored_final_bytes: [4096]u8 = undefined; + var restored_final_writer: std.Io.Writer = .fixed(&restored_final_bytes); + try restored_stream.writeContinuation(&restored_final_writer); + try testing.expectEqualStrings( + source_final_writer.buffered(), + restored_final_writer.buffered(), + ); + + const final_continuation: Continuation = if (source_final_writer.end == 0) + .ground + else + .{ .bytes = source_final_writer.buffered() }; + var source_final_snapshot: std.Io.Writer.Allocating = .init( + testing.allocator, + ); + defer source_final_snapshot.deinit(); + try encode( + testing.allocator, + &source_final_snapshot.writer, + &source_terminal, + .{ .continuation = final_continuation }, + ); + var restored_final_snapshot: std.Io.Writer.Allocating = .init( + testing.allocator, + ); + defer restored_final_snapshot.deinit(); + try encode( + testing.allocator, + &restored_final_snapshot.writer, + &restored_terminal, + .{ .continuation = final_continuation }, + ); + try testing.expectEqualStrings( + source_final_snapshot.written(), + restored_final_snapshot.written(), + ); + }; +} + test "complete snapshot preserves Kitty virtual placeholders" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; @@ -491,17 +874,19 @@ test "complete snapshot preserves Kitty virtual placeholders" { 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, test_encode_options); var encoded_source: std.Io.Reader = .fixed(encoded.written()); var restored = try decode( testing.allocator, testing.io, &encoded_source, + test_decode_options, ); defer restored.deinit(testing.allocator); + const restored_terminal = &restored.terminal.?; - const restored_cell = restored.screens.active.pages.getCell(.{ + const restored_cell = restored_terminal.screens.active.pages.getCell(.{ .screen = .{}, }).?; try testing.expectEqual( @@ -512,11 +897,11 @@ test "complete snapshot preserves Kitty virtual placeholders" { try testing.expect(restored_cell.row.kitty_virtual_placeholder); try testing.expectEqual( @as(usize, 0), - restored.screens.active.kitty_images.images.count(), + restored_terminal.screens.active.kitty_images.images.count(), ); try testing.expectEqual( @as(usize, 0), - restored.screens.active.kitty_images.placements.count(), + restored_terminal.screens.active.kitty_images.placements.count(), ); } @@ -535,7 +920,7 @@ test "complete snapshot encoding streams from the current writer position" { defer nonempty.deinit(); try nonempty.writer.writeAll("prefix"); const snapshot_offset = nonempty.written().len; - try encode(testing.allocator, &nonempty.writer, &t); + try encode(testing.allocator, &nonempty.writer, &t, test_encode_options); try testing.expectEqualStrings( "prefix", nonempty.written()[0..snapshot_offset], @@ -547,6 +932,7 @@ test "complete snapshot encoding streams from the current writer position" { testing.allocator, testing.io, &appended_source, + test_decode_options, ); appended.deinit(testing.allocator); @@ -558,7 +944,12 @@ test "complete snapshot encoding streams from the current writer position" { try destination.writer.writeAll("prefix"); try testing.expectError( error.InvalidPalette, - encode(testing.allocator, &destination.writer, &t), + encode( + testing.allocator, + &destination.writer, + &t, + test_encode_options, + ), ); var expected_envelope: [envelope.encoded_len]u8 = undefined; var envelope_writer: std.Io.Writer = .fixed(&expected_envelope); @@ -579,6 +970,58 @@ test "complete snapshot rejects ordering and invalid checkpoints" { defer t.deinit(testing.allocator); const primary = t.screens.get(.primary).?; + // Old version-1 snapshots proceeded directly from SCREEN to READY. READY + // is individually valid here, but CONTINUATION is now required first. + var old_order: std.Io.Writer.Allocating = .init(testing.allocator); + defer old_order.deinit(); + var old_order_stream: record.Writer = .init( + testing.allocator, + &old_order.writer, + ); + defer old_order_stream.deinit(); + try envelope.encode(old_order_stream.writer()); + try terminal.encode(&t, &old_order_stream); + try screen.encode(primary, .primary, &old_order_stream); + try checkpoint.encode(.ready, &old_order_stream); + var old_order_source: std.Io.Reader = .fixed(old_order.written()); + try testing.expectError( + error.UnexpectedRecordTag, + decode( + testing.allocator, + testing.io, + &old_order_source, + test_decode_options, + ), + ); + + // A second CONTINUATION is rejected where READY is required. + var duplicate_continuation: std.Io.Writer.Allocating = .init( + testing.allocator, + ); + defer duplicate_continuation.deinit(); + var duplicate_continuation_stream: record.Writer = .init( + testing.allocator, + &duplicate_continuation.writer, + ); + defer duplicate_continuation_stream.deinit(); + try envelope.encode(duplicate_continuation_stream.writer()); + try terminal.encode(&t, &duplicate_continuation_stream); + try screen.encode(primary, .primary, &duplicate_continuation_stream); + try continuation.encode(.ground, &duplicate_continuation_stream); + try continuation.encode(.ground, &duplicate_continuation_stream); + var duplicate_continuation_source: std.Io.Reader = .fixed( + duplicate_continuation.written(), + ); + try testing.expectError( + error.UnexpectedRecordTag, + decode( + testing.allocator, + testing.io, + &duplicate_continuation_source, + test_decode_options, + ), + ); + // HISTORY is individually valid here, but the full decoder requires the // primary SCREEN before READY. var reordered: std.Io.Writer.Allocating = .init(testing.allocator); @@ -594,7 +1037,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" { var reordered_source: std.Io.Reader = .fixed(reordered.written()); try testing.expectError( error.UnexpectedRecordTag, - decode(testing.allocator, testing.io, &reordered_source), + decode( + testing.allocator, + testing.io, + &reordered_source, + test_decode_options, + ), ); // Construct a correctly framed READY with an intentionally unrelated @@ -609,6 +1057,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" { try envelope.encode(invalid_ready_stream.writer()); try terminal.encode(&t, &invalid_ready_stream); try screen.encode(primary, .primary, &invalid_ready_stream); + try continuation.encode(.ground, &invalid_ready_stream); const ready_payload = invalid_ready_stream.begin(.ready); errdefer invalid_ready_stream.cancel(); try ready_payload.splatByteAll( @@ -621,7 +1070,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" { ); try testing.expectError( error.InvalidDigest, - decode(testing.allocator, testing.io, &invalid_ready_source), + decode( + testing.allocator, + testing.io, + &invalid_ready_source, + test_decode_options, + ), ); // A SCREEN key must name one of the slots declared by TERMINAL. @@ -638,7 +1092,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" { var undeclared_source: std.Io.Reader = .fixed(undeclared.written()); try testing.expectError( error.UnexpectedScreenKey, - decode(testing.allocator, testing.io, &undeclared_source), + decode( + testing.allocator, + testing.io, + &undeclared_source, + test_decode_options, + ), ); // HISTORY sequences are also routed by key, which must name a declared @@ -653,6 +1112,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" { try envelope.encode(undeclared_history_stream.writer()); try terminal.encode(&t, &undeclared_history_stream); try screen.encode(primary, .primary, &undeclared_history_stream); + try continuation.encode(.ground, &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( @@ -660,7 +1120,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" { ); try testing.expectError( error.UnexpectedHistoryKey, - decode(testing.allocator, testing.io, &undeclared_history_source), + decode( + testing.allocator, + testing.io, + &undeclared_history_source, + test_decode_options, + ), ); // The declared count cannot be satisfied by repeating the same key. @@ -679,7 +1144,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" { var duplicate_source: std.Io.Reader = .fixed(duplicate.written()); try testing.expectError( error.DuplicateScreen, - decode(testing.allocator, testing.io, &duplicate_source), + decode( + testing.allocator, + testing.io, + &duplicate_source, + test_decode_options, + ), ); // The declared count cannot be satisfied by repeating one HISTORY key. @@ -698,6 +1168,7 @@ test "complete snapshot rejects ordering and invalid checkpoints" { .alternate, &duplicate_history_stream, ); + try continuation.encode(.ground, &duplicate_history_stream); try checkpoint.encode(.ready, &duplicate_history_stream); try history.encode(primary, .primary, &duplicate_history_stream); try history.encode(primary, .primary, &duplicate_history_stream); @@ -706,7 +1177,12 @@ test "complete snapshot rejects ordering and invalid checkpoints" { ); try testing.expectError( error.DuplicateHistory, - decode(testing.allocator, testing.io, &duplicate_history_source), + decode( + testing.allocator, + testing.io, + &duplicate_history_source, + test_decode_options, + ), ); } @@ -721,22 +1197,32 @@ test "complete snapshot leaves continuation bytes unread" { 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, test_encode_options); const snapshot_len = encoded.written().len; try encoded.writer.writeAll("pty"); var source: std.Io.Reader = .fixed(encoded.written()); - var restored = try decode(testing.allocator, testing.io, &source); + var restored = try decode( + testing.allocator, + testing.io, + &source, + test_decode_options, + ); defer restored.deinit(testing.allocator); - var continuation: [3]u8 = undefined; - try source.readSliceAll(&continuation); - try testing.expectEqualStrings("pty", &continuation); + var trailing: [3]u8 = undefined; + try source.readSliceAll(&trailing); + try testing.expectEqualStrings("pty", &trailing); var exact_source: std.Io.Reader = .fixed(encoded.written()); try testing.expectError( error.TrailingData, - decodeExact(testing.allocator, testing.io, &exact_source), + decodeExact( + testing.allocator, + testing.io, + &exact_source, + test_decode_options, + ), ); var bounded_source: std.Io.Reader = .fixed( @@ -746,6 +1232,7 @@ test "complete snapshot leaves continuation bytes unread" { testing.allocator, testing.io, &bounded_source, + test_decode_options, ); defer bounded.deinit(testing.allocator); } @@ -761,13 +1248,84 @@ test "complete snapshots decode sequentially from one reader" { 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); + try encode(testing.allocator, &encoded.writer, &t, test_encode_options); + try encode(testing.allocator, &encoded.writer, &t, test_encode_options); var source: std.Io.Reader = .fixed(encoded.written()); - var first = try decode(testing.allocator, testing.io, &source); + var first = try decode( + testing.allocator, + testing.io, + &source, + test_decode_options, + ); defer first.deinit(testing.allocator); - var second = try decode(testing.allocator, testing.io, &source); + var second = try decode( + testing.allocator, + testing.io, + &source, + test_decode_options, + ); defer second.deinit(testing.allocator); try testing.expectError(error.EndOfStream, source.takeByte()); } + +test "complete snapshot decode allocation failures are transactional" { + const testing = std.testing; + const S = struct { + fn exercise(bytes: []const u8) !void { + var baseline = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = std.math.maxInt(usize), + }); + var baseline_source: std.Io.Reader = .fixed(bytes); + var baseline_decoded = try decode( + baseline.allocator(), + testing.io, + &baseline_source, + test_decode_options, + ); + baseline_decoded.deinit(baseline.allocator()); + const allocation_count = baseline.alloc_index; + try testing.expect(allocation_count > 0); + + var saw_out_of_memory = false; + for (0..allocation_count) |fail_index| { + var failing = testing.FailingAllocator.init( + testing.allocator, + .{ .fail_index = fail_index }, + ); + var source: std.Io.Reader = .fixed(bytes); + var decoded = decode( + failing.allocator(), + testing.io, + &source, + test_decode_options, + ) catch |err| switch (err) { + error.OutOfMemory => { + saw_out_of_memory = true; + continue; + }, + else => return err, + }; + decoded.deinit(failing.allocator()); + } + try testing.expect(saw_out_of_memory); + } + }; + + // The complete golden exercises Terminal, both screens, and history. + try S.exercise(&test_complete_fixture); + + // A non-ground component additionally exercises owned continuation bytes. + 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, .{ + .continuation = .{ .bytes = "\x1b[31" }, + }); + try S.exercise(encoded.written()); +} diff --git a/src/terminal/snapshot/testdata/complete-v1.hex b/src/terminal/snapshot/testdata/complete-v1.hex index 7731a9c9d..83e3e174c 100644 --- a/src/terminal/snapshot/testdata/complete-v1.hex +++ b/src/terminal/snapshot/testdata/complete-v1.hex @@ -106,34 +106,37 @@ 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 # 0x0000053d 00 # 0x0000054d -# offset 0x0000054e: ready record, payload 32 bytes -05 00 20 00 00 00 43 0d 7b 3a 84 06 56 ba 02 59 # 0x0000054e -04 24 20 59 96 92 14 ba 76 53 80 40 09 12 85 a7 # 0x0000055e -f2 b5 b8 f9 63 98 e8 dc 9a 39 # 0x0000056e +# offset 0x0000054e: continuation record, payload 0 bytes +07 00 00 00 00 00 27 80 63 d1 # 0x0000054e -# 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 0x00000558: ready record, payload 32 bytes +05 00 20 00 00 00 d4 d1 fc d0 8d 69 76 c1 6e c6 # 0x00000558 +88 21 bd 10 97 41 97 35 63 2d ce 9e 3c b8 fa cf # 0x00000568 +57 04 f5 4b e0 70 66 5e 7e b6 # 0x00000578 -# offset 0x00000588: page record, payload 86 bytes -03 00 56 00 00 00 52 3b 6e 8d 02 00 02 00 00 00 # 0x00000588 -00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x00000598 -00 00 00 00 00 00 00 42 00 00 00 00 00 00 00 00 # 0x000005a8 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005b8 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005c8 -00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005d8 +# offset 0x00000582: history record, payload 6 bytes +04 00 06 00 00 00 20 32 ed e1 00 00 02 00 00 00 # 0x00000582 -# 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 0x00000592: page record, payload 86 bytes +03 00 56 00 00 00 52 3b 6e 8d 02 00 02 00 00 00 # 0x00000592 +00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x000005a2 +00 00 00 00 00 00 00 42 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 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000005e2 -# 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 0x000005f2: page record, payload 86 bytes +03 00 56 00 00 00 fb bc 15 06 02 00 02 00 00 00 # 0x000005f2 +00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 00 # 0x00000602 +00 00 00 00 00 00 00 41 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 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000642 -# 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 +# offset 0x00000652: history record, payload 6 bytes +04 00 06 00 00 00 b8 7a ba b1 01 00 00 00 00 00 # 0x00000652 + +# offset 0x00000662: finish record, payload 32 bytes +06 00 20 00 00 00 f3 a4 cf b6 f2 2b 17 88 1a 28 # 0x00000662 +a7 27 6e f7 de bc 7a 52 f9 a4 f9 c3 32 5b e0 09 # 0x00000672 +8a 1a 7d a0 9f 8b 32 31 67 41 # 0x00000682 diff --git a/src/terminal/snapshot/testdata/continuation-apc-v1.hex b/src/terminal/snapshot/testdata/continuation-apc-v1.hex new file mode 100644 index 000000000..4e3054bbe --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-apc-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Incomplete Kitty APC payload. +07 00 07 00 00 00 bd ff a8 ff 1b 5f 47 64 61 74 61 diff --git a/src/terminal/snapshot/testdata/continuation-csi-v1.hex b/src/terminal/snapshot/testdata/continuation-csi-v1.hex new file mode 100644 index 000000000..b83aceca2 --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-csi-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Incomplete SGR CSI. +07 00 04 00 00 00 30 d5 20 17 1b 5b 33 31 diff --git a/src/terminal/snapshot/testdata/continuation-dcs-v1.hex b/src/terminal/snapshot/testdata/continuation-dcs-v1.hex new file mode 100644 index 000000000..e8e3ebda2 --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-dcs-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Incomplete supported DCS payload. +07 00 07 00 00 00 63 ae 0a 1d 1b 50 71 64 61 74 61 diff --git a/src/terminal/snapshot/testdata/continuation-esc-v1.hex b/src/terminal/snapshot/testdata/continuation-esc-v1.hex new file mode 100644 index 000000000..0ca1d5b17 --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-esc-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Bare ESC leaves the VT parser unfinished. +07 00 01 00 00 00 1c d9 1d 17 1b diff --git a/src/terminal/snapshot/testdata/continuation-ground-v1.hex b/src/terminal/snapshot/testdata/continuation-ground-v1.hex new file mode 100644 index 000000000..42463c358 --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-ground-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Empty payload is the explicit ground-state assertion. +07 00 00 00 00 00 27 80 63 d1 diff --git a/src/terminal/snapshot/testdata/continuation-osc-v1.hex b/src/terminal/snapshot/testdata/continuation-osc-v1.hex new file mode 100644 index 000000000..10d6aef44 --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-osc-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Incomplete title OSC. +07 00 09 00 00 00 39 7d 6e 08 1b 5d 32 3b 74 69 74 6c 65 diff --git a/src/terminal/snapshot/testdata/continuation-utf8-v1.hex b/src/terminal/snapshot/testdata/continuation-utf8-v1.hex new file mode 100644 index 000000000..dc07c803e --- /dev/null +++ b/src/terminal/snapshot/testdata/continuation-utf8-v1.hex @@ -0,0 +1,8 @@ +# Ghostty snapshot fixture +# Kaitai type: continuation_record +# Kaitai params: +# Kaitai offset: 0 +# Wire version: 1 + +# Partial four-byte UTF-8 codepoint. +07 00 03 00 00 00 b8 07 2a 22 f0 9f 98 diff --git a/src/terminal/snapshot/verify-kaitai.py b/src/terminal/snapshot/verify-kaitai.py index fb3c750fe..a128efe75 100755 --- a/src/terminal/snapshot/verify-kaitai.py +++ b/src/terminal/snapshot/verify-kaitai.py @@ -159,7 +159,7 @@ def crc32c(data: bytes) -> int: def validate_record(record: Any, data: bytes, offset: int) -> int: """Validate one parsed record's source bytes and CRC32C.""" - payload = record._raw_payload + payload = getattr(record, "_raw_payload", record.payload) header = record.header if header.payload_length != len(payload): raise ValueError( @@ -194,6 +194,7 @@ def all_snapshot_records(snapshot: Any) -> list[Any]: for sequence in snapshot.screens: records.append(sequence.screen) records.extend(sequence.pages) + records.append(snapshot.continuation) records.append(snapshot.ready) for sequence in snapshot.histories: records.append(sequence.history) diff --git a/src/terminal/stream_continuation.zig b/src/terminal/stream_continuation.zig index 4512947de..bd6b8f0f5 100644 --- a/src/terminal/stream_continuation.zig +++ b/src/terminal/stream_continuation.zig @@ -4,6 +4,72 @@ const Allocator = std.mem.Allocator; const Parser = @import("Parser.zig"); const UTF8Decoder = @import("UTF8Decoder.zig"); +/// Errors possible while validating a snapshot continuation. +pub const ValidateError = error{ + /// Nonempty input left both the VT parser and UTF-8 decoder at ground. + /// + /// A continuation exists only to reconstruct state that was unfinished at + /// the snapshot cut. Input that returns to ground is a complete PTY + /// fragment, not a continuation. Replaying it would repeat work already + /// represented by the Terminal snapshot. For example, `ESC [ 3 1 m` + /// completes an SGR command and must not be stored as a continuation. + NoPendingState, + + /// The input does not begin at its effective replay start. + /// + /// A later ESC supersedes earlier VT parser state, and a pending UTF-8 + /// codepoint begins at its lead byte. Any prefix before that effective + /// start is unnecessary and would disappear when the restored Stream + /// exports its continuation again. Rejecting the prefix preserves the + /// byte-identical re-export invariant and prevents unrelated prior input + /// from being replayed. + NonCanonicalContinuation, + + /// Replaying the input would perform handler-visible work. + /// + /// The Terminal snapshot already contains every mutation committed before + /// its capture cut. A continuation may rebuild unfinished parser or + /// builder state, but it must not mutate the Terminal or repeat an external + /// effect while doing so. For example, BEL inside an unfinished CSI would + /// ring again even though the CSI itself remains pending. + ReplayWouldCommit, +}; + +/// Validate continuation bytes. +/// +/// Ensure that: +/// +/// - replay ends with either VT or UTF-8 state unfinished +/// - byte zero is the effective start needed to reconstruct that state +/// - replay commits no Terminal mutation or external handler effect +pub fn validate(bytes: []const u8) ValidateError!void { + // Empty explicitly requests ground state and needs no replay. + if (bytes.len == 0) return; + + // We need the final parser and decoder states to classify the input, so a + // committed byte cannot return early. Remember it while scanning the rest. + var scanner: BoundaryScanner = .init(); + var committed_work = false; + for (bytes) |byte| { + if (scanner.next(byte) != .uncommitted) committed_work = true; + } + + // Nonempty continuation bytes must leave state that future input needs. + if (scanner.ground()) return error.NoPendingState; + + // The tracker exports the minimal suffix beginning at the effective replay + // start. A different prefix would not survive byte-identical re-export. + const replay_start = if (scanner.parser.state != .ground) + findVTReplayStart(bytes) + else + findUtf8ReplayStart(bytes); + if (replay_start != 0) return error.NonCanonicalContinuation; + + // At this point the input is unfinished and minimal, but replay must also + // be inert with respect to the already-restored Terminal and its effects. + if (committed_work) return error.ReplayWouldCommit; +} + /// Retains the input needed to reconstruct unfinished Stream parser state. /// /// A feed is one chunk of bytes given to a Stream. It can end in the middle of