"""muse plumbing hash-object — compute the SHA-256 object ID of a file. Computes the content-addressed object ID (SHA-256 hex digest) of a file or stdin stream. With ``--write`` the object is also stored in ``.muse/objects/`` so it can be referenced by future snapshots and commits. Output (JSON, default):: {"object_id": "", "stored": false} Output (--format text):: Plumbing contract ----------------- - Exit 0: hash computed successfully. - Exit 1: file not found, path is a directory, or unknown --format value. - Exit 3: I/O error writing to the store, or integrity check failed. Agent use --------- Read from stdin to avoid temp-file overhead:: echo -n "hello" | muse plumbing hash-object --stdin cat large_file.bin | muse plumbing hash-object --stdin --write """ from __future__ import annotations import argparse import hashlib import json import logging import pathlib import sys from muse.core.errors import ExitCode from muse.core.object_store import write_object, write_object_from_path from muse.core.repo import find_repo_root from muse.core.snapshot import hash_file from muse.core.validation import sanitize_display logger = logging.getLogger(__name__) _FORMAT_CHOICES = ("json", "text") def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the hash-object subcommand.""" parser = subparsers.add_parser( "hash-object", help="SHA-256 a file; optionally store it in the object store.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "path", type=pathlib.Path, nargs="?", default=None, help="File to hash. Omit when using --stdin.", ) parser.add_argument( "--stdin", action="store_true", help="Read content from stdin instead of a file path.", ) parser.add_argument( "--write", "-w", action="store_true", help="Store the object in .muse/objects/.", ) 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 _hash_bytes(data: bytes) -> str: """Return the SHA-256 hex digest of *data*.""" return hashlib.sha256(data).hexdigest() def run(args: argparse.Namespace) -> None: """Compute the SHA-256 object ID of a file or stdin stream. Analogous to ``git hash-object``. The object ID is deterministic — identical bytes always produce the same ID. Pass ``--write`` to also store the object so it can be referenced by future ``muse plumbing commit-tree`` calls. Pass ``--stdin`` to read from stdin — useful for agents that want to hash content without writing a temporary file first. """ fmt: str = args.fmt path: pathlib.Path | None = args.path use_stdin: bool = args.stdin write: bool = args.write if fmt not in _FORMAT_CHOICES: print( f"❌ Unknown format {fmt!r}. Valid choices: {', '.join(_FORMAT_CHOICES)}", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if use_stdin and path is not None: print("❌ Cannot use both a path argument and --stdin.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if not use_stdin and path is None: print("❌ Provide a file path or use --stdin.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) # ── stdin mode ─────────────────────────────────────────────────────────── if use_stdin: data = sys.stdin.buffer.read() object_id = _hash_bytes(data) stored = False if write: root = find_repo_root(pathlib.Path.cwd()) if root is None: print( "❌ Not inside a Muse repository. Cannot write object.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) try: stored = write_object(root, object_id, data) except ValueError as exc: print( f"❌ Integrity check failed: {sanitize_display(str(exc))}", file=sys.stderr, ) raise SystemExit(ExitCode.INTERNAL_ERROR) except OSError as exc: print( f"❌ Failed to write object: {sanitize_display(str(exc))}", file=sys.stderr, ) raise SystemExit(ExitCode.INTERNAL_ERROR) _emit(fmt, object_id, stored) return # ── file mode ──────────────────────────────────────────────────────────── assert path is not None # Null bytes are not valid in POSIX paths; pathlib raises ValueError on # Python ≥3.12 but the explicit check is clearer and version-independent. try: _path_str = str(path) except Exception: _path_str = repr(path) if "\x00" in _path_str: print("❌ Path contains a null byte.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if not path.exists(): print( f"❌ Path does not exist: {sanitize_display(str(path))}", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if path.is_dir(): print( f"❌ Path is a directory, not a file: {sanitize_display(str(path))}", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # Reject special files (block/char devices, named pipes, sockets). # These would either block forever (FIFO), return device-specific data # (block/char), or fail unpredictably (socket). Only regular files and # symlinks to regular files are accepted — consistent with git hash-object. if not path.is_file(): print( f"❌ Path is not a regular file (device, pipe, or socket): " f"{sanitize_display(str(path))}", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) object_id = hash_file(path) stored = False if write: root = find_repo_root(pathlib.Path.cwd()) if root is None: print("❌ Not inside a Muse repository. Cannot write object.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) try: # write_object_from_path streams at 64 KiB chunks — heap-safe for # arbitrarily large blobs. Re-verifies hash before writing to catch # any TOCTOU race between hash_file() above and the store write. stored = write_object_from_path(root, object_id, path) except ValueError as exc: print( f"❌ Integrity check failed (file may have changed during write): " f"{sanitize_display(str(exc))}", file=sys.stderr, ) raise SystemExit(ExitCode.INTERNAL_ERROR) except OSError as exc: print( f"❌ Failed to write object: {sanitize_display(str(exc))}", file=sys.stderr, ) raise SystemExit(ExitCode.INTERNAL_ERROR) _emit(fmt, object_id, stored) def _emit(fmt: str, object_id: str, stored: bool) -> None: """Print the result in the requested format.""" if fmt == "text": print(object_id) else: print(json.dumps({"object_id": object_id, "stored": stored}))