From d6494544cf9f6df963ae32377f3c7783545d9804 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:15:15 +0200 Subject: [PATCH 1/3] terminal: add panic test for integer overflow in selectPrev with no active matches --- src/terminal/search/screen.zig | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 3e7e316fa..03b56ddb8 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -1380,6 +1380,40 @@ test "select prev with history" { } } +test "select prev wraps when all matches are in history" { + // Regression test: when every match is in scrollback (the active area + // has none, so active_len == 0), selecting prev from index 0 must wrap + // to the last result without underflowing `active_len - 1`. + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var s = t.vtStream(); + defer s.deinit(); + + // Put the only match in scrollback, then scroll the active area to all + // blank lines so it contains no match (active_len == 0, history_len == 1). + s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(0, search.active_results.items.len); + + // Select the first match (idx 0), then wrap backwards. This must not + // panic and must keep a valid selection. + _ = try search.select(.next); + _ = try search.select(.prev); + try testing.expect(search.selectedMatch() != null); +} + test "screen search no scrollback has no history" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{ From bd365e1aa96133b7876221aa7b58dabb3c58e040 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Fri, 5 Jun 2026 23:20:49 +0200 Subject: [PATCH 2/3] terminal: cover selection drop when all matches disappear selectPrev's wrap (active_len + history_len - 1) would underflow if a selection were live while both result lists are empty. Add a test that exercises the invariant making that unreachable: overwriting the only match forces a reload that empties both lists and drops the selection, so the next select() hits the no-matches guard instead of the wrap arithmetic. --- src/terminal/search/screen.zig | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 03b56ddb8..02b5c3a0f 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -1414,6 +1414,38 @@ test "select prev wraps when all matches are in history" { try testing.expect(search.selectedMatch() != null); } +test "select after all matches disappear drops the selection" { + // The wrap arithmetic in selectPrev (active_len + history_len - 1) would + // underflow if a selection were ever live while both result lists are + // empty. This guards the invariant that makes that unreachable: when a + // reload/prune empties the results, the selection is dropped, so the next + // select() hits the "no matches" guard instead of the wrap arithmetic. + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + s.nextSlice("Fizz"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(1, search.active_results.items.len); + + // Take a selection, then overwrite the only match so a reload finds none + // (active and history both empty). + _ = try search.select(.next); + try testing.expect(search.selectedMatch() != null); + s.nextSlice("\x1b[1;1H "); + + // Must not underflow; the selection is dropped and nothing is selected. + _ = try search.select(.prev); + try testing.expect(search.selectedMatch() == null); + try testing.expectEqual(0, search.active_results.items.len); + try testing.expectEqual(0, search.history_results.items.len); +} + test "screen search no scrollback has no history" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{ From 1e63834cdc90365fd458b34b1b02413d42972995 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:30:58 +0200 Subject: [PATCH 3/3] fix(terminal): avoid integer overflow in selectPrev with no active matches --- src/terminal/search/screen.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 02b5c3a0f..93cdc27fb 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -798,7 +798,7 @@ pub const ScreenSearch = struct { const active_len = self.active_results.items.len; const history_len = self.history_results.items.len; - const next_idx = if (prev.idx != 0) prev.idx - 1 else active_len - 1 + history_len; + const next_idx = if (prev.idx != 0) prev.idx - 1 else active_len + history_len - 1; const hl: FlattenedHighlight = if (next_idx < active_len) self.active_results.items[active_len - 1 - next_idx]