""" The hub's content blocklist, as this node applies it (docs/MESHBAY_DESIGN.md §7.5). Hashes of public content that the hub's moderation has blocked. A node hosting a public group stops serving those files there: they leave the index members are sent, and a request for one is refused. Nothing is deleted from the operator's disk — the node stops serving, and what the operator keeps is theirs to decide. Private groups are untouched. The hub never learns what a private group holds, so there is nothing it could have blocked in one, and nothing here reaches them. Kept on disk as well as in memory, so a node that restarts while the hub is unreachable does not serve again, meanwhile, what it had already stopped serving. The hub's list is the authority: a full sync replaces this one. It is an exact match on the content id. A file changed by one byte is another id, and files over the partial-hash threshold are identified by a sample of their bytes (§6.3) — a moderation tool, not a guarantee. """ import json import logging import os import re from pathlib import Path log = logging.getLogger(__name__) _HASH = re.compile(r"^[0-9a-f]{64}$") class ContentBlocklist: def __init__(self, path: Path | None = None): self._path = path self._hashes: set[str] = set() if path is not None: try: data = json.loads(path.read_text(encoding="utf-8")) self._hashes = {h for h in data.get("hashes", []) if _HASH.match(h)} except FileNotFoundError: pass except (OSError, ValueError) as e: # A damaged file is not a reason to refuse to start; the next # sync with the hub rewrites it. log.warning("Content blocklist %s unreadable: %s", path, e) def __contains__(self, content_hash: object) -> bool: return content_hash in self._hashes def __len__(self) -> int: return len(self._hashes) def __iter__(self): return iter(self._hashes) def replace(self, hashes) -> bool: """The hub's full list. True when it differs from what was applied.""" new = {h for h in hashes if isinstance(h, str) and _HASH.match(h)} if new == self._hashes: return False self._hashes = new self._save() return True def apply(self, add=(), remove=()) -> bool: """One pushed change. True when it changed anything.""" before = set(self._hashes) self._hashes |= {h for h in add if isinstance(h, str) and _HASH.match(h)} self._hashes -= {h for h in remove if isinstance(h, str)} if self._hashes == before: return False self._save() return True def _save(self) -> None: if self._path is None: return tmp = self._path.with_suffix(".tmp") try: tmp.write_text(json.dumps({"hashes": sorted(self._hashes)}), encoding="utf-8") os.replace(tmp, self._path) except OSError as e: log.warning("Content blocklist %s not saved: %s", self._path, e)