Reject positional cursors that overflow the position arithmetic - #10
Merged
Merged
Conversation
A positional cursor is client-supplied: a resolver hands back whatever `after` it is given. `PositionalFetcher` validated the shape but not the magnitude, so an over-large cursor overflowed the row arithmetic to float and surfaced from `OffsetPage::hasMoreAfter()` as an uncaught TypeError instead of the MalformedPageException `fetchPage()` documents. Reachable at 18 digits for PageNumberFetcher, which multiplies by the page size, and at 19 for OffsetFetcher, where the `(int)` cast saturates at PHP_INT_MAX so every larger cursor silently addressed the same position. Both now throw before the upstream is called. Also anchor the cursor pattern with \A and \z: `$` matches before a trailing newline, so "5\n" parsed as page 5. Alongside the fix, from the same review pass: - Pin the cross-unit approximation in the offset direction. `totalPages` from an OffsetFetcher over short windows lets the derived ordinal lag the rows read; at nine rows that costs one. The page-number mirror was already pinned, this direction was not. - Correct hasNext()'s docblock, which claimed the opposite of what a budget-exhausted walk does. - Drop the "exactly" from ConnectionFormatter's hasPreviousPage docblock: a bare positional '0' decodes to a non-null anchor and reports true. Behaviour unchanged - recognising one fetcher's wire format in the Relay layer would leak it across the boundary. - Remove v0.1.0 migration history from Walk's docblock and de-duplicate the page-size warning that had drifted into three files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzT1u3EN6X7hoHiage9HxK
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A positional cursor is client-supplied — a resolver hands back whatever
afterit is given.PositionalFetchervalidated the cursor's shape but not its magnitude, so an over-large oneoverflowed the position arithmetic to
float. Understrict_typesthat float reachedOffsetPage::hasMoreAfter()'sintparameters as an uncaughtTypeError— not theMalformedPageExceptionthatfetchPage()'s contract documents, and not aCursorWalkExceptionat all, so a caller catching the library's own base class never saw it.
Both fetchers were affected, with different triggers, and the upstream was already called with
the absurd position before the throw:
"999999999999999999"(18 digits)PageNumberFetcherTypeError—($n - 1) * $pageSize→float(5.0E+19)"9999999999999999999"(19 digits)OffsetFetcherTypeError—(int)cast saturates toPHP_INT_MAX, then+ count()overflows"5\n"The saturation case is the quieter half:
(int)clamps rather than wraps, so"9999999999999999999"andstr_repeat('9', 40)both becamePHP_INT_MAXand addressed thesame position.
The trailing-newline hole is unrelated in cause but shares a fix site:
/^\d+$/'s$alsomatches immediately before a trailing newline.
Change
The fix — one range guard in the shared
positionFrom(), so both subclasses inherit it, andthe pattern anchored with
\A/\z:PageNumberFetcheroverflows an order of magnitude sooner thanOffsetFetcherbecause itmultiplies, so the bound is derived from the page size rather than hardcoded. The
- 1gives onepage of headroom for the
+ count($items)term, including an upstream that over-delivers. Thebound doubles as the saturation guard: a saturated cursor lands on
PHP_INT_MAX, which alwaysexceeds
intdiv(PHP_INT_MAX, $pageSize) - 1, so it is rejected rather than silently aliased.Rejected alternative: a
(string) (int) $cursor === $cursorround-trip closes saturation and thenewline in one line, but it also starts rejecting
"007". The library never emits a zero-paddedcursor, yet a hand-written resume cursor could carry one, so the explicit bound is the more
conservative trade.
Also in this pass (same review, all verified against running code):
totalPagesfrom anOffsetFetcherover short windows lets the ordinal derived byintdiv($offset, $pageSize) + 1lag the rows actually read. The page-number mirror was already pinned; this direction was not.
Worth noting the fixture had to be chosen deliberately: at eight rows the lag happens to
land on the final window and nothing is lost, so the test uses nine, where it costs a row.
Walk::hasNext()claimed "whether another page may be fetched", which is theopposite of what a budget-exhausted walk does: it still returns
trueandnextCursor()throws. Deliberate (a spent budget must be loud, never indistinguishable from end-of-stream),
but undocumented, as was the asymmetry with the loop guard, which does set
finished.ConnectionFormatter'shasPreviousPage. It reads thecursor as
CursorCodecdoes, so a bare positional'0'—OffsetFetcher's origin — decodesto a non-null anchor and reports
true. Behaviour unchanged: teaching the Relay layer torecognise one fetcher's wire format would leak that format across the boundary, so the
docblock now points callers at passing
nullfor the first page, as the recipes already do.Walk's docblock (it argued the change toa reviewer rather than telling a reader an invariant; the CHANGELOG carries it), and collapsed
the page-size/short-page warning that had drifted into three near-identical copies down to one
canonical statement on
OffsetPage::hasMoreAfter().Verification
vendor/bin/phpunitcomposer stan(PHPStan level max,src+tests+tools)composer cs(php-cs-fixer, PSR-12)composer check→examples/offline-example.phpAll checks passed.MalformedPageException; upstream no longer called at allphp -ron the raw arithmetic(999999999999999999 - 1) * 50→float(5.0E+19)confirmedpreg_matchon"5\n"1with/^\d+$/,0with/\A\d+\z/confirmedmaxPosition()New tests: 4 in
OffsetFetcherTest(2 providers + the accept-boundary + the cross-unit pin),2 in
PageNumberFetcherTest(provider + accept-boundary), covering ZOMBIES Boundaries andExceptions for the new guard.
Notes
and the 100% line-coverage gate are checked only by CI. I expect the new logic mutants to be
killed (
>→>=and- 1→+ 1both break the paired boundary tests) with the newexception-message concatenation escaping as an equivalent mutant, matching the category already
documented in
infection.json5. That is a prediction, not a measurement — CI decides.README.mdis deliberately untouched. The affected limit (~1.8 × 10¹⁷ rows) is unreachable forany real dataset, and this repo keeps its contracts in docblocks and the CHANGELOG while the
README stays a recipe guide.
/code-reviewpass over the already-merged Add offset fetchers and a Walk stepper, and report hasPreviousPage truthfully #4. The other findings from that passare all in here; nothing was deferred.
Risk: 🟡 medium — narrows accepted input on a public entry point. Any caller currently sending a
cursor above
intdiv(PHP_INT_MAX, $pageSize) - 1, or one with a trailing newline, moves from aTypeError(or silent mis-parse) to aMalformedPageException. Both were broken before; nolegitimate cursor is affected.
🤖 Generated with Claude Code