libghostty: fix utf-8 grapheme length overflow (#13145)

The GRAPHEMES_UTF8 row-cells getter inferred its required byte
accumulator from utf8CodepointSequenceLength, which stores the value in
u3. Multi-scalar clusters longer than seven UTF-8 bytes could overflow
that accumulator before the capacity check, causing wrong probe sizes
and allowing optimized builds to write past a caller-provided buffer.

Use usize for the required byte count so probing and capacity checks
match the later encode loop. Extend the render C API test to cover the
short combining cluster, an eight-byte flag cluster, a longer family
emoji, exact-size success, and the cap == needed - 1 no-write boundary.
This commit is contained in:
Mitchell Hashimoto
2026-07-01 20:04:46 -07:00
committed by GitHub

View File

@@ -591,7 +591,7 @@ fn rowCellsGetGraphemesUtf8(
if (!cell.hasText()) return .success;
var needed = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch
var needed: usize = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch
return .invalid_value;
for (extra) |cp| {
needed += std.unicode.utf8CodepointSequenceLength(cp) catch
@@ -1293,6 +1293,82 @@ test "render: row cells get has_styling" {
}
test "render: row cells get graphemes utf8" {
const cases = [_]struct {
terminal_input: []const u8,
expected: []const u8,
}{
.{
.terminal_input = "e\u{301}",
.expected = "e\u{301}",
},
.{
.terminal_input = "\x1b[?2027h\u{1F1FA}\u{1F1F8}",
.expected = "\u{1F1FA}\u{1F1F8}",
},
.{
.terminal_input = "\x1b[?2027h\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}",
.expected = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}",
},
};
for (cases) |case| {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
.{ .cols = 10, .rows = 3, .max_scrollback = 10_000 },
));
defer terminal_c.free(terminal);
terminal_c.vt_write(terminal, case.terminal_input.ptr, case.terminal_input.len);
var state: RenderState = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&state,
));
defer free(state);
try testing.expectEqual(Result.success, update(state, terminal));
var it: RowIterator = null;
try testing.expectEqual(Result.success, row_iterator_new(
&lib.alloc.test_allocator,
&it,
));
defer row_iterator_free(it);
var cells: RowCells = null;
try testing.expectEqual(Result.success, row_cells_new(
&lib.alloc.test_allocator,
&cells,
));
defer row_cells_free(cells);
try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it)));
try testing.expect(row_iterator_next(it));
try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells)));
try testing.expectEqual(Result.success, row_cells_select(cells, 0));
var text: lib.Buffer = .{};
try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
try testing.expectEqual(case.expected.len, text.len);
var small = [_]u8{'x'} ** 32;
const small_cap = case.expected.len - 1;
text = .{ .ptr = small[0..small_cap].ptr, .cap = small_cap };
try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
try testing.expectEqual(case.expected.len, text.len);
try testing.expectEqualSlices(u8, &([_]u8{'x'} ** 32), &small);
var buf: [32]u8 = undefined;
text = .{ .ptr = &buf, .cap = case.expected.len };
try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
try testing.expectEqual(case.expected.len, text.len);
try testing.expectEqualStrings(case.expected, buf[0..text.len]);
}
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
@@ -1331,26 +1407,9 @@ test "render: row cells get graphemes utf8" {
try testing.expect(row_iterator_next(it));
try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells)));
try testing.expectEqual(Result.success, row_cells_select(cells, 0));
var text: lib.Buffer = .{};
try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
try testing.expectEqual(@as(usize, input.len), text.len);
var small = [_]u8{ 'x', 'x' };
text = .{ .ptr = &small, .cap = small.len };
try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
try testing.expectEqual(@as(usize, input.len), text.len);
try testing.expectEqualSlices(u8, &.{ 'x', 'x' }, &small);
var buf: [8]u8 = undefined;
text = .{ .ptr = &buf, .cap = buf.len };
try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
try testing.expectEqual(input.len, text.len);
try testing.expectEqualStrings(input, buf[0..text.len]);
try testing.expectEqual(Result.success, row_cells_select(cells, 1));
text = .{ .ptr = &buf, .cap = buf.len };
var buf: [8]u8 = undefined;
var text: lib.Buffer = .{ .ptr = &buf, .cap = buf.len };
try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
try testing.expectEqual(@as(usize, 0), text.len);
}