"""Release catalog inspection.""" from __future__ import annotations import hashlib import json from pathlib import Path from urllib.error import HTTPError, URLError from .http_fetch import fetch_http from .model import CatalogSnapshot from .workspace import website_root DEFAULT_PUBLIC_BASE_URL = "https://govoplan.add-ideas.de" def collect_catalog_snapshot( *, workspace_root: Path, channel: str = "stable", public_base_url: str = DEFAULT_PUBLIC_BASE_URL, check_public: bool = False, ) -> CatalogSnapshot: web_root = website_root(workspace_root) catalog_path = web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json" keyring_path = web_root / "public" / "catalogs" / "v1" / "keyring.json" public_catalog_url = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json" public_keyring_url = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json" catalog_version: str | None = None core_release_version: str | None = None module_count = 0 signature_count = 0 generated_at: str | None = None expires_at: str | None = None catalog_error: str | None = None local_catalog_hash: str | None = None if catalog_path.exists(): try: payload = json.loads(catalog_path.read_text(encoding="utf-8")) local_catalog_hash = canonical_hash(payload) parsed = parse_catalog_payload(payload) catalog_version = parsed["catalog_version"] core_release_version = parsed["core_release_version"] module_count = parsed["module_count"] or 0 signature_count = parsed["signature_count"] or 0 generated_at = parsed["generated_at"] expires_at = parsed["expires_at"] except Exception as exc: # noqa: BLE001 - shown in local operator UI. catalog_error = str(exc) key_count = 0 keyring_error: str | None = None local_keyring_hash: str | None = None if keyring_path.exists(): try: payload = json.loads(keyring_path.read_text(encoding="utf-8")) local_keyring_hash = canonical_hash(payload) keys = payload.get("keys") if isinstance(payload, dict) else None key_count = len(keys) if isinstance(keys, list) else 0 except Exception as exc: # noqa: BLE001 - shown in local operator UI. keyring_error = str(exc) public_catalog_exists: bool | None = None public_keyring_exists: bool | None = None public_core_release_version: str | None = None public_module_count: int | None = None public_signature_count: int | None = None public_key_count: int | None = None public_catalog_hash: str | None = None public_keyring_hash: str | None = None public_catalog_error: str | None = None public_keyring_error: str | None = None if check_public: public_catalog = fetch_json(public_catalog_url) if public_catalog["ok"]: public_catalog_exists = True payload = public_catalog["payload"] public_catalog_hash = canonical_hash(payload) parsed = parse_catalog_payload(payload) public_core_release_version = parsed["core_release_version"] public_module_count = parsed["module_count"] public_signature_count = parsed["signature_count"] else: public_catalog_exists = False public_catalog_error = str(public_catalog["error"]) public_keyring = fetch_json(public_keyring_url) if public_keyring["ok"]: public_keyring_exists = True payload = public_keyring["payload"] public_keyring_hash = canonical_hash(payload) keys = payload.get("keys") if isinstance(payload, dict) else None public_key_count = len(keys) if isinstance(keys, list) else 0 else: public_keyring_exists = False public_keyring_error = str(public_keyring["error"]) return CatalogSnapshot( channel=channel, website_path=str(web_root), catalog_path=str(catalog_path), catalog_exists=catalog_path.exists(), keyring_path=str(keyring_path), keyring_exists=keyring_path.exists(), catalog_version=catalog_version, core_release_version=core_release_version, module_count=module_count, signature_count=signature_count, key_count=key_count, generated_at=generated_at, expires_at=expires_at, catalog_error=catalog_error, keyring_error=keyring_error, local_catalog_hash=local_catalog_hash, local_keyring_hash=local_keyring_hash, public_catalog_url=public_catalog_url, public_keyring_url=public_keyring_url, public_checked=check_public, public_catalog_exists=public_catalog_exists, public_keyring_exists=public_keyring_exists, public_core_release_version=public_core_release_version, public_module_count=public_module_count, public_signature_count=public_signature_count, public_key_count=public_key_count, public_catalog_hash=public_catalog_hash, public_keyring_hash=public_keyring_hash, public_catalog_error=public_catalog_error, public_keyring_error=public_keyring_error, catalog_matches_public=compare_hashes(local_catalog_hash, public_catalog_hash) if check_public else None, keyring_matches_public=compare_hashes(local_keyring_hash, public_keyring_hash) if check_public else None, ) def parse_catalog_payload(payload: object) -> dict[str, str | int | None]: if not isinstance(payload, dict): return { "catalog_version": None, "core_release_version": None, "module_count": 0, "signature_count": 0, "generated_at": None, "expires_at": None, } core_release = payload.get("core_release") modules = payload.get("modules") signatures = payload.get("signatures") return { "catalog_version": string_or_none(payload.get("catalog_version")), "core_release_version": string_or_none(core_release.get("version")) if isinstance(core_release, dict) else None, "module_count": len(modules) if isinstance(modules, list) else 0, "signature_count": len(signatures) if isinstance(signatures, list) else 0, "generated_at": string_or_none(payload.get("generated_at")), "expires_at": string_or_none(payload.get("expires_at")), } def fetch_json(url: str) -> dict[str, object]: try: response = fetch_http( url, timeout=10, label="Public release catalog URL", headers={"User-Agent": "GovOPlaN release-console"}, ) return {"ok": True, "payload": json.loads(response.text())} except (HTTPError, URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc: return {"ok": False, "error": str(exc)} def canonical_hash(payload: object) -> str: canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") return hashlib.sha256(canonical).hexdigest() def compare_hashes(local_hash: str | None, public_hash: str | None) -> bool | None: if local_hash is None or public_hash is None: return None return local_hash == public_hash def string_or_none(value: object) -> str | None: return value if isinstance(value, str) else None