mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-05 15:18:40 +00:00
terminal/snapshot: pty continuation record (#13556)
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
320
src/terminal/snapshot/continuation.zig
Normal file
320
src/terminal/snapshot/continuation.zig
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
55
src/terminal/snapshot/testdata/complete-v1.hex
vendored
55
src/terminal/snapshot/testdata/complete-v1.hex
vendored
@@ -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
|
||||
|
||||
8
src/terminal/snapshot/testdata/continuation-apc-v1.hex
vendored
Normal file
8
src/terminal/snapshot/testdata/continuation-apc-v1.hex
vendored
Normal file
@@ -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
|
||||
8
src/terminal/snapshot/testdata/continuation-csi-v1.hex
vendored
Normal file
8
src/terminal/snapshot/testdata/continuation-csi-v1.hex
vendored
Normal file
@@ -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
|
||||
8
src/terminal/snapshot/testdata/continuation-dcs-v1.hex
vendored
Normal file
8
src/terminal/snapshot/testdata/continuation-dcs-v1.hex
vendored
Normal file
@@ -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
|
||||
8
src/terminal/snapshot/testdata/continuation-esc-v1.hex
vendored
Normal file
8
src/terminal/snapshot/testdata/continuation-esc-v1.hex
vendored
Normal file
@@ -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
|
||||
8
src/terminal/snapshot/testdata/continuation-ground-v1.hex
vendored
Normal file
8
src/terminal/snapshot/testdata/continuation-ground-v1.hex
vendored
Normal file
@@ -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
|
||||
8
src/terminal/snapshot/testdata/continuation-osc-v1.hex
vendored
Normal file
8
src/terminal/snapshot/testdata/continuation-osc-v1.hex
vendored
Normal file
@@ -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
|
||||
8
src/terminal/snapshot/testdata/continuation-utf8-v1.hex
vendored
Normal file
8
src/terminal/snapshot/testdata/continuation-utf8-v1.hex
vendored
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user