2026-07-29 06:02:07 -04:00
|
|
|
"""Deterministic, generation-bound pagination cursors for bounded public results."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import base64
|
|
|
|
|
import binascii
|
|
|
|
|
import hashlib
|
|
|
|
|
import hmac
|
|
|
|
|
import json
|
|
|
|
|
from collections.abc import Mapping
|
|
|
|
|
from typing import cast
|
|
|
|
|
|
|
|
|
|
from .errors import DocForgeError
|
|
|
|
|
|
|
|
|
|
CURSOR_SCHEMA_VERSION = 1
|
|
|
|
|
MAX_CURSOR_CHARS = 8_192
|
|
|
|
|
_CURSOR_DOMAIN = b"docforge-page-cursor-v1\0"
|
|
|
|
|
_CURSOR_KEYS = frozenset({"schema_version", "kind", "binding", "position", "checksum"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def canonical_hash(value: object) -> str:
|
|
|
|
|
"""Hash one JSON-compatible value using DocForge's deterministic JSON form."""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
encoded = _canonical_bytes(value)
|
|
|
|
|
except (TypeError, ValueError) as error:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_pagination_source",
|
|
|
|
|
"Pagination source data is not deterministic JSON",
|
|
|
|
|
) from error
|
|
|
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def page_limit(limit: int | None, *, default: int, maximum: int) -> int:
|
|
|
|
|
"""Validate one additive page size against the project result policy."""
|
|
|
|
|
|
|
|
|
|
selected = default if limit is None else limit
|
|
|
|
|
if type(selected) is not int or selected < 1 or selected > maximum:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_limit",
|
|
|
|
|
"Page limit is outside the configured result limit",
|
|
|
|
|
maximum=maximum,
|
|
|
|
|
)
|
|
|
|
|
return selected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def encode_cursor(
|
|
|
|
|
*,
|
|
|
|
|
kind: str,
|
|
|
|
|
binding: Mapping[str, object],
|
|
|
|
|
position: int,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Encode a corruption-detecting cursor bound to an immutable result identity."""
|
|
|
|
|
|
|
|
|
|
if not kind or type(position) is not int or position < 0:
|
|
|
|
|
raise ValueError("Cursor kind and position must be valid")
|
|
|
|
|
body: dict[str, object] = {
|
|
|
|
|
"schema_version": CURSOR_SCHEMA_VERSION,
|
|
|
|
|
"kind": kind,
|
|
|
|
|
"binding": dict(binding),
|
|
|
|
|
"position": position,
|
|
|
|
|
}
|
|
|
|
|
checksum = hashlib.sha256(_CURSOR_DOMAIN + _canonical_bytes(body)).hexdigest()
|
|
|
|
|
envelope = {**body, "checksum": checksum}
|
|
|
|
|
return base64.urlsafe_b64encode(_canonical_bytes(envelope)).decode("ascii").rstrip("=")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def decode_cursor(
|
|
|
|
|
cursor: str | None,
|
|
|
|
|
*,
|
|
|
|
|
kind: str,
|
|
|
|
|
binding: Mapping[str, object],
|
|
|
|
|
total_count: int,
|
|
|
|
|
) -> int:
|
|
|
|
|
"""Return a validated position, rejecting corrupt, foreign, or stale cursors."""
|
|
|
|
|
|
|
|
|
|
if cursor is None:
|
|
|
|
|
return 0
|
|
|
|
|
if not cursor or len(cursor) > MAX_CURSOR_CHARS or not cursor.isascii():
|
|
|
|
|
raise _invalid_cursor()
|
|
|
|
|
padding = "=" * (-len(cursor) % 4)
|
|
|
|
|
try:
|
|
|
|
|
raw = base64.b64decode(
|
|
|
|
|
(cursor + padding).encode("ascii"),
|
|
|
|
|
altchars=b"-_",
|
|
|
|
|
validate=True,
|
|
|
|
|
)
|
|
|
|
|
parsed: object = json.loads(raw.decode("utf-8"))
|
|
|
|
|
except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError):
|
|
|
|
|
raise _invalid_cursor() from None
|
|
|
|
|
if base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") != cursor or not isinstance(
|
|
|
|
|
parsed, dict
|
|
|
|
|
):
|
|
|
|
|
raise _invalid_cursor()
|
|
|
|
|
payload = cast(dict[str, object], parsed)
|
|
|
|
|
checksum = payload.get("checksum")
|
|
|
|
|
position = payload.get("position")
|
|
|
|
|
stored_binding = payload.get("binding")
|
|
|
|
|
if (
|
|
|
|
|
frozenset(payload) != _CURSOR_KEYS
|
|
|
|
|
or payload.get("schema_version") != CURSOR_SCHEMA_VERSION
|
|
|
|
|
or payload.get("kind") != kind
|
|
|
|
|
or not isinstance(stored_binding, dict)
|
|
|
|
|
or type(position) is not int
|
|
|
|
|
or position < 0
|
|
|
|
|
or not isinstance(checksum, str)
|
|
|
|
|
or len(checksum) != 64
|
|
|
|
|
):
|
|
|
|
|
raise _invalid_cursor()
|
|
|
|
|
body = {key: payload[key] for key in payload if key != "checksum"}
|
|
|
|
|
expected = hashlib.sha256(_CURSOR_DOMAIN + _canonical_bytes(body)).hexdigest()
|
|
|
|
|
if not hmac.compare_digest(checksum, expected):
|
|
|
|
|
raise _invalid_cursor()
|
|
|
|
|
if stored_binding != dict(binding):
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"stale_cursor",
|
|
|
|
|
"Pagination cursor does not match the current result generation",
|
|
|
|
|
)
|
2026-07-29 08:23:04 -04:00
|
|
|
if position >= total_count:
|
|
|
|
|
raise _invalid_cursor()
|
2026-07-29 06:02:07 -04:00
|
|
|
return position
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def page_receipt(
|
|
|
|
|
*,
|
|
|
|
|
kind: str,
|
|
|
|
|
binding: Mapping[str, object],
|
|
|
|
|
position: int,
|
|
|
|
|
count: int,
|
|
|
|
|
limit: int,
|
|
|
|
|
total_count: int,
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
"""Return one bounded page receipt and the next generation-bound cursor."""
|
|
|
|
|
|
|
|
|
|
next_position = position + count
|
|
|
|
|
has_more = next_position < total_count
|
|
|
|
|
return {
|
|
|
|
|
"schema_version": CURSOR_SCHEMA_VERSION,
|
|
|
|
|
"kind": kind,
|
|
|
|
|
"returned_count": count,
|
|
|
|
|
"limit": limit,
|
|
|
|
|
"total_count": total_count,
|
|
|
|
|
"has_more": has_more,
|
|
|
|
|
"next_cursor": (
|
|
|
|
|
encode_cursor(kind=kind, binding=binding, position=next_position) if has_more else None
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _canonical_bytes(value: object) -> bytes:
|
|
|
|
|
return json.dumps(
|
|
|
|
|
value,
|
|
|
|
|
sort_keys=True,
|
|
|
|
|
separators=(",", ":"),
|
|
|
|
|
ensure_ascii=True,
|
|
|
|
|
allow_nan=False,
|
|
|
|
|
).encode("utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _invalid_cursor() -> DocForgeError:
|
|
|
|
|
return DocForgeError(
|
|
|
|
|
"invalid_cursor",
|
|
|
|
|
"Pagination cursor is malformed or does not match its operation",
|
|
|
|
|
)
|