From 5bc6588e43e280b32049a18835414688a46bdb26 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 19:54:29 -0700 Subject: [PATCH 1/3] terminal/search: ignore empty search needles Low-level search state accepted an empty needle even though the search thread normally filters it out. SlidingWindow treated the empty string as a zero-length match and underflowed while calculating its inclusive end offset. Active and viewport overlap calculations could also underflow while loading adjacent pages. Treat an empty needle as an inactive search with no matches or history, and saturate the viewport overlap length. --- src/terminal/search/active.zig | 4 ++++ src/terminal/search/sliding_window.zig | 24 ++++++++++++++++++++++++ src/terminal/search/viewport.zig | 5 +++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig index aee21b3e3..b99b23579 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -56,6 +56,10 @@ pub const ActiveSearch = struct { // Clear our previous sliding window self.window.clearAndRetainCapacity(); + // An empty needle represents an inactive search and has no overlap + // or history to load. + if (self.window.needle.len == 0) return null; + // First up, add enough pages to cover the active area. var rem: usize = list.rows; var node_ = list.pages.last; diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index ea039bf3a..5c29ce03b 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -176,6 +176,11 @@ pub const SlidingWindow = struct { /// or `append()`. If the caller wants to retain the flattened highlight /// then they should clone it. pub fn next(self: *SlidingWindow) ?FlattenedHighlight { + // An empty needle represents an inactive search. Searching for it + // would otherwise produce a zero-length match, which highlight() + // cannot represent because its end offset is inclusive. + if (self.needle.len == 0) return null; + const slices = slices: { // If we have less data then the needle then we can't possibly match const data_len = self.data.len(); @@ -704,6 +709,25 @@ test "SlidingWindow empty on init" { try testing.expectEqual(0, w.meta.len()); } +test "SlidingWindow empty needle has no matches" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, ""); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }); + defer s.deinit(); + try s.testWriteString("hello"); + + _ = try w.append(s.pages.pages.first.?); + try testing.expectEqual(null, w.next()); +} + test "SlidingWindow single append" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index a1458bbed..d8dbb9429 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -134,6 +134,7 @@ pub const ViewportSearch = struct { // Add enough overlap to cover needle.len - 1 bytes (if it // exists) so we can cover the overlap. We only do this for the // soft-wrapped prior pages. + const overlap_len = self.window.needle.len -| 1; var node_ = fingerprint.nodes[0].prev; var added: usize = 0; while (node_) |node| : (node_ = node.prev) { @@ -142,7 +143,7 @@ pub const ViewportSearch = struct { // wrap so this should be fine. const appended = try self.window.appendIfWrapped(node) orelse break; added += appended.content_len; - if (added >= self.window.needle.len - 1) break; + if (added >= overlap_len) break; } // We can use our fingerprint nodes to initialize our sliding @@ -162,7 +163,7 @@ pub const ViewportSearch = struct { while (node_) |node| : (node_ = node.next) { const appended = try self.window.append(node); added += appended.content_len; - if (added >= self.window.needle.len - 1) break; + if (added >= overlap_len) break; // If this row doesn't wrap, then we can quit if (!appended.last_row_wrapped) break; From 0ff4e41b22b4da6e5603dd69570eabf4083c8ede Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 19:54:57 -0700 Subject: [PATCH 2/3] terminal: fix pin wrapping at row boundaries Pin.leftWrap and rightWrap calculated the destination using the remainder after consuming the current row. When that remainder was an exact multiple of the column count, rightWrap subtracted one from zero and leftWrap produced a column equal to the width. Dereferencing either pin could panic. A maximum usize offset on a one-column page also overflowed the row count. Base the row and column calculations on the remainder minus one. This maps exact multiples to the final cell of the correct row and keeps the maximum offset calculation in range so traversal reports overflow normally. --- src/terminal/PageList.zig | 64 +++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 167654d61..04b58b99b 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -6251,13 +6251,14 @@ pub const Pin = struct { if (n <= remaining_in_row) return self.left(n); const extra_after_remaining = n - remaining_in_row; - - const rows_off = 1 + extra_after_remaining / cols; + const rows_off = 1 + (extra_after_remaining - 1) / cols; switch (self.upOverflow(rows_off)) { .offset => |v| { var result = v; - result.x = @intCast(cols - extra_after_remaining % cols); + result.x = @intCast( + cols - 1 - (extra_after_remaining - 1) % cols, + ); return result; }, .overflow => return null, @@ -6278,13 +6279,12 @@ pub const Pin = struct { if (n <= remaining_in_row) return self.right(n); const extra_after_remaining = n - remaining_in_row; - - const rows_off = 1 + extra_after_remaining / cols; + const rows_off = 1 + (extra_after_remaining - 1) / cols; switch (self.downOverflow(rows_off)) { .offset => |v| { var result = v; - result.x = @intCast(extra_after_remaining % cols - 1); + result.x = @intCast((extra_after_remaining - 1) % cols); return result; }, .overflow => return null, @@ -6451,6 +6451,58 @@ fn growColdPagesForTest(self: *PageList, count: usize) !void { } } +test "PageList Pin rightWrap exact row multiple" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .x = 5, .y = 0 } }).?; + const wrapped = start.rightWrap(14).?; + _ = wrapped.rowAndCell(); + + try testing.expectEqual( + point.Point{ .active = .{ .x = 9, .y = 1 } }, + s.pointFromPin(.active, wrapped), + ); +} + +test "PageList Pin leftWrap exact row multiple" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .x = 5, .y = 2 } }).?; + const wrapped = start.leftWrap(15).?; + _ = wrapped.rowAndCell(); + + try testing.expectEqual( + point.Point{ .active = .{ .x = 0, .y = 1 } }, + s.pointFromPin(.active, wrapped), + ); +} + +test "PageList Pin rightWrap maximum distance" { + const testing = std.testing; + + var s = try init(testing.allocator, 1, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .y = 0 } }).?; + try testing.expectEqual(null, start.rightWrap(std.math.maxInt(usize))); +} + +test "PageList Pin leftWrap maximum distance" { + const testing = std.testing; + + var s = try init(testing.allocator, 1, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .y = 2 } }).?; + try testing.expectEqual(null, start.leftWrap(std.math.maxInt(usize))); +} + test "PageList incremental compression skips visible history" { const testing = std.testing; From 0aaedf4360ff3db1d724e148acc6f1c69d196c76 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 19:55:13 -0700 Subject: [PATCH 3/3] terminal: saturate origin cursor offsets setCursorPos added origin-mode margins to requested row and column values before clamping them to the scrolling region. A request near maxInt(usize) overflowed during that addition and crashed instead of landing on the region boundary. Use saturating addition for the origin offsets. The existing clamp then places oversized requests on the bottom-right margin without changing normal cursor positioning. --- src/terminal/Terminal.zig | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index b9363684d..3bcfdf5d9 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2120,8 +2120,8 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { // Calculate our new x/y const row = if (row_req == 0) 1 else row_req; const col = if (col_req == 0) 1 else col_req; - const x = @min(params.x_max, col + params.x_offset) -| 1; - const y = @min(params.y_max, row + params.y_offset) -| 1; + const x = @min(params.x_max, col +| params.x_offset) -| 1; + const y = @min(params.y_max, row +| params.y_offset) -| 1; // If the y is unchanged then this is fast pointer math if (y == self.screens.active.cursor.y) { @@ -3816,6 +3816,24 @@ fn clearDirty(t: *Terminal) void { t.screens.active.pages.clearDirty(); } +test "Terminal: setCursorPos saturates overflowing origin offsets" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + t.scrolling_region = .{ + .top = 2, + .bottom = 7, + .left = 3, + .right = 8, + }; + t.modes.set(.origin, true); + + t.setCursorPos(std.math.maxInt(usize), std.math.maxInt(usize)); + try testing.expectEqual(@as(size.CellCountInt, 8), t.screens.active.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 7), t.screens.active.cursor.y); +} + test "Terminal: input with no control characters" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 40, .rows = 40 });