"""Read-only hosted discovery with explicit, bounded source acceptance.""" from __future__ import annotations import hashlib import json import math import urllib.error import urllib.parse import urllib.request from datetime import date, datetime, timedelta, timezone from typing import Any MAX_SNAPSHOT_BYTES = 5 * 1024 * 1024 class SnapshotUnavailable(ValueError): """The selected source cannot support a planning recommendation.""" def load_hosted_snapshot( base_url: str, *, max_age_hours: float = 24 ) -> tuple[list[dict[str, Any]], str, dict[str, Any]]: """Fetch the public index; never send intent, tokens or mutation requests. Acceptance concerns the hub's composition and warning signals. It does not certify that every upstream repository has published its latest capability. Failure must not become a local fallback or a 'new capability' verdict. """ url = base_url.rstrip("/") + "/v1/federated" try: parsed = urllib.parse.urlsplit(base_url) valid_url = (parsed.scheme in {"http", "https"} and parsed.hostname and not parsed.username and not parsed.password and not parsed.query and not parsed.fragment) except ValueError: valid_url = False if not valid_url: raise SnapshotUnavailable("hub URL must be HTTP(S), without credentials, query or fragment") if not math.isfinite(max_age_hours) or max_age_hours <= 0: raise SnapshotUnavailable("maximum index age must be a positive finite number of hours") request = urllib.request.Request( url, headers={"Accept": "application/json", "User-Agent": "reuse-surface/0.1"} ) try: with urllib.request.urlopen(request, timeout=30) as response: if response.status != 200: raise SnapshotUnavailable(f"hosted index returned HTTP {response.status}") if response.headers.get("X-Federation-Warnings"): raise SnapshotUnavailable("hosted index reports federation warnings; inspect its sources") raw = response.read(MAX_SNAPSHOT_BYTES + 1) except urllib.error.HTTPError as exc: raise SnapshotUnavailable(f"hosted index returned HTTP {exc.code}") from exc except (urllib.error.URLError, OSError, ValueError) as exc: if isinstance(exc, SnapshotUnavailable): raise raise SnapshotUnavailable("hosted index could not be fetched; no local fallback used") from exc if len(raw) > MAX_SNAPSHOT_BYTES: raise SnapshotUnavailable("hosted index exceeds the 5 MiB response limit") try: payload = json.loads(raw) except (ValueError, UnicodeError) as exc: raise SnapshotUnavailable("hosted index is not valid JSON") from exc if not isinstance(payload, dict) or payload.get("stale") is not False: raise SnapshotUnavailable("hosted index is stale or its freshness signal is missing") now = datetime.now(timezone.utc) try: composed = datetime.fromisoformat(payload["composed_at"]) updated = payload["updated"] date.fromisoformat(updated) if composed.tzinfo is None: raise ValueError("timezone missing") except (KeyError, TypeError, ValueError) as exc: raise SnapshotUnavailable("hosted index has invalid composition metadata") from exc age = (now - composed).total_seconds() if age < -timedelta(minutes=5).total_seconds() or age > max_age_hours * 3600: raise SnapshotUnavailable("hosted index composition is outside the accepted age window") capabilities = payload.get("capabilities") if not isinstance(capabilities, list) or not capabilities: raise SnapshotUnavailable("hosted index has no usable capability collection") ids: set[str] = set() for item in capabilities: if (not isinstance(item, dict) or not isinstance(item.get("id"), str) or not item["id"].strip() or item["id"] in ids or not isinstance(item.get("name"), str) or not isinstance(item.get("summary"), str) or not isinstance(item.get("tags", []), list) or any(not isinstance(tag, str) for tag in item.get("tags", [])) or any(item.get(key) is not None and not isinstance(item[key], str) for key in ("owner", "vector"))): raise SnapshotUnavailable("hosted index contains malformed or duplicate capabilities") ids.add(item["id"]) provenance = { "kind": "hosted", "url": url, "fetched_at": now.isoformat(), "composed_at": payload["composed_at"], "capability_count": len(capabilities), "sha256": hashlib.sha256(raw).hexdigest(), "digest_format": "response-bytes", "max_age_hours": max_age_hours, "stale": False, "federation_warnings": False, } return capabilities, updated, provenance