From c2f681dd78f02286338cb07a9bad329d10f8c576 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Mon, 25 May 2026 16:11:39 +0300 Subject: [PATCH] constant-time auth, migration short-circuit --- haiku_rag_slim/haiku/rag/ingester/api/auth.py | 4 +- .../haiku/rag/ingester/api/routes/sources.py | 2 +- .../haiku/rag/ingester/pollers/base.py | 4 ++ .../haiku/rag/store/upgrades/v0_50_0.py | 17 +++++- tests/store/test_v0_50_0_migration.py | 53 +++++++++++++++++++ 5 files changed, 76 insertions(+), 4 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/ingester/api/auth.py b/haiku_rag_slim/haiku/rag/ingester/api/auth.py index 97341cd7..dc92020d 100644 --- a/haiku_rag_slim/haiku/rag/ingester/api/auth.py +++ b/haiku_rag_slim/haiku/rag/ingester/api/auth.py @@ -1,3 +1,5 @@ +import secrets + from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -14,7 +16,7 @@ async def require_auth( expected = getattr(request.app.state, "auth_token", None) if expected is None: return - if creds is None or creds.credentials != expected: + if creds is None or not secrets.compare_digest(creds.credentials, expected): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized", diff --git a/haiku_rag_slim/haiku/rag/ingester/api/routes/sources.py b/haiku_rag_slim/haiku/rag/ingester/api/routes/sources.py index 838e974c..5bd45082 100644 --- a/haiku_rag_slim/haiku/rag/ingester/api/routes/sources.py +++ b/haiku_rag_slim/haiku/rag/ingester/api/routes/sources.py @@ -19,7 +19,7 @@ async def list_sources( source_id=poller.source_id, type=type(poller.config).__name__, last_polled_at=poller.last_polled_at, - circuit_breaker_open=poller._breaker.is_open, + circuit_breaker_open=poller.is_circuit_open, ) ) return summaries diff --git a/haiku_rag_slim/haiku/rag/ingester/pollers/base.py b/haiku_rag_slim/haiku/rag/ingester/pollers/base.py index b6cf1e20..26bbe0a6 100644 --- a/haiku_rag_slim/haiku/rag/ingester/pollers/base.py +++ b/haiku_rag_slim/haiku/rag/ingester/pollers/base.py @@ -72,6 +72,10 @@ class BasePoller: def last_polled_at(self) -> datetime | None: return self._last_polled_at + @property + def is_circuit_open(self) -> bool: + return self._breaker.is_open + async def run(self) -> None: # pragma: no cover - subclasses override raise NotImplementedError diff --git a/haiku_rag_slim/haiku/rag/store/upgrades/v0_50_0.py b/haiku_rag_slim/haiku/rag/store/upgrades/v0_50_0.py index 044d2229..4d0c4e85 100644 --- a/haiku_rag_slim/haiku/rag/store/upgrades/v0_50_0.py +++ b/haiku_rag_slim/haiku/rag/store/upgrades/v0_50_0.py @@ -35,11 +35,24 @@ def _normalize_metadata(meta: dict) -> tuple[dict, bool]: async def _apply_canonical_metadata_keys(store: Store) -> None: """Rewrite document.metadata so revision lives under the source-agnostic `source_revision` key and `contentType` becomes `content_type`. Documents - whose metadata already uses the canonical keys are untouched.""" + whose metadata already uses the canonical keys are untouched. + + Scoped via LIKE on the JSON-encoded metadata string so already-migrated + DBs return an empty set and skip the materialisation pass on subsequent + boots. `metadata` is stored as JSON text, so the quoted key form + (`"etag"`, `"contentType"`) is unambiguous against substrings inside + values. + """ rows = ( - await store.documents_table.query().select(["id", "metadata"]).to_arrow() + await store.documents_table.query() + .where("metadata LIKE '%\"etag\"%' OR metadata LIKE '%\"contentType\"%'") + .select(["id", "metadata"]) + .to_arrow() ).to_pylist() total = len(rows) + if total == 0: + logger.info("No legacy metadata keys found; nothing to migrate") + return logger.info("Normalising document metadata keys across %d documents", total) rewritten = 0 skipped = 0 diff --git a/tests/store/test_v0_50_0_migration.py b/tests/store/test_v0_50_0_migration.py index e025ea23..3284f151 100644 --- a/tests/store/test_v0_50_0_migration.py +++ b/tests/store/test_v0_50_0_migration.py @@ -86,6 +86,59 @@ class TestV0_50_0Migration: "md5": "m", } + async def test_like_filter_ignores_non_key_occurrences(self, temp_db_path): + """The WHERE short-circuit on `metadata LIKE '%"etag"%'` looks for the + quoted-key form. Values that happen to contain the substring `etag` and + composite key names like `my_etag_key` must not get rewritten.""" + async with Store(temp_db_path, create=True, skip_migration_check=True) as store: + await store.set_haiku_version("0.48.1") + await store.documents_table.add( + [ + # `etag` only appears as a VALUE — no `etag` key. Either + # the LIKE excludes it (no work), or it pulls it in and + # _normalize_metadata leaves it alone. Either way the row + # must end up unchanged. + DocumentRecord( + id="value-only", + content="x", + uri="u1", + metadata=json.dumps( + { + "description": "the etag of the file", + "source_revision": "v1", + } + ), + ), + # A composite key containing `etag` but not equal to it. + # Must not be rewritten. + DocumentRecord( + id="composite-key", + content="x", + uri="u2", + metadata=json.dumps( + { + "my_etag_key": "v", + "source_revision": "v2", + } + ), + ), + ] + ) + + async with Store(temp_db_path, skip_migration_check=True) as store: + await store.migrate() + rows = await store.documents_table.query().to_list() + by_id = {r["id"]: json.loads(r["metadata"]) for r in rows} + + assert by_id["value-only"] == { + "description": "the etag of the file", + "source_revision": "v1", + } + assert by_id["composite-key"] == { + "my_etag_key": "v", + "source_revision": "v2", + } + async def test_preserves_existing_canonical_keys_on_collision(self, temp_db_path): """If both legacy and canonical keys are present, the canonical wins and the legacy is dropped — defends against partial-migration states."""