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.