"""Persistent, encrypted retry buffer for offline periods. When the upstream is unreachable, signed payloads are written to ``buffer_dir`` as individual ``{timestamp}-{router_id}.json.enc`` files, encrypted with a key derived from the tenant API key (see compliance.crypto). On reconnection they are replayed oldest-first; each is deleted only after a ``202 Accepted``. The buffer is bounded (``max_buffer_payloads``); the oldest file is dropped when the limit is exceeded. """ import json import re from pathlib import Path from typing import Optional from compliance import crypto from compliance.settings import ComplianceSettings _SAFE = re.compile(r"[^A-Za-z0-9_.-]") def _safe(s: str) -> str: return _SAFE.sub("_", s) class BufferStore: def __init__(self, cfg: ComplianceSettings): self._cfg = cfg self._dir = Path(cfg.buffer_dir) def _ensure_dir(self) -> None: self._dir.mkdir(parents=True, exist_ok=True) def _files(self) -> list[Path]: if not self._dir.exists(): return [] # Filenames are ``{epoch_ms}-{router_id}.json.enc``; lexical sort on the # zero-padded epoch prefix is chronological. return sorted(self._dir.glob("*.json.enc")) def count(self) -> int: return len(self._files()) def save(self, payload: dict, timestamp_ms: int) -> Optional[Path]: """Encrypt and persist ``payload``. Returns the path, or None on failure.""" if not self._cfg.api_key: return None self._ensure_dir() self._enforce_limit(reserve=1) name = f"{timestamp_ms:020d}-{_safe(self._cfg.router_id)}.json.enc" path = self._dir / name token = crypto.encrypt(json.dumps(payload).encode("utf-8"), self._cfg.api_key) path.write_bytes(token) return path def load(self, path: Path) -> Optional[dict]: """Decrypt and parse a buffer file. Returns None (and deletes) on corruption.""" if not self._cfg.api_key: return None try: token = path.read_bytes() except OSError: return None plaintext = crypto.decrypt(token, self._cfg.api_key) if plaintext is None: # Wrong key or corrupt file — unrecoverable, drop it. self.delete(path) return None try: return json.loads(plaintext) except json.JSONDecodeError: self.delete(path) return None def pending(self) -> list[Path]: """Buffered files, oldest first.""" return self._files() def delete(self, path: Path) -> None: try: path.unlink() except OSError: pass def clear(self) -> None: for p in self._files(): self.delete(p) def _enforce_limit(self, reserve: int = 0) -> None: """Drop oldest files until under (max - reserve).""" limit = max(self._cfg.max_buffer_payloads - reserve, 0) files = self._files() while len(files) > limit: self.delete(files.pop(0))