Skip to content

fix: paginate bugs_quicksearch over the bugs a client may see - #18

Merged
plusky merged 1 commit into
mainfrom
fix/search-pagination-window
Jul 27, 2026
Merged

fix: paginate bugs_quicksearch over the bugs a client may see#18
plusky merged 1 commit into
mainfrom
fix/search-pagination-window

Conversation

@plusky

@plusky plusky commented Jul 27, 2026

Copy link
Copy Markdown
Owner

The oracle

Bugzilla applied limit/offset, then the guard filtered what came back. So
a hidden bug left a hole exactly where it sat: the page returned short
while the next offset still had results.

That gap says "a bug exists here that you may not see". And because
quicksearch matches summary text, it is a probe, not a hint:

bugs_quicksearch(query="product:Foo",             limit=1, offset=0) -> []        <- hole
bugs_quicksearch(query="product:Foo",             limit=1, offset=1) -> [{id:41}]
bugs_quicksearch(query="product:Foo summary:heap",   limit=1, offset=0) -> []     <- hole survives => "heap" is in the hidden title
bugs_quicksearch(query="product:Foo summary:heapzz", limit=1, offset=0) -> [...]  <- hole gone   => it is not

Iterate a dictionary and the hidden bug's title comes out one word at a time.
Uniform denials (I2) and silent filtering (I3) are worth nothing while the
window itself reports what was removed — which makes this the finding that
actually defeats the embargo rules the policy exists to enforce.

The fix

Pagination is applied after the guard. Guard::quicksearch_window scans
the upstream result from row 0 in 200-row chunks, classifies each chunk, and
continues until enough visible bugs have accumulated to fill the requested
window. A hidden bug now costs a row of scanning and nothing else — it cannot
shorten a page or contradict a later offset.

Scanning from row 0 every time is not laziness: a visible-space offset cannot
be mapped onto an upstream offset without classifying everything before it,
and how many bugs that is, is exactly what must not leak.

Bounds and what they cost

bound value effect when hit
addressable window (offset+limit) 1000 page truncates
upstream rows scanned 2000 (≤10 sequential requests) page truncates

Truncation is indistinguishable from reaching the end of the results, so the
failure direction hides more rather than less. I chose these over the 10,000
rows / 50 requests the design proposed, because this deployment already showed
it drops connections under load — a search costing 50 upstream round trips is
not a good trade for pagination depth almost nobody uses.

Rows are deduped on the server-reported id (relevance ordering proved unstable
between calls against the live instance), and a row without a readable id is
dropped rather than guessed at (I4).

Residual, stated plainly

The number of upstream requests grows with how many bugs were hidden in
the scanned prefix, so a client with a stopwatch can infer that a query's
results contain hidden bugs somewhere. That is a long way from
reconstructing a title, and the count steps only every 200 rows. Removing it
entirely would mean a fixed-size scan on every search, charging every query
for the worst case.

Verification

147 tests (+7). I checked the tests actually detect the old behaviour by
reintroducing it: three fail immediately. Two others did not discriminate
— one placed its hidden bugs past the window where they could not matter — so
I sharpened them; that one now interleaves hidden bugs before and inside the
window and asserts the page is still full.

Pinned: pages stay full while visible bugs remain, consecutive pages tile the
visible sequence disjointly and gaplessly, a hidden bug never shortens a page,
exhaustion and scan-truncation look alike, a zero limit touches upstream not at
all, and the objects returned are the ones that were classified.

Search failures now return a bare Search failed; the upstream text is logged
server-side only, since it can name a bug and say whether it exists.

Bugzilla applied limit/offset and the guard filtered what came back, so a
hidden bug left a hole exactly where it sat: the page returned short while
the next offset still had results. That gap says "a bug exists here that
you may not see" — and because quicksearch matches summary text, it is a
probe rather than a hint. Append a word to the query, see whether the hole
survives, and the hidden bug's title comes out one word at a time. Uniform
denials (I2) and silent filtering (I3) are worth nothing while the window
itself reports what was removed.

Apply pagination after the guard instead. Guard::quicksearch_window scans
the upstream result from row 0 in chunks, classifies each chunk, and keeps
going until enough visible bugs have accumulated to fill the window the
client asked for. A hidden bug now costs a row of scanning and nothing
else: it cannot shorten a page or contradict a later offset.

Scanning from row 0 every time is not laziness. A visible-space offset
cannot be mapped onto an upstream offset without classifying everything
before it, and how many bugs that is, is exactly what must not leak.

The scan target is rounded up to whole chunks rather than stopping the
instant the window fills. Stopping exactly would make the request count a
function of both the client's limit and the hidden-bug density, and those
can be separated: binary-search limit against the clock and each block's
exact hidden count falls out. Quantised, every limit within a block buys
the same work, so the clock reports at most "this block was not entirely
visible". The common case is unaffected — an unfiltered chunk already
fills the target in one request.

Bounded so a hostile query cannot spin: 1000 addressable, 2000 upstream
rows scanned, so at most ten sequential requests. Hitting either bound
truncates the page, which is indistinguishable from reaching the end of
the results — it hides more, never less.

The slice start is clamped to the slice end, not merely to the length. The
window stops at 1000 but the scan overshoots it whenever a chunk yields
fewer visible bugs than it holds, which happens only when bugs WERE
hidden — so an unclamped start panicked precisely when something had been
hidden past the window, answering the question this function exists to
refuse and taking the session with it.

Rows are deduped on the id the server reported, since relevance ordering
is not stable between calls, and a row without a readable id is dropped
rather than guessed at (I4). Dedup handles a row that moves forward
between chunks; one that moves backwards past an already scanned offset is
missed entirely and appears on no page. That hides more rather than less,
and is noted where it could otherwise read as a guarantee.

Search failures now return a bare "Search failed"; the upstream text goes
to the log, since it can name a bug and say whether it exists.
@plusky
plusky force-pushed the fix/search-pagination-window branch from 8ffc346 to 5a75017 Compare July 27, 2026 19:45
@plusky
plusky merged commit 3308555 into main Jul 27, 2026
10 checks passed
@plusky
plusky deleted the fix/search-pagination-window branch July 27, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant