"""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 position >= total_count 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", ) 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", )