Skip to content

Reject positional cursors that overflow the position arithmetic - #10

Merged
ryckakas merged 1 commit into
mainfrom
fix/positional-cursor-range-guard
Aug 5, 2026
Merged

ryckakas merged 1 commit into
mainfrom
fix/positional-cursor-range-guard

Conversation

@ryckakas

@ryckakas ryckakas commented Aug 5, 2026 •

Copy link
Copy Markdown
Owner

Problem

A positional cursor is client-supplied — a resolver hands back whatever after it is given.
PositionalFetcher validated the cursor's shape but not its magnitude, so an over-large one
overflowed the position arithmetic to float. Under strict_types that float reached
OffsetPage::hasMoreAfter()'s int parameters as an uncaught TypeError — not the
MalformedPageException that fetchPage()'s contract documents, and not a CursorWalkException
at 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:

Input Fetcher Before
"999999999999999999" (18 digits) PageNumberFetcher TypeError — ($n - 1) * $pageSize → float(5.0E+19)
"9999999999999999999" (19 digits) OffsetFetcher TypeError — (int) cast saturates to PHP_INT_MAX, then + count() overflows
"5\n" both silently accepted as position 5

The saturation case is the quieter half: (int) clamps rather than wraps, so
"9999999999999999999" and str_repeat('9', 40) both became PHP_INT_MAX and addressed the
same position.

The trailing-newline hole is unrelated in cause but shares a fix site: /^\d+$/'s $ also
matches immediately before a trailing newline.

Change

The fix — one range guard in the shared positionFrom(), so both subclasses inherit it, and
the pattern anchored with \A/\z:

if ($position > $this->maxPosition()) {
    throw MalformedPageException::invalidEnvelope(/* ... */);
}

private function maxPosition(): int
{
    return intdiv(\PHP_INT_MAX, $this->pageSize) - 1;
}

PageNumberFetcher overflows an order of magnitude sooner than OffsetFetcher because it
multiplies, so the bound is derived from the page size rather than hardcoded. The - 1 gives one
page of headroom for the + count($items) term, including an upstream that over-delivers. The
bound doubles as the saturation guard: a saturated cursor lands on PHP_INT_MAX, which always
exceeds intdiv(PHP_INT_MAX, $pageSize) - 1, so it is rejected rather than silently aliased.

Rejected alternative: a (string) (int) $cursor === $cursor round-trip closes saturation and the
newline in one line, but it also starts rejecting "007". The library never emits a zero-padded
cursor, 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):

  • Test — pins the cross-unit approximation in the offset direction. totalPages from an
    OffsetFetcher over short windows lets the ordinal derived by intdiv($offset, $pageSize) + 1
    lag 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.
  • Docblock — Walk::hasNext() claimed "whether another page may be fetched", which is the
    opposite of what a budget-exhausted walk does: it still returns true and nextCursor()
    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.
  • Docblock — dropped "exactly" from ConnectionFormatter's hasPreviousPage. It reads the
    cursor as CursorCodec does, so a bare positional '0' — OffsetFetcher's origin — decodes
    to a non-null anchor and reports true. Behaviour unchanged: teaching the Relay layer to
    recognise one fetcher's wire format would leak that format across the boundary, so the
    docblock now points callers at passing null for the first page, as the recipes already do.
  • Comments — removed v0.1.0 migration history from Walk's docblock (it argued the change to
    a 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

# Gate Method Result
1 Tests vendor/bin/phpunit 283 passed, 1136 assertions (was 270 / 1122)
2 Static analysis composer stan (PHPStan level max, src + tests + tools) No errors
3 Code style composer cs (php-cs-fixer, PSR-12) 0 of 37 files need fixing
4 Smoke test composer check → examples/offline-example.php All checks passed.
5 Before/after repro Standalone script against the real classes All three inputs now MalformedPageException; upstream no longer called at all
6 Overflow claim php -r on the raw arithmetic (999999999999999999 - 1) * 50 → float(5.0E+19) confirmed
7 Regex claim preg_match on "5\n" 1 with /^\d+$/, 0 with /\A\d+\z/ confirmed
8 Boundary New tests at exactly one over and one under maxPosition() Both directions asserted

New tests: 4 in OffsetFetcherTest (2 providers + the accept-boundary + the cross-unit pin),
2 in PageNumberFetcherTest (provider + accept-boundary), covering ZOMBIES Boundaries and
Exceptions for the new guard.

Notes

  • Infection could not run locally — no xdebug/pcov in this environment, so the MSI ≥ 85 floor
    and the 100% line-coverage gate are checked only by CI. I expect the new logic mutants to be
    killed (> → >= and - 1 → + 1 both break the paired boundary tests) with the new
    exception-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.md is deliberately untouched. The affected limit (~1.8 × 10¹⁷ rows) is unreachable for
    any real dataset, and this repo keeps its contracts in docblocks and the CHANGELOG while the
    README stays a recipe guide.
  • Found by a /code-review pass over the already-merged Add offset fetchers and a Walk stepper, and report hasPreviousPage truthfully #4. The other findings from that pass
    are 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 a
TypeError (or silent mis-parse) to a MalformedPageException. Both were broken before; no
legitimate cursor is affected.

🤖 Generated with Claude Code

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
@ryckakas ryckakas added bug Something isn't working documentation Improvements or additions to documentation labels Aug 5, 2026
@ryckakas
ryckakas merged commit 11061be into main Aug 5, 2026
4 checks passed
@ryckakas
ryckakas deleted the fix/positional-cursor-range-guard branch August 5, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant