Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0291a-1e87-7151-9934-fcbfe3f65eb1
125 lines
4.2 KiB
Python
125 lines
4.2 KiB
Python
#!/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())
|