diff --git a/src/terminal/snapshot/page.zig b/src/terminal/snapshot/page.zig index 3e1f618eb..97a9c932a 100644 --- a/src/terminal/snapshot/page.zig +++ b/src/terminal/snapshot/page.zig @@ -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, diff --git a/src/terminal/snapshot/screen.zig b/src/terminal/snapshot/screen.zig index 72c79a999..20bc24a8e 100644 --- a/src/terminal/snapshot/screen.zig +++ b/src/terminal/snapshot/screen.zig @@ -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. diff --git a/src/terminal/snapshot/style.zig b/src/terminal/snapshot/style.zig index 6df8ba03c..176190b75 100644 --- a/src/terminal/snapshot/style.zig +++ b/src/terminal/snapshot/style.zig @@ -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| {