terminal/snapshot: harden grapheme suffix decode

Grapheme suffix decoding accepted U+0000 even though zero is the native
empty-cell sentinel. It also appended codepoints one at a time and, when
page capacity failed after a prefix had been stored, left that truncated
prefix attached to the cell. Hostile snapshots could therefore introduce
invalid cluster data or render a partial cluster depending on allocator
capacity.

Ignore NUL alongside invalid scalar values. If any append runs out of
native capacity, remove the prefix already attached and consume the rest
of the declared suffix without applying it, making delivery atomic at the
cluster level. Cover NUL input and a failure after 128 accepted suffix
codepoints.
This commit is contained in:
Mitchell Hashimoto
2026-08-02 20:53:36 -07:00
parent 9a5279db68
commit 418b5d1805

View File

@@ -1067,20 +1067,25 @@ fn decodeGraphemes(
break :target .{ .row = row, .cell = cell };
};
// Always consume every declared codepoint. Invalid scalars and
// suffixes that exceed the native capacity are dropped
// independently without affecting the rest of the grid.
// Always consume every declared codepoint. Invalid scalars and NUL are
// not meaningful grapheme suffix components and are ignored. If native
// capacity is exhausted, remove any prefix already attached so the
// cell never exposes a truncated cluster.
var accept = target != null;
for (0..cp_count) |_| {
const cp = try io.readInt(reader, u32);
if (!accept) continue;
if (!validScalar(cp)) continue;
if (cp == 0 or !validScalar(cp)) continue;
page.appendGrapheme(
target.?.row,
target.?.cell,
@intCast(cp),
) catch {
if (target.?.cell.hasGrapheme()) {
page.clearGrapheme(target.?.cell);
page.updateRowGraphemeFlag(target.?.row);
}
accept = false;
};
}
@@ -1752,8 +1757,9 @@ test "grid drops undeliverable grapheme entries" {
// Valid entry with one invalid scalar dropped from within it.
try io.writeInt(&writer, u16, 0);
try io.writeInt(&writer, u16, 0);
try io.writeInt(&writer, u16, 3);
try io.writeInt(&writer, u16, 4);
try io.writeInt(&writer, u32, 0x0301);
try io.writeInt(&writer, u32, 0);
try io.writeInt(&writer, u32, 0xD800);
try io.writeInt(&writer, u32, 0x0302);
// Duplicate entry for the same cell is consumed and dropped.
@@ -1794,6 +1800,48 @@ test "grid drops undeliverable grapheme entries" {
try testing.expect(!page.getRowAndCell(3, 0).cell.hasGrapheme());
}
test "grid drops a complete grapheme when capacity fails mid-cluster" {
const testing = std.testing;
var page = try TerminalPage.init(.{
.cols = 1,
.rows = 1,
.grapheme_bytes = 16,
});
defer page.deinit();
var style_remap = try StyleRemap.init(testing.allocator);
defer style_remap.deinit(testing.allocator);
var hyperlink_remap = try HyperlinkRemap.init(testing.allocator);
defer hyperlink_remap.deinit(testing.allocator);
var payload: [1024]u8 = undefined;
var writer: std.Io.Writer = .fixed(&payload);
try writer.writeByte(@bitCast(Row{ .cell_width = .eight }));
try io.writeInt(&writer, u16, 1);
try io.writeInt(&writer, u64, @bitCast(Cell{ .kind = 1, .content = 'x' }));
try io.writeInt(&writer, u32, 1);
try io.writeInt(&writer, u16, 0);
try io.writeInt(&writer, u16, 0);
// BitmapAllocator rounds this capacity to 64 four-codepoint chunks. The
// 129th suffix needs 33 new chunks while the old 32-chunk slice is still
// live, forcing the append's atomic replacement allocation to fail.
try io.writeInt(&writer, u16, 129);
for (0..129) |i| try io.writeInt(
&writer,
u32,
@intCast(0x0300 + i),
);
var reader: std.Io.Reader = .fixed(writer.buffered());
try decode(&page, &reader, &style_remap, &hyperlink_remap);
try page.verifyIntegrity(testing.allocator);
const cell = page.getRowAndCell(0, 0);
try testing.expectEqual(@as(u21, 'x'), cell.cell.codepoint());
try testing.expect(!cell.cell.hasGrapheme());
try testing.expect(!cell.row.grapheme);
try testing.expectEqual(@as(usize, 0), page.graphemeCount());
}
test "grid encodes rows at their narrowest width" {
const testing = std.testing;
var page = try TerminalPage.init(.{