"""muse plumbing verify-pack — verify the integrity of a PackBundle. Reads a PackBundle msgpack binary from stdin (or ``--file``) and performs three levels of integrity checking: 1. **Object integrity** — every ``objects`` entry has its SHA-256 recomputed from the raw ``content`` bytes. The digest must match the declared ``object_id``. 2. **Snapshot consistency** — every snapshot in the bundle references only object IDs that are either in the bundle itself or already present in the local store. Orphaned manifest entries are reported as failures. 3. **Commit consistency** — every commit in the bundle references a ``snapshot_id`` that is either in the bundle or already in the local store. Pipe from ``pack-objects`` to validate before sending to a remote:: muse plumbing pack-objects | muse plumbing verify-pack Or verify a saved bundle file:: muse plumbing verify-pack --file bundle.muse Quick structural inspection without full hash verification:: muse plumbing verify-pack --stat --file bundle.muse Output (JSON, default):: { "objects_checked": 42, "snapshots_checked": 5, "commits_checked": 5, "all_ok": true, "failures": [] } With failures:: { "objects_checked": 42, "snapshots_checked": 5, "commits_checked": 5, "all_ok": false, "failures": [ {"kind": "object", "id": "", "error": "hash mismatch"}, {"kind": "snapshot", "id": "", "error": "missing object: "} ] } Stat-only output (``--stat``):: {"objects": 42, "snapshots": 5, "commits": 5} Plumbing contract ----------------- - Exit 0: bundle is fully intact; or ``--stat`` completed. - Exit 1: one or more integrity failures; malformed msgpack input; bad format. - Exit 3: I/O error reading stdin or the bundle file. Agent use --------- Verify before pushing:: muse plumbing pack-objects "$TIP" \\ | muse plumbing verify-pack --json \\ | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d['all_ok'] else 1)" Inspect bundle structure without hashing (fast):: muse plumbing verify-pack --stat --file bundle.muse --json Quiet CI gate — fails pipeline if bundle is corrupt:: muse plumbing pack-objects "$TIP" --file bundle.muse muse plumbing verify-pack --quiet --file bundle.muse """ from __future__ import annotations import argparse import hashlib import json import logging import pathlib import sys from typing import TypedDict from muse.core.errors import ExitCode from muse.core.object_store import read_object from muse.core.repo import require_repo from muse.core.store import MAX_PACK_MSGPACK_BYTES, read_snapshot, safe_unpackb from muse.core.validation import sanitize_display, validate_object_id logger = logging.getLogger(__name__) _FORMAT_CHOICES = ("json", "text") class _Failure(TypedDict): kind: str id: str error: str class _VerifyPackResult(TypedDict): objects_checked: int snapshots_checked: int commits_checked: int all_ok: bool failures: list[_Failure] class _StatResult(TypedDict): objects: int snapshots: int commits: int def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the verify-pack subcommand.""" parser = subparsers.add_parser( "verify-pack", help="Verify the integrity of a PackBundle.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--file", "-i", default=None, dest="bundle_file", metavar="PATH", help="Path to a PackBundle file. Reads from stdin when omitted.", ) parser.add_argument( "--stat", action="store_true", dest="stat_only", help=( "Fast structural inspection: count objects, snapshots, and commits " "without computing any hashes. Exits 0 on valid msgpack structure." ), ) parser.add_argument( "--quiet", "-q", action="store_true", help="No output. Exit 0 if all checks pass, exit 1 otherwise.", ) parser.add_argument( "--no-local", "-L", action="store_true", dest="skip_local_check", help="Skip checking the local store for missing snapshot/commit refs.", ) parser.add_argument( "--format", "-f", dest="fmt", default="json", metavar="FORMAT", help="Output format: json or text. (default: json)", ) parser.add_argument( "--json", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) parser.set_defaults(func=run) def run(args: argparse.Namespace) -> None: """Verify the integrity of a PackBundle. Reads a PackBundle from stdin or ``--file`` and checks: - Every object's payload re-hashes to its declared SHA-256 ID. - Every snapshot's manifest references objects present in the bundle or the local store. - Every commit's snapshot ID is present in the bundle or the local store. Use ``--stat`` for a fast structural count without hash verification. """ fmt: str = args.fmt bundle_file: str | None = args.bundle_file quiet: bool = args.quiet skip_local_check: bool = args.skip_local_check stat_only: bool = args.stat_only if fmt not in _FORMAT_CHOICES: print( json.dumps( {"error": f"Unknown format {fmt!r}. Valid: {', '.join(_FORMAT_CHOICES)}"} ), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # Read bundle bytes. if bundle_file is not None: try: raw_bytes = pathlib.Path(bundle_file).read_bytes() except OSError as exc: print( json.dumps({"error": f"Cannot read file: {sanitize_display(str(exc))}"}), file=sys.stderr, ) raise SystemExit(ExitCode.INTERNAL_ERROR) else: try: raw_bytes = sys.stdin.buffer.read() except OSError as exc: print( json.dumps({"error": f"Cannot read stdin: {exc}"}), file=sys.stderr, ) raise SystemExit(ExitCode.INTERNAL_ERROR) try: bundle = safe_unpackb( raw_bytes, context="pack input", max_bytes=MAX_PACK_MSGPACK_BYTES, allow_binary=True, ) except (ValueError, TypeError, Exception) as exc: print(json.dumps({"error": f"Invalid msgpack: {exc}"}), file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if not isinstance(bundle, dict): print( json.dumps({"error": "PackBundle must be a msgpack map."}), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # --stat: fast structural count — no hash verification. if stat_only: objects_raw = bundle.get("objects", []) snapshots_raw = bundle.get("snapshots", []) commits_raw = bundle.get("commits", []) stat_result: _StatResult = { "objects": len(objects_raw) if isinstance(objects_raw, list) else 0, "snapshots": len(snapshots_raw) if isinstance(snapshots_raw, list) else 0, "commits": len(commits_raw) if isinstance(commits_raw, list) else 0, } if fmt == "text": print( f"objects={stat_result['objects']} " f"snapshots={stat_result['snapshots']} " f"commits={stat_result['commits']}" ) else: print(json.dumps(stat_result)) return # We need the repo root for local-store checks (optional). root: pathlib.Path | None = require_repo() if not skip_local_check else None failures: list[_Failure] = [] # ----------------------------------------------------------------------- # 1. Object integrity — re-hash each payload. # ----------------------------------------------------------------------- bundle_object_ids: set[str] = set() objects_raw = bundle.get("objects", []) if not isinstance(objects_raw, list): print( json.dumps({"error": "'objects' field must be a list."}), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) for entry in objects_raw: if not isinstance(entry, dict): failures.append( _Failure(kind="object", id="(unknown)", error="entry is not a dict") ) continue oid = entry.get("object_id", "") content = entry.get("content") if not isinstance(oid, str) or not isinstance(content, (bytes, bytearray)): failures.append( _Failure( kind="object", id="(unknown)", error="missing or invalid object_id / content fields", ) ) continue # Validate object ID format before embedding it anywhere. try: validate_object_id(oid) except ValueError: failures.append( _Failure( kind="object", id="(invalid)", error=f"object_id is not a valid 64-char hex SHA-256: {oid[:24]!r}", ) ) continue # Hash without copying: msgpack returns bytes; hashlib accepts bytes/bytearray. actual = hashlib.sha256(content).hexdigest() if actual != oid: failures.append( _Failure( kind="object", id=oid, error=( f"hash mismatch: declared {oid[:12]}... " f"recomputed {actual[:12]}..." ), ) ) else: bundle_object_ids.add(oid) objects_checked = len(objects_raw) # ----------------------------------------------------------------------- # 2. Snapshot consistency — manifest entries must be present. # ----------------------------------------------------------------------- bundle_snapshot_ids: set[str] = set() snapshots_raw = bundle.get("snapshots", []) if not isinstance(snapshots_raw, list): print( json.dumps({"error": "'snapshots' field must be a list."}), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) for snap_entry in snapshots_raw: if not isinstance(snap_entry, dict): failures.append( _Failure( kind="snapshot", id="(unknown)", error="snapshot entry is not a dict" ) ) continue snap_id = snap_entry.get("snapshot_id", "") if not isinstance(snap_id, str): failures.append( _Failure(kind="snapshot", id="(unknown)", error="missing snapshot_id") ) continue bundle_snapshot_ids.add(snap_id) manifest = snap_entry.get("manifest", {}) if not isinstance(manifest, dict): continue for path, obj_id in manifest.items(): if not isinstance(obj_id, str): continue if obj_id in bundle_object_ids: continue # Check local store if allowed. Use read_object (not has_object) # so that a bit-flipped on-disk object is caught here rather than # silently declared "present". read_object re-hashes every byte # and raises OSError on any mismatch. if root is not None: try: local_content = read_object(root, obj_id) except OSError as exc: # Object exists on disk but fails the SHA-256 integrity check. failures.append( _Failure( kind="object", id=obj_id, error=( f"local store object {obj_id[:12]}... failed " f"SHA-256 integrity check: {exc}" ), ) ) continue if local_content is not None: continue # present and verified failures.append( _Failure( kind="snapshot", id=snap_id, error=( f"manifest path {path!r} references " f"missing object {obj_id[:12]}..." ), ) ) snapshots_checked = len(snapshots_raw) # ----------------------------------------------------------------------- # 3. Commit consistency — snapshot_id must be resolvable. # ----------------------------------------------------------------------- commits_raw = bundle.get("commits", []) if not isinstance(commits_raw, list): print( json.dumps({"error": "'commits' field must be a list."}), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) for commit_entry in commits_raw: if not isinstance(commit_entry, dict): failures.append( _Failure( kind="commit", id="(unknown)", error="commit entry is not a dict" ) ) continue commit_id = commit_entry.get("commit_id", "") snap_id = commit_entry.get("snapshot_id", "") if not isinstance(commit_id, str) or not isinstance(snap_id, str): failures.append( _Failure( kind="commit", id="(unknown)", error="missing commit_id or snapshot_id", ) ) continue if snap_id in bundle_snapshot_ids: continue if root is not None and read_snapshot(root, snap_id) is not None: continue if not skip_local_check: failures.append( _Failure( kind="commit", id=commit_id, error=f"references snapshot {snap_id[:12]}... not in bundle or local store", ) ) commits_checked = len(commits_raw) all_ok = len(failures) == 0 if quiet: raise SystemExit(0 if all_ok else ExitCode.USER_ERROR) if fmt == "text": print( f"objects={objects_checked} snapshots={snapshots_checked} " f"commits={commits_checked} all_ok={all_ok}" ) for f in failures: print( f" FAIL [{sanitize_display(f['kind'])}] " f"{sanitize_display(f['id'][:16])}... " f"{sanitize_display(f['error'])}" ) if not all_ok: raise SystemExit(ExitCode.USER_ERROR) return result: _VerifyPackResult = { "objects_checked": objects_checked, "snapshots_checked": snapshots_checked, "commits_checked": commits_checked, "all_ok": all_ok, "failures": failures, } print(json.dumps(result)) if not all_ok: raise SystemExit(ExitCode.USER_ERROR)