From 9157eb439a3dfe34579e5030fcd03a6bb32585c7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 1 Mar 2026 14:35:50 -0800 Subject: [PATCH] terminal: insertBlanks should not crash with count 0 and CSI @ clamps [1,) CSI @ (ICH) with an explicit parameter of 0 should be clamped to 1, matching xterm behavior. Previously, a zero count reached Terminal.insertBlanks which called clearCells with an empty slice, triggering an out-of-bounds panic. Fix the stream dispatch to clamp 0 to 1 via @max, and add a defensive guard in insertBlanks for count == 0. Found by AFL++ stream fuzzer. --- src/terminal/Terminal.zig | 25 +++++++++++++++++++++++++ src/terminal/stream.zig | 24 +++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index ae495f0f3..ecc501c6a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2166,6 +2166,12 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // xterm does. self.screens.active.cursor.pending_wrap = false; + // If we're given a zero then we do nothing. The rest of this function + // assumes count > 0 and will crash if zero so return early. Note that + // this shouldn't be possible with real CSI sequences because the value + // is clamped to 1 min. + if (count == 0) return; + // If our cursor is outside the margins then do nothing. We DO reset // wrap state still so this must remain below the above logic. if (self.screens.active.cursor.x < self.scrolling_region.left or @@ -9409,6 +9415,25 @@ test "Terminal: DECALN resets graphemes with protected mode" { } } +test "Terminal: insertBlanks zero" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + defer t.deinit(alloc); + + try t.print('A'); + try t.print('B'); + try t.print('C'); + t.setCursorPos(1, 1); + + t.insertBlanks(0); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC", str); + } +} + test "Terminal: insertBlanks" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index fe9af48ed..32619c958 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1894,7 +1894,7 @@ pub fn Stream(comptime Handler: type) type { '@' => switch (input.intermediates.len) { 0 => try self.handler.vt(.insert_blanks, switch (input.params.len) { 0 => 1, - 1 => input.params[0], + 1 => @max(1, input.params[0]), else => { @branchHint(.unlikely); log.warn("invalid ICH command: {f}", .{input}); @@ -2966,6 +2966,28 @@ test "stream: insert characters" { try testing.expect(!s.handler.called); } +test "stream: insert characters explicit zero clamps to 1" { + const H = struct { + const Self = @This(); + value: ?usize = null, + + pub fn vt( + self: *Self, + comptime action: anytype, + value: anytype, + ) !void { + switch (action) { + .insert_blanks => self.value = value, + else => {}, + } + } + }; + + var s: Stream(H) = .init(.{}); + for ("\x1B[0@") |c| try s.next(c); + try testing.expectEqual(@as(usize, 1), s.handler.value.?); +} + test "stream: SCOSC" { const H = struct { const Self = @This();