"""HTTP revalidation validators for the read-only catalogue reads (phase 167, task 03, D3 — LOCKED A4). Three pure functions, no I/O, ONE place for the pattern (the routes compose them; the alternative was a copy per route): * :func:`weak_etag` — the validator: a weak ``ETag`` over the EXACT bytes being served, * :func:`etag_matches` — the tolerant ``If-None-Match`` evaluator, * :func:`revalidate_headers` — the header pair a validated response must carry. **Why a content hash, not ``sources_meta.version``** (LOCKED A4): bumping that generation marks every saved chat ``stale`` (phase 53) — the wrong tool for a payload revalidation — and the admin PATCHes (``/api/documents/summary``, ``/api/documents/date``, ``/api/folders/summary``) mutate the tree WITHOUT bumping it, so a version-derived tag would serve stale trees. A hash of the serialized body is content-exact by construction: the tag changes ⟺ the body changes, it needs no migration, and the tree is built anyway (the hash costs microseconds next to the query). The tag is over the EXACT served bytes — the endpoint serializes once and hands those bytes to both the hash and the ``Response``. **Why a truncation to 32 hex chars is safe**: 32 hex chars of SHA-256 is this file's truncation convention. Collisions are not a correctness risk for a revalidated cache: a false match only ever costs one stale repaint on a ~2 MB namespace (the next unconditional load corrects it), while 128 bits of digest make even that astronomically unlikely. A19 SCOPE (:func:`revalidate_headers`, LOCKED A4 as a recorded, scoped extension of A19): HTML pages stay ``no-cache`` with NO validators (the caching middleware strips inbound conditionals and outbound validators — the phase-54 contract), ``/assets/*`` stay ``immutable`` for a year (URL-versioned), and this module governs the READ-ONLY API arm only — today the two KB-tree routes and ``GET /api/docs``. The SSE stream and every mutating route are untouched. """ from __future__ import annotations import hashlib #: Hex chars of the SHA-256 digest the tag carries (see the module #: docstring's collision argument). _TAG_HEX_CHARS = 32 #: The ``Cache-Control`` paired with every validator here: ``private`` #: (no shared cache may store the admin-gated catalogue) + #: ``must-revalidate`` (a sync or an admin edit is seen on the NEXT #: load — the client may never serve these bytes without asking, which #: is what distinguishes this from the ``/assets/*`` posture). REVALIDATE_CACHE_CONTROL = "private, must-revalidate" def weak_etag(body: str | bytes) -> str: """Weak validator over the exact served bytes: ``W/""``. Content-exact by construction (the endpoint hashes the SAME string it returns — see the module docstring for the A4 argument and the truncation convention). ``str`` bodies are hashed as UTF-8, so the str and its ``bytes`` form produce the SAME tag. Weak (``W/``) because the comparison is byte-equivalence under revalidation, not bit-identity — a differently gzipped encoding of the same content must still match (RFC 9110 §8.8.3). """ raw = body.encode("utf-8") if isinstance(body, str) else body return f'W/"{hashlib.sha256(raw).hexdigest()[:_TAG_HEX_CHARS]}"' def _strip_weakness(candidate: str) -> str: """One comparable form: trimmed, with the ``W/`` weakness marker gone (weak/strong equivalence — ``W/"x"`` matches ``"x"`` in BOTH directions, RFC 9110 §8.8.3.2's weak comparison rule applied in both directions on purpose: our tag is over the exact bytes, so a client that stored either form HAS these bytes).""" value = candidate.strip() if value[:2].upper() == "W/": value = value[2:].strip() return value def etag_matches(header: str | None, tag: str) -> bool: """Does an inbound ``If-None-Match`` header value match our ``tag``? Tolerant the way real clients send it: ``*`` matches (RFC 9110 §13.3.3 — the client holds nothing it trusts), a comma-separated candidate list matches on ANY entry, weak/strong forms are equivalent in both directions, and surrounding whitespace is ignored. ``None`` or an empty/whitespace-only header never matches (a request with no conditional header is an unconditional GET → 200). """ if not header or not header.strip(): return False candidates = header.split(",") if any(candidate.strip() == "*" for candidate in candidates): return True target = _strip_weakness(tag) return any(_strip_weakness(candidate) == target for candidate in candidates) def revalidate_headers(etag: str) -> dict[str, str]: """The ONE header pair every validated catalogue response carries — on the 200 AND on the 304 (RFC 9110 §8.8.3: a 304 must repeat the validator, or the client cannot extend the stored entry's life). A19 scope (module docstring): HTML pages stay ``no-cache`` with no validators, ``/assets/*`` stay ``immutable``; this pair is the read-only API arm — ``private, must-revalidate`` so a sync or an admin edit is seen on the next load, because the tag changes exactly when the body changes. """ return {"ETag": etag, "Cache-Control": REVALIDATE_CACHE_CONTROL}