#!/usr/bin/env python3 """Verify a local canon-lineage record against an authoritative checkout.""" from __future__ import annotations import argparse import hashlib import json import subprocess import sys from pathlib import Path from typing import Any, Mapping import yaml LINEAGE_STANDARD = "canon-lineage_v0.1" class LineageError(ValueError): """The lineage manifest or canonical artifact is inconsistent.""" def _required(mapping: Mapping[str, Any], key: str) -> Any: value = mapping.get(key) if value is None or value == "": raise LineageError(f"lineage.{key} is required") return value def _sha256(content: bytes) -> str: return hashlib.sha256(content).hexdigest() def _frontmatter(content: bytes) -> dict[str, Any]: text = content.decode() if not text.startswith("---\n") or "\n---\n" not in text[4:]: raise LineageError("canonical artifact requires YAML frontmatter") raw = text.split("\n---\n", 1)[0][4:] value = yaml.safe_load(raw) or {} if not isinstance(value, dict): raise LineageError("canonical frontmatter must be a mapping") return value def check_lineage( manifest: Any, canon_root: Path, *, verify_revision: bool = True, ) -> dict[str, Any]: if not isinstance(manifest, Mapping): raise LineageError("lineage manifest must be a mapping") if manifest.get("standard") != LINEAGE_STANDARD: raise LineageError(f"lineage.standard must be {LINEAGE_STANDARD}") relative = Path(str(_required(manifest, "canonical_path"))) if relative.is_absolute() or ".." in relative.parts: raise LineageError("canonical_path must stay below canon_root") revision = str(_required(manifest, "canonical_revision")) expected_hash = str(_required(manifest, "canonical_sha256")) expected_status = str(_required(manifest, "canonical_status")) canonical_path = canon_root / relative try: content = canonical_path.read_bytes() except OSError as exc: raise LineageError(f"cannot read canonical artifact: {exc}") from exc actual_hash = _sha256(content) frontmatter = _frontmatter(content) errors: list[str] = [] if actual_hash != expected_hash: errors.append( f"canonical content hash changed: expected {expected_hash}, got {actual_hash}" ) if str(frontmatter.get("status")) != expected_status: errors.append( "canonical lifecycle changed: " f"expected {expected_status}, got {frontmatter.get('status')}" ) revision_hash = None if verify_revision: completed = subprocess.run( ["git", "-C", str(canon_root), "show", f"{revision}:{relative.as_posix()}"], check=False, capture_output=True, ) if completed.returncode != 0: errors.append( f"cannot read canonical artifact at revision {revision}: " + completed.stderr.decode().strip() ) else: revision_hash = _sha256(completed.stdout) if revision_hash != expected_hash: errors.append( f"revision {revision} content does not match canonical_sha256" ) return { "ok": not errors, "artifact": manifest.get("artifact"), "publication_owner": manifest.get("publication_owner"), "canonical_path": relative.as_posix(), "canonical_revision": revision, "canonical_status": frontmatter.get("status"), "canonical_sha256": actual_hash, "revision_sha256": revision_hash, "errors": errors, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--manifest", required=True, type=Path) parser.add_argument("--canon-root", required=True, type=Path) args = parser.parse_args() try: manifest = yaml.safe_load(args.manifest.read_text()) or {} result = check_lineage(manifest, args.canon_root) except (OSError, yaml.YAMLError, LineageError) as exc: result = {"ok": False, "errors": [str(exc)]} print(json.dumps(result, indent=2, sort_keys=True)) return 0 if result["ok"] else 1 if __name__ == "__main__": sys.exit(main())