""" Mutable Global Share storage control plane. Global Share is a capability-bearing lifecycle, not a cache. This module keeps that lifecycle behind one bounded store interface so a deployment can choose: ``memory`` Compatibility/development only. Process-local and lost at restart. ``sqlite`` Restart-durable transactional storage for one local filesystem authority. ``redis`` Shared transactional storage for multiple replicas in one Redis consistency domain. Redis durability is reported only when the operator explicitly confirms it; shared is not synonymous with durable. Public Share identifiers are never stored as SQLite/Redis keys verbatim. Their SHA-256 digest is sufficient for lookup because generated identifiers carry at least 128 bits of entropy, while keeping bearer read capabilities out of routine backend key listings. """ from __future__ import annotations import asyncio import hashlib import json import sqlite3 import time from pathlib import Path from typing import Any from ._redis_security import RedisSecurityError, redis_connection_kwargs class ShareStoreError(RuntimeError): """Stable, non-sensitive Share control-plane error.""" def __init__(self, code: str) -> None: super().__init__(code) self.code = code def _copy(entry: dict[str, Any] | None) -> dict[str, Any] | None: return None if entry is None else json.loads(json.dumps(entry, ensure_ascii=False)) def _key(share_id: str) -> str: return hashlib.sha256(str(share_id).encode("utf-8")).hexdigest() def _now() -> float: return time.time() class MemoryShareStore: backend = "memory" durability = "process_local" durable = False shared = False authoritative = False consistency_scope = "process_local" def __init__(self, *, max_entries: int, max_total_bytes: int) -> None: self.max_entries = int(max_entries) self.max_total_bytes = int(max_total_bytes) self.entries: dict[str, dict[str, Any]] = {} self._lock = asyncio.Lock() async def initialize(self) -> None: return None async def close(self) -> None: return None def manifest(self) -> dict[str, Any]: return { "backend": self.backend, "durability": self.durability, "durable": self.durable, "shared": self.shared, "authoritative": self.authoritative, "consistency_scope": self.consistency_scope, } def _sweep(self, now: float) -> None: for sid in [ sid for sid, e in self.entries.items() if float(e.get("expiresAt_ts") or 0) <= now ]: self.entries.pop(sid, None) async def create(self, share_id: str, entry: dict[str, Any]) -> None: async with self._lock: self._sweep(_now()) if share_id in self.entries: raise ShareStoreError("DUPLICATE_SHARE") if len(self.entries) >= self.max_entries: raise ShareStoreError("ENTRY_CAPACITY") total = sum(int(e.get("bytes") or 0) for e in self.entries.values()) if total + int(entry.get("bytes") or 0) > self.max_total_bytes: raise ShareStoreError("BYTE_CAPACITY") self.entries[share_id] = _copy(entry) or {} async def get(self, share_id: str) -> dict[str, Any] | None: async with self._lock: entry = self.entries.get(share_id) if entry is None: return None if float(entry.get("expiresAt_ts") or 0) <= _now(): self.entries.pop(share_id, None) raise ShareStoreError("EXPIRED") return _copy(entry) async def replace_authorized( self, share_id: str, edit_hash: str, entry: dict[str, Any] ) -> None: async with self._lock: current = self.entries.get(share_id) if current is None: raise ShareStoreError("NOT_FOUND") if float(current.get("expiresAt_ts") or 0) <= _now(): self.entries.pop(share_id, None) raise ShareStoreError("EXPIRED") if str(current.get("edit_hash") or "") != str(edit_hash or ""): raise ShareStoreError("AUTH") total = sum(int(e.get("bytes") or 0) for e in self.entries.values()) proposed = ( total - int(current.get("bytes") or 0) + int(entry.get("bytes") or 0) ) if proposed > self.max_total_bytes: raise ShareStoreError("BYTE_CAPACITY") self.entries[share_id] = _copy(entry) or {} async def delete_authorized(self, share_id: str, edit_hash: str) -> None: async with self._lock: current = self.entries.get(share_id) if current is None: raise ShareStoreError("NOT_FOUND") if float(current.get("expiresAt_ts") or 0) <= _now(): self.entries.pop(share_id, None) raise ShareStoreError("EXPIRED") if str(current.get("edit_hash") or "") != str(edit_hash or ""): raise ShareStoreError("AUTH") self.entries.pop(share_id, None) async def delete_unchecked(self, share_id: str) -> None: async with self._lock: self.entries.pop(share_id, None) class SQLiteShareStore: backend = "sqlite" durability = "restart_durable_local" durable = True shared = False authoritative = True consistency_scope = "single_sqlite_file" def __init__(self, path: str, *, max_entries: int, max_total_bytes: int) -> None: if not str(path or "").strip(): raise ShareStoreError("SQLITE_PATH_REQUIRED") self.path = str(path) self.max_entries = int(max_entries) self.max_total_bytes = int(max_total_bytes) self._lock = asyncio.Lock() def manifest(self) -> dict[str, Any]: return { "backend": self.backend, "durability": self.durability, "durable": self.durable, "shared": self.shared, "authoritative": self.authoritative, "consistency_scope": self.consistency_scope, "public_id_at_rest": "sha256", } def _connect(self) -> sqlite3.Connection: conn = sqlite3.connect(self.path, timeout=5.0) conn.row_factory = sqlite3.Row conn.execute("PRAGMA busy_timeout=5000") conn.execute("PRAGMA secure_delete=ON") return conn def _init_sync(self) -> None: Path(self.path).parent.mkdir(parents=True, exist_ok=True) conn = self._connect() try: conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=FULL") conn.execute("""CREATE TABLE IF NOT EXISTS global_shares ( share_key TEXT PRIMARY KEY, entry_json TEXT NOT NULL, bytes INTEGER NOT NULL, expires_at REAL NOT NULL, edit_hash TEXT NOT NULL, updated_at REAL NOT NULL ) """) conn.execute( "CREATE INDEX IF NOT EXISTS ix_global_shares_expires ON global_shares(expires_at)" ) conn.execute("DELETE FROM global_shares WHERE expires_at <= ?", (_now(),)) conn.commit() finally: conn.close() async def initialize(self) -> None: await asyncio.to_thread(self._init_sync) async def close(self) -> None: return None async def create(self, share_id: str, entry: dict[str, Any]) -> None: async with self._lock: def op() -> None: conn = self._connect() try: conn.execute("BEGIN IMMEDIATE") now = _now() conn.execute( "DELETE FROM global_shares WHERE expires_at <= ?", (now,) ) count, total = conn.execute( "SELECT COUNT(*), COALESCE(SUM(bytes),0) FROM global_shares" ).fetchone() if int(count) >= self.max_entries: raise ShareStoreError("ENTRY_CAPACITY") if int(total) + int(entry.get("bytes") or 0) > self.max_total_bytes: raise ShareStoreError("BYTE_CAPACITY") try: conn.execute( "INSERT INTO global_shares(share_key,entry_json,bytes,expires_at,edit_hash,updated_at) VALUES(?,?,?,?,?,?)", ( _key(share_id), json.dumps( entry, ensure_ascii=False, separators=(",", ":") ), int(entry.get("bytes") or 0), float(entry.get("expiresAt_ts") or 0), str(entry.get("edit_hash") or ""), now, ), ) except sqlite3.IntegrityError as exc: raise ShareStoreError("DUPLICATE_SHARE") from exc conn.commit() except Exception: conn.rollback() raise finally: conn.close() await asyncio.to_thread(op) async def get(self, share_id: str) -> dict[str, Any] | None: async with self._lock: def op() -> dict[str, Any] | None: conn = self._connect() try: conn.execute("BEGIN IMMEDIATE") row = conn.execute( "SELECT entry_json,expires_at FROM global_shares WHERE share_key=?", (_key(share_id),), ).fetchone() if row is None: conn.commit() return None if float(row["expires_at"] or 0) <= _now(): conn.execute( "DELETE FROM global_shares WHERE share_key=?", (_key(share_id),), ) conn.commit() raise ShareStoreError("EXPIRED") conn.commit() return json.loads(row["entry_json"]) finally: conn.close() return await asyncio.to_thread(op) async def replace_authorized( self, share_id: str, edit_hash: str, entry: dict[str, Any] ) -> None: async with self._lock: def op() -> None: conn = self._connect() try: conn.execute("BEGIN IMMEDIATE") row = conn.execute( "SELECT bytes,expires_at,edit_hash FROM global_shares WHERE share_key=?", (_key(share_id),), ).fetchone() if row is None: raise ShareStoreError("NOT_FOUND") if float(row["expires_at"] or 0) <= _now(): conn.execute( "DELETE FROM global_shares WHERE share_key=?", (_key(share_id),), ) conn.commit() raise ShareStoreError("EXPIRED") if str(row["edit_hash"] or "") != str(edit_hash or ""): raise ShareStoreError("AUTH") total = int( conn.execute( "SELECT COALESCE(SUM(bytes),0) FROM global_shares" ).fetchone()[0] ) proposed = ( total - int(row["bytes"] or 0) + int(entry.get("bytes") or 0) ) if proposed > self.max_total_bytes: raise ShareStoreError("BYTE_CAPACITY") conn.execute( "UPDATE global_shares SET entry_json=?,bytes=?,expires_at=?,edit_hash=?,updated_at=? WHERE share_key=? AND edit_hash=?", ( json.dumps( entry, ensure_ascii=False, separators=(",", ":") ), int(entry.get("bytes") or 0), float(entry.get("expiresAt_ts") or 0), str(entry.get("edit_hash") or ""), _now(), _key(share_id), edit_hash, ), ) conn.commit() except Exception: conn.rollback() raise finally: conn.close() await asyncio.to_thread(op) async def delete_authorized(self, share_id: str, edit_hash: str) -> None: async with self._lock: def op() -> None: conn = self._connect() try: conn.execute("BEGIN IMMEDIATE") row = conn.execute( "SELECT expires_at,edit_hash FROM global_shares WHERE share_key=?", (_key(share_id),), ).fetchone() if row is None: raise ShareStoreError("NOT_FOUND") if float(row["expires_at"] or 0) <= _now(): conn.execute( "DELETE FROM global_shares WHERE share_key=?", (_key(share_id),), ) conn.commit() raise ShareStoreError("EXPIRED") if str(row["edit_hash"] or "") != str(edit_hash or ""): raise ShareStoreError("AUTH") conn.execute( "DELETE FROM global_shares WHERE share_key=?", (_key(share_id),) ) conn.commit() except Exception: conn.rollback() raise finally: conn.close() await asyncio.to_thread(op) async def delete_unchecked(self, share_id: str) -> None: async with self._lock: def op() -> None: conn = self._connect() try: conn.execute( "DELETE FROM global_shares WHERE share_key=?", (_key(share_id),) ) conn.commit() finally: conn.close() await asyncio.to_thread(op) _REDIS_CREATE = r""" local now=tonumber(ARGV[1]); local member=ARGV[2]; local raw=ARGV[3]; local exp=tonumber(ARGV[4]); local max_entries=tonumber(ARGV[5]); local max_bytes=tonumber(ARGV[6]); local bytes=tonumber(ARGV[7]); local ttl=tonumber(ARGV[8]); local prefix=ARGV[9] local expired=redis.call('ZRANGEBYSCORE',KEYS[1],'-inf',now) for _,m in ipairs(expired) do local old=redis.call('GET',prefix..m); if old then local e=cjson.decode(old); redis.call('DECRBY',KEYS[2],tonumber(e.bytes or 0)) end redis.call('DEL',prefix..m); redis.call('ZREM',KEYS[1],m) end if redis.call('EXISTS',KEYS[3]) == 1 then return {0,'DUPLICATE_SHARE'} end if redis.call('ZCARD',KEYS[1]) >= max_entries then return {0,'ENTRY_CAPACITY'} end local total=tonumber(redis.call('GET',KEYS[2]) or '0'); if total+bytes > max_bytes then return {0,'BYTE_CAPACITY'} end redis.call('SET',KEYS[3],raw,'EX',ttl); redis.call('ZADD',KEYS[1],exp,member); redis.call('INCRBY',KEYS[2],bytes); return {1,'OK'} """.strip() _REDIS_GET = r""" local now=tonumber(ARGV[1]); local member=ARGV[2] local old=redis.call('GET',KEYS[3]); if not old then return {0,'NOT_FOUND'} end local e=cjson.decode(old) if tonumber(e.expiresAt_ts or 0) <= now then redis.call('DEL',KEYS[3]); redis.call('ZREM',KEYS[1],member) local n=tonumber(e.bytes or 0); if n > 0 then redis.call('DECRBY',KEYS[2],n) end return {0,'EXPIRED'} end return {1,old} """.strip() _REDIS_REPLACE = r""" local now=tonumber(ARGV[1]); local raw=ARGV[2]; local exp=tonumber(ARGV[3]); local bytes=tonumber(ARGV[4]); local ttl=tonumber(ARGV[5]); local expected=ARGV[6]; local max_bytes=tonumber(ARGV[7]) local old=redis.call('GET',KEYS[3]); if not old then return {0,'NOT_FOUND'} end local e=cjson.decode(old); if tonumber(e.expiresAt_ts or 0) <= now then redis.call('DEL',KEYS[3]); redis.call('ZREM',KEYS[1],ARGV[8]); redis.call('DECRBY',KEYS[2],tonumber(e.bytes or 0)); return {0,'EXPIRED'} end if tostring(e.edit_hash or '') ~= expected then return {0,'AUTH'} end local total=tonumber(redis.call('GET',KEYS[2]) or '0'); local proposed=total-tonumber(e.bytes or 0)+bytes; if proposed > max_bytes then return {0,'BYTE_CAPACITY'} end redis.call('SET',KEYS[3],raw,'EX',ttl); redis.call('ZADD',KEYS[1],exp,ARGV[8]); redis.call('SET',KEYS[2],proposed); return {1,'OK'} """.strip() _REDIS_DELETE = r""" local now=tonumber(ARGV[1]); local expected=ARGV[2]; local member=ARGV[3] local old=redis.call('GET',KEYS[3]); if not old then return {0,'NOT_FOUND'} end local e=cjson.decode(old); if tonumber(e.expiresAt_ts or 0) <= now then redis.call('DEL',KEYS[3]); redis.call('ZREM',KEYS[1],member); redis.call('DECRBY',KEYS[2],tonumber(e.bytes or 0)); return {0,'EXPIRED'} end if tostring(e.edit_hash or '') ~= expected then return {0,'AUTH'} end redis.call('DEL',KEYS[3]); redis.call('ZREM',KEYS[1],member); redis.call('DECRBY',KEYS[2],tonumber(e.bytes or 0)); return {1,'OK'} """.strip() class RedisShareStore: backend = "redis" shared = True authoritative = True consistency_scope = "single_redis_consistency_domain" def __init__( self, url: str, *, key_prefix: str, max_entries: int, max_total_bytes: int, durable_confirmed: bool = False, socket_timeout_seconds: float = 2.0, client: Any | None = None, require_tls: bool = False, ) -> None: if not str(url or "").strip(): raise ShareStoreError("REDIS_URL_REQUIRED") self.url = str(url).strip() self.max_entries = int(max_entries) self.max_total_bytes = int(max_total_bytes) self.require_tls = bool(require_tls) try: self._transport, self._connection_kwargs = redis_connection_kwargs( self.url, require_tls=self.require_tls, socket_timeout_seconds=socket_timeout_seconds, ) except RedisSecurityError as exc: raise ShareStoreError(exc.code) from exc self.durable = bool(durable_confirmed) self.durability = ( "shared_external_persistence_confirmed" if self.durable else "shared_external_persistence_unverified" ) safe = "".join( c for c in str(key_prefix or "sphinx-ai-assistant").lower() if c.isalnum() or c in "_-:" )[:64] self.key_prefix = safe or "sphinx-ai-assistant" tag = f"{self.key_prefix}:{{share}}" self._all = f"{tag}:all" self._bytes = f"{tag}:bytes" self._prefix = f"{tag}:entry:" self.socket_timeout_seconds = max( 0.25, min(float(socket_timeout_seconds), 10.0) ) self._client = client self._owns = client is None self._lock = asyncio.Lock() def manifest(self) -> dict[str, Any]: return { "backend": self.backend, "durability": self.durability, "durable": self.durable, "shared": True, "authoritative": True, "consistency_scope": self.consistency_scope, "public_id_at_rest": "sha256", **self._transport.manifest(), } async def initialize(self) -> None: async with self._lock: if self._client is None: try: import redis.asyncio as redis_async # type: ignore[import-not-found] # ruff: ignore[import-outside-top-level] except Exception as exc: raise ShareStoreError("REDIS_DEPENDENCY_UNAVAILABLE") from exc self._client = redis_async.from_url(self.url, **self._connection_kwargs) try: await self._client.ping() except Exception as exc: raise ShareStoreError("REDIS_UNAVAILABLE") from exc async def close(self) -> None: if self._client is None or not self._owns: return closer = getattr(self._client, "aclose", None) or getattr( self._client, "close", None ) if closer: result = closer() if hasattr(result, "__await__"): await result self._client = None def _keys(self, share_id: str) -> tuple[str, str]: member = _key(share_id) return member, self._prefix + member async def _eval( self, script: str, keys: list[str], args: list[Any] ) -> tuple[int, str]: if self._client is None: raise ShareStoreError("REDIS_NOT_INITIALIZED") try: out = await self._client.eval(script, len(keys), *keys, *args) except Exception as exc: raise ShareStoreError("REDIS_OPERATION_FAILED") from exc if not isinstance(out, (list, tuple)) or len(out) < ( 2 # ruff: ignore[magic-value-comparison] ): raise ShareStoreError("REDIS_PROTOCOL_ERROR") val = out[1].decode() if isinstance(out[1], bytes) else str(out[1]) return int(out[0]), val @staticmethod def _encode(entry: dict[str, Any]) -> str: return json.dumps(entry, ensure_ascii=False, separators=(",", ":")) async def create(self, share_id: str, entry: dict[str, Any]) -> None: member, key = self._keys(share_id) now = _now() exp = float(entry.get("expiresAt_ts") or 0) ttl = max(1, int(exp - now + 0.999)) ok, val = await self._eval( _REDIS_CREATE, [self._all, self._bytes, key], [ now, member, self._encode(entry), exp, self.max_entries, self.max_total_bytes, int(entry.get("bytes") or 0), ttl, self._prefix, ], ) if not ok: raise ShareStoreError(val) async def get(self, share_id: str) -> dict[str, Any] | None: member, key = self._keys(share_id) ok, val = await self._eval( _REDIS_GET, [self._all, self._bytes, key], [_now(), member] ) if not ok: if val == "NOT_FOUND": return None raise ShareStoreError(val) try: return json.loads(val) except Exception as exc: raise ShareStoreError("REDIS_PROTOCOL_ERROR") from exc async def replace_authorized( self, share_id: str, edit_hash: str, entry: dict[str, Any] ) -> None: member, key = self._keys(share_id) now = _now() exp = float(entry.get("expiresAt_ts") or 0) ttl = max(1, int(exp - now + 0.999)) ok, val = await self._eval( _REDIS_REPLACE, [self._all, self._bytes, key], [ now, self._encode(entry), exp, int(entry.get("bytes") or 0), ttl, edit_hash, self.max_total_bytes, member, ], ) if not ok: raise ShareStoreError(val) async def delete_authorized(self, share_id: str, edit_hash: str) -> None: member, key = self._keys(share_id) ok, val = await self._eval( _REDIS_DELETE, [self._all, self._bytes, key], [_now(), edit_hash, member] ) if not ok: raise ShareStoreError(val) async def delete_unchecked(self, share_id: str) -> None: if self._client is None: raise ShareStoreError("REDIS_NOT_INITIALIZED") member, key = self._keys(share_id) try: raw = await self._client.get(key) n = 0 if raw: if isinstance(raw, bytes): raw = raw.decode("utf-8") try: n = int(json.loads(str(raw)).get("bytes") or 0) except Exception: # ruff: ignore[blind-except] n = 0 pipe = self._client.pipeline(transaction=True) pipe.delete(key) pipe.zrem(self._all, member) if n: pipe.decrby(self._bytes, n) await pipe.execute() except Exception as exc: raise ShareStoreError("REDIS_OPERATION_FAILED") from exc def build_share_store( backend: str, *, sqlite_path: str, redis_url: str = "", redis_key_prefix: str = "sphinx-ai-assistant", redis_timeout_seconds: float = 2.0, redis_durable_confirmed: bool = False, max_entries: int, max_total_bytes: int, redis_client: Any | None = None, require_redis_tls: bool = False, ): name = str(backend or "memory").strip().lower() if name == "memory": return MemoryShareStore( max_entries=max_entries, max_total_bytes=max_total_bytes ) if name == "sqlite": return SQLiteShareStore( sqlite_path, max_entries=max_entries, max_total_bytes=max_total_bytes ) if name == "redis": return RedisShareStore( redis_url, key_prefix=redis_key_prefix, max_entries=max_entries, max_total_bytes=max_total_bytes, durable_confirmed=redis_durable_confirmed, socket_timeout_seconds=redis_timeout_seconds, client=redis_client, require_tls=require_redis_tls, ) raise ShareStoreError("UNSUPPORTED_BACKEND")