terminal/snapshot: preserve style reader errors

Lenient style decoding previously caught every error, so PAGE and SCREEN 
could treat truncation or an I/O failure as an invalid semantic style and 
continue from a corrupted stream position.

Add a nullable decoder that discards only invalid style contents while 
propagating reader failures. Update snapshot callers and cover both semantic 
fallback and structural failure behavior.
This commit is contained in:
Mitchell Hashimoto
2026-08-03 07:37:08 -07:00
parent b5290e74c4
commit f99896bf8c
3 changed files with 111 additions and 3 deletions

View File

@@ -370,7 +370,7 @@ fn decodePayloadBody(
// Styles
for (0..header.style_count) |_| {
const native_id = try io.readInt(reader, TerminalStyleId);
const value: ?TerminalStyle = style.decodeOrDiscard(reader) catch null;
const value = try style.decodeOrNull(reader);
// Zero is reserved for the implicit default. For a duplicate encoded
// ID, the first entry wins and this complete entry is simply ignored.
@@ -1309,6 +1309,82 @@ test "decode validates dimensions" {
}
}
test "decode propagates a style table read failure" {
const FailOnceReader = struct {
source: std.Io.Reader,
interface: std.Io.Reader,
bytes_before_failure: usize,
failed: bool = false,
fn init(bytes: []const u8, bytes_before_failure: usize) @This() {
return .{
.source = .fixed(bytes),
.interface = .{
.vtable = &.{ .stream = stream },
.buffer = &.{},
.seek = 0,
.end = 0,
},
.bytes_before_failure = bytes_before_failure,
};
}
fn stream(
reader: *std.Io.Reader,
writer: *std.Io.Writer,
limit: std.Io.Limit,
) std.Io.Reader.StreamError!usize {
const self: *@This() = @fieldParentPtr("interface", reader);
if (self.bytes_before_failure == 0 and !self.failed) {
self.failed = true;
return error.ReadFailed;
}
const read_limit = if (self.failed)
limit
else
limit.min(.limited(self.bytes_before_failure));
const n = try self.source.stream(writer, read_limit);
if (!self.failed) self.bytes_before_failure -= n;
return n;
}
};
const header: Header = .{
.columns = 1,
.rows = 1,
.style_count = 1,
.hyperlink_count = 0,
.style_capacity = 8,
.hyperlink_capacity_bytes = 0,
.grapheme_capacity_bytes = 0,
.string_capacity_bytes = 0,
};
// If the style read error is swallowed, its sixteen zero bytes are then
// misread as a valid empty grid and the unframed payload appears to decode.
var encoded: [Header.len + @sizeOf(TerminalStyleId) + style.len]u8 =
@splat(0);
var writer: std.Io.Writer = .fixed(&encoded);
try header.encode(&writer);
try io.writeInt(&writer, TerminalStyleId, 1);
try writer.splatByteAll(0, style.len);
var source = FailOnceReader.init(
writer.buffered(),
Header.len + @sizeOf(TerminalStyleId),
);
var decoded = decodePayload(
&source.interface,
std.testing.allocator,
) catch |err| {
try std.testing.expectEqual(error.ReadFailed, err);
return;
};
defer decoded.deinit();
try std.testing.expect(false);
}
test "decode normalizes duplicate default and invalid style entries" {
const header: Header = .{
.columns = 1,

View File

@@ -847,7 +847,7 @@ pub const SavedCursor = struct {
return .{
.x = try io.readInt(reader, u16),
.y = try io.readInt(reader, u16),
.pen = style.decodeOrDiscard(reader) catch .{},
.pen = (try style.decodeOrNull(reader)) orelse .{},
.flags = try Flags.decode(reader),
.charset = decodeCharsetState(
try io.readInt(reader, u16),
@@ -1008,7 +1008,8 @@ pub const Header = struct {
try reader.takeByte(),
) orelse .block;
const cursor_flags = try CursorFlags.decode(reader);
const cursor_pen: TerminalStyle = style.decodeOrDiscard(reader) catch .{};
const cursor_pen: TerminalStyle =
(try style.decodeOrNull(reader)) orelse .{};
const hyperlink_implicit_id = try io.readInt(reader, u32);
// Charset and selective-erase state.

View File

@@ -172,6 +172,27 @@ pub fn decodeOrDiscard(
return decode(&source);
}
/// Decode one complete entry, returning null for invalid semantic contents.
///
/// Reader errors remain structural and are always propagated. This is the
/// lenient entry point for enclosing codecs which can safely replace an
/// invalid fixed-size style without treating truncation as invalid styling.
pub fn decodeOrNull(
reader: *std.Io.Reader,
) std.Io.Reader.Error!?terminal_style.Style {
return decodeOrDiscard(reader) catch |err| switch (err) {
error.ReadFailed => return error.ReadFailed,
error.EndOfStream => return error.EndOfStream,
error.InvalidColorKind,
error.InvalidColor,
error.InvalidUnderline,
error.InvalidFlags,
error.InvalidReserved,
=> null,
};
}
fn encodeColor(
value: terminal_style.Style.Color,
writer: *std.Io.Writer,
@@ -383,6 +404,16 @@ test "decodeOrDiscard preserves the next entry boundary" {
try std.testing.expectEqual(@as(u8, 0xFF), try reader.takeByte());
}
test "decodeOrNull distinguishes semantic errors from truncation" {
var invalid: [len]u8 = @splat(0);
invalid[0] = 3;
var invalid_reader: std.Io.Reader = .fixed(&invalid);
try std.testing.expectEqual(null, try decodeOrNull(&invalid_reader));
var truncated: std.Io.Reader = .fixed(invalid[0 .. len - 1]);
try std.testing.expectError(error.EndOfStream, decodeOrNull(&truncated));
}
test "reject every truncation" {
const fixture = [_]u8{0} ** len;
for (0..len) |fixture_len| {