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()); +}