terminal: fix printCell corrupting previous row when overwriting wide char

printCell, when overwriting a wide cell with a narrow cell at x<=1 and
y>0, unconditionally sets the last cell of the previous row to .narrow.
This is intended to clear a spacer_head left by a wrapped wide char, but
the cell could be a spacer_tail if a wide char fit entirely on the
previous row. Setting a spacer_tail to .narrow orphans the preceding
.wide cell, which later causes an integrity violation in insertBlanks
(assert that the cell after a .wide is .spacer_tail).

Fix by guarding the assignment so it only fires when the previous row's
last cell is actually a .spacer_head. The same fix is applied in both
the .wide and .spacer_tail branches of printCell.

Found by AFL++ stream fuzzer.
This commit is contained in:
Mitchell Hashimoto
2026-03-02 07:23:06 -08:00
parent 9d3c46c4bc
commit e7030e73db

View File

@@ -723,9 +723,14 @@ fn printCell(
self.screens.active.cursor.page_row,
spacer_cell[0..1],
);
// If we're near the left edge, a wide char may have
// wrapped from the previous row, leaving a spacer_head
// at the end of that row. Clear it so the previous row
// doesn't keep a stale spacer_head.
if (self.screens.active.cursor.y > 0 and self.screens.active.cursor.x <= 1) {
const head_cell = self.screens.active.cursorCellEndOfPrev();
head_cell.wide = .narrow;
if (head_cell.wide == .spacer_head) head_cell.wide = .narrow;
}
},
@@ -744,9 +749,13 @@ fn printCell(
self.screens.active.cursor.page_row,
wide_cell[0..1],
);
// If we're near the left edge, a wide char may have
// wrapped from the previous row, leaving a spacer_head
// at the end of that row. Clear it so the previous row
// doesn't keep a stale spacer_head.
if (self.screens.active.cursor.y > 0 and self.screens.active.cursor.x <= 1) {
const head_cell = self.screens.active.cursorCellEndOfPrev();
head_cell.wide = .narrow;
if (head_cell.wide == .spacer_head) head_cell.wide = .narrow;
}
},
@@ -3341,6 +3350,44 @@ test "Terminal: print over wide char at 0,0" {
try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } }));
}
test "Terminal: print over wide char at col 0 corrupts previous row" {
// Crash found by AFL++ fuzzer (afl-out/stream/default/crashes/id:000002).
//
// printCell, when overwriting a wide cell with a narrow cell at x<=1
// and y>0, sets the last cell of the previous row to .narrow — even
// when that cell is a .spacer_tail rather than a .spacer_head. This
// orphans the .wide cell at cols-2.
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 10, .rows = 3 });
defer t.deinit(alloc);
// Fill rows 0 and 1 with wide chars (5 per row on a 10-col terminal).
for (0..10) |_| try t.print(0x4E2D);
// Move cursor to row 1, col 0 (on top of a wide char) and print a
// narrow character. This triggers printCell's .wide branch which
// corrupts row 0's last cell: col 9 changes from .spacer_tail to
// .narrow, orphaning the .wide at col 8.
t.setCursorPos(2, 1);
try t.print('A');
// Row 1, col 0 should be narrow (we just overwrote the wide char).
{
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?;
try testing.expectEqual(Cell.Wide.narrow, list_cell.cell.wide);
}
// Row 0, col 8 should still be .wide (the last wide char on the row).
{
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 8, .y = 0 } }).?;
try testing.expectEqual(Cell.Wide.wide, list_cell.cell.wide);
}
// Row 0, col 9 must remain .spacer_tail to pair with the .wide at col 8.
{
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 9, .y = 0 } }).?;
try testing.expectEqual(Cell.Wide.spacer_tail, list_cell.cell.wide);
}
}
test "Terminal: print over wide spacer tail" {
var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 });
defer t.deinit(testing.allocator);