#!/usr/bin/env python3 """Validate the declared S1 backup membership and public-recipient policy.""" from __future__ import annotations import argparse import json import re from pathlib import Path, PurePosixPath from typing import Any import yaml SCHEMA_VERSION = "1.0" AGE_RECIPIENT_RE = re.compile(r"^age1[023456789acdefghjklmnpqrstuvwxyz]{20,}$") REQUIRED_EXCLUSIONS = { "/etc/ssl/private", "/home", "/root", "/var/lib/kubelet", "/var/lib/rancher", } FORBIDDEN_PARTS = {"id_rsa", "id_ed25519", "keys.txt", "private", "secrets"} class BackupContractError(ValueError): """The backup declaration is ambiguous, unsafe, or inconsistent.""" def _absolute_path(value: Any, label: str) -> str: if not isinstance(value, str) or not value.startswith("/"): raise BackupContractError(f"{label} must be an absolute path") path = PurePosixPath(value) if ".." in path.parts or str(path) != value.rstrip("/"): raise BackupContractError(f"{label} must be normalized without traversal") return str(path) def _sops_recipients(path: Path) -> set[str]: try: payload = yaml.safe_load(path.read_text(encoding="utf-8")) except (OSError, yaml.YAMLError) as exc: raise BackupContractError(f"cannot read recipient policy {path}: {exc}") from exc recipients: set[str] = set() for rule in payload.get("creation_rules", []) if isinstance(payload, dict) else []: for group in rule.get("key_groups", []) if isinstance(rule, dict) else []: age = group.get("age", []) if isinstance(group, dict) else [] if isinstance(age, list): recipients.update(item.strip() for item in age if isinstance(item, str)) if not recipients: raise BackupContractError("recipient policy contains no age recipients") return recipients def validate_spec(payload: Any, *, policy_path: Path) -> dict[str, Any]: if not isinstance(payload, dict): raise BackupContractError("backup declaration must be a YAML object") errors: list[str] = [] if str(payload.get("schema_version")) != SCHEMA_VERSION: errors.append(f"schema_version must be {SCHEMA_VERSION}") try: backup_root = _absolute_path(payload.get("backup_root"), "backup_root") except BackupContractError as exc: errors.append(str(exc)) backup_root = "" if backup_root in {"", "/", "/etc", "/home", "/root", "/var"}: errors.append("backup_root must be a dedicated bounded directory") recipients = payload.get("recipients") if not isinstance(recipients, list) or not recipients or not all( isinstance(item, str) and AGE_RECIPIENT_RE.fullmatch(item) for item in recipients ): errors.append("recipients must be a non-empty list of age public recipients") recipients = [] elif len(set(recipients)) != len(recipients): errors.append("recipients contains duplicates") try: policy_recipients = _sops_recipients(policy_path) if set(recipients) != policy_recipients: errors.append("backup recipients must exactly match the SOPS public-recipient policy") except BackupContractError as exc: errors.append(str(exc)) retention = payload.get("retention") if not isinstance(retention, dict): errors.append("retention must be an object") else: for key, low, high in ( ("keep_complete_bundles", 2, 90), ("max_total_bytes", 1048576, 10737418240), ("freshness_hours", 1, 168), ): value = retention.get(key) if not isinstance(value, int) or isinstance(value, bool) or not low <= value <= high: errors.append(f"retention.{key} must be an integer from {low} to {high}") offsite = payload.get("offsite") if not isinstance(offsite, dict) or not isinstance( offsite.get("required_before_prune"), bool ): errors.append("offsite must declare boolean required_before_prune and receipt_root") else: try: receipt_root = _absolute_path(offsite.get("receipt_root"), "offsite.receipt_root") if backup_root and receipt_root != backup_root + "/offsite-receipts": errors.append("offsite.receipt_root must be the bounded backup-root receipt directory") except BackupContractError as exc: errors.append(str(exc)) exclusions = payload.get("excluded_prefixes") normalized_exclusions: list[str] = [] if not isinstance(exclusions, list): errors.append("excluded_prefixes must be a list") else: for index, value in enumerate(exclusions): try: normalized_exclusions.append( _absolute_path(value, f"excluded_prefixes[{index}]") ) except BackupContractError as exc: errors.append(str(exc)) missing = sorted(REQUIRED_EXCLUSIONS - set(normalized_exclusions)) if missing: errors.append(f"excluded_prefixes omits governed exclusions {', '.join(missing)}") artifacts = payload.get("artifacts") if not isinstance(artifacts, dict) or set(artifacts) != {"os-config", "packages"}: errors.append("artifacts must contain exactly os-config and packages") artifacts = {} os_config = artifacts.get("os-config", {}) paths = os_config.get("paths") if isinstance(os_config, dict) else None declared: list[str] = [] if not isinstance(paths, list) or not paths: errors.append("artifacts.os-config.paths must be a non-empty list") else: for index, item in enumerate(paths): label = f"artifacts.os-config.paths[{index}]" if not isinstance(item, dict) or not isinstance(item.get("required"), bool): errors.append(f"{label} must contain path and boolean required") continue expected_mode = item.get("expected_mode") if item["required"] and not ( isinstance(expected_mode, str) and re.fullmatch(r"0[0-7]{3}", expected_mode) ): errors.append(f"{label} requires expected_mode as four octal digits") if not item["required"] and expected_mode is not None: errors.append(f"{label}.expected_mode is allowed only for required paths") try: path = _absolute_path(item.get("path"), f"{label}.path") except BackupContractError as exc: errors.append(str(exc)) continue declared.append(path) lowered = {part.lower() for part in PurePosixPath(path).parts} if lowered & FORBIDDEN_PARTS: errors.append(f"{label}.path selects secret/private-key shaped content") if any(path == prefix or path.startswith(prefix + "/") for prefix in normalized_exclusions): errors.append(f"{label}.path falls under excluded prefix") if len(set(declared)) != len(declared): errors.append("artifacts.os-config.paths contains duplicates") for left in declared: for right in declared: if left != right and right.startswith(left + "/"): errors.append(f"declared paths overlap: {left} contains {right}") if isinstance(os_config, dict) and os_config.get("format") != "tar.gz": errors.append("artifacts.os-config.format must be tar.gz") packages = artifacts.get("packages", {}) if not isinstance(packages, dict) or packages.get("format") != "dpkg-selections": errors.append("artifacts.packages.format must be dpkg-selections") elif packages.get("command") != ["dpkg", "--get-selections"]: errors.append("artifacts.packages.command must be the fixed dpkg selection argv") if errors: raise BackupContractError("backup contract failed:\n- " + "\n- ".join(errors)) return payload def load_spec(path: Path) -> dict[str, Any]: try: payload = yaml.safe_load(path.read_text(encoding="utf-8")) except (OSError, yaml.YAMLError) as exc: raise BackupContractError(f"cannot read {path}: {exc}") from exc policy_value = payload.get("recipient_policy") if isinstance(payload, dict) else None if not isinstance(policy_value, str) or not policy_value: raise BackupContractError("recipient_policy must name a repository-relative file") policy_relative = PurePosixPath(policy_value) if policy_relative.is_absolute() or ".." in policy_relative.parts: raise BackupContractError("recipient_policy must be a normalized repository-relative path") root = path.resolve().parent.parent return validate_spec(payload, policy_path=root / policy_relative) def declaration_sha256(path: Path) -> str: import hashlib return hashlib.sha256(path.read_bytes()).hexdigest() def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("spec", nargs="?", type=Path, default=Path("spec/s1-backup.yaml")) args = parser.parse_args() payload = load_spec(args.spec) print( json.dumps( { "artifacts": sorted(payload["artifacts"]), "declaration_sha256": declaration_sha256(args.spec), "ok": True, "path_count": len(payload["artifacts"]["os-config"]["paths"]), "recipients": len(payload["recipients"]), }, sort_keys=True, ) ) return 0 if __name__ == "__main__": raise SystemExit(main())