hash_object.py python
230 lines 7.8 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago
1 """muse plumbing hash-object — compute the SHA-256 object ID of a file.
2
3 Computes the content-addressed object ID (SHA-256 hex digest) of a file or
4 stdin stream. With ``--write`` the object is also stored in ``.muse/objects/``
5 so it can be referenced by future snapshots and commits.
6
7 Output (JSON, default)::
8
9 {"object_id": "<sha256>", "stored": false}
10
11 Output (--format text)::
12
13 <sha256>
14
15 Plumbing contract
16 -----------------
17
18 - Exit 0: hash computed successfully.
19 - Exit 1: file not found, path is a directory, or unknown --format value.
20 - Exit 3: I/O error writing to the store, or integrity check failed.
21
22 Agent use
23 ---------
24
25 Read from stdin to avoid temp-file overhead::
26
27 echo -n "hello" | muse plumbing hash-object --stdin
28 cat large_file.bin | muse plumbing hash-object --stdin --write
29 """
30
31 from __future__ import annotations
32
33 import argparse
34 import hashlib
35 import json
36 import logging
37 import pathlib
38 import sys
39
40 from muse.core.errors import ExitCode
41 from muse.core.object_store import write_object, write_object_from_path
42 from muse.core.repo import find_repo_root
43 from muse.core.snapshot import hash_file
44 from muse.core.validation import sanitize_display
45
46 logger = logging.getLogger(__name__)
47
48 _FORMAT_CHOICES = ("json", "text")
49
50
51 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
52 """Register the hash-object subcommand."""
53 parser = subparsers.add_parser(
54 "hash-object",
55 help="SHA-256 a file; optionally store it in the object store.",
56 description=__doc__,
57 formatter_class=argparse.RawDescriptionHelpFormatter,
58 )
59 parser.add_argument(
60 "path",
61 type=pathlib.Path,
62 nargs="?",
63 default=None,
64 help="File to hash. Omit when using --stdin.",
65 )
66 parser.add_argument(
67 "--stdin",
68 action="store_true",
69 help="Read content from stdin instead of a file path.",
70 )
71 parser.add_argument(
72 "--write", "-w",
73 action="store_true",
74 help="Store the object in .muse/objects/.",
75 )
76 parser.add_argument(
77 "--format", "-f",
78 dest="fmt",
79 default="json",
80 metavar="FORMAT",
81 help="Output format: json or text. (default: json)",
82 )
83 parser.add_argument(
84 "--json", action="store_const", const="json", dest="fmt",
85 help="Shorthand for --format json.",
86 )
87 parser.set_defaults(func=run)
88
89
90 def _hash_bytes(data: bytes) -> str:
91 """Return the SHA-256 hex digest of *data*."""
92 return hashlib.sha256(data).hexdigest()
93
94
95 def run(args: argparse.Namespace) -> None:
96 """Compute the SHA-256 object ID of a file or stdin stream.
97
98 Analogous to ``git hash-object``. The object ID is deterministic —
99 identical bytes always produce the same ID. Pass ``--write`` to also
100 store the object so it can be referenced by future ``muse plumbing
101 commit-tree`` calls.
102
103 Pass ``--stdin`` to read from stdin — useful for agents that want to hash
104 content without writing a temporary file first.
105 """
106 fmt: str = args.fmt
107 path: pathlib.Path | None = args.path
108 use_stdin: bool = args.stdin
109 write: bool = args.write
110
111 if fmt not in _FORMAT_CHOICES:
112 print(
113 f"❌ Unknown format {fmt!r}. Valid choices: {', '.join(_FORMAT_CHOICES)}",
114 file=sys.stderr,
115 )
116 raise SystemExit(ExitCode.USER_ERROR)
117
118 if use_stdin and path is not None:
119 print("❌ Cannot use both a path argument and --stdin.", file=sys.stderr)
120 raise SystemExit(ExitCode.USER_ERROR)
121
122 if not use_stdin and path is None:
123 print("❌ Provide a file path or use --stdin.", file=sys.stderr)
124 raise SystemExit(ExitCode.USER_ERROR)
125
126 # ── stdin mode ───────────────────────────────────────────────────────────
127 if use_stdin:
128 data = sys.stdin.buffer.read()
129 object_id = _hash_bytes(data)
130 stored = False
131
132 if write:
133 root = find_repo_root(pathlib.Path.cwd())
134 if root is None:
135 print(
136 "❌ Not inside a Muse repository. Cannot write object.",
137 file=sys.stderr,
138 )
139 raise SystemExit(ExitCode.USER_ERROR)
140 try:
141 stored = write_object(root, object_id, data)
142 except ValueError as exc:
143 print(
144 f"❌ Integrity check failed: {sanitize_display(str(exc))}",
145 file=sys.stderr,
146 )
147 raise SystemExit(ExitCode.INTERNAL_ERROR)
148 except OSError as exc:
149 print(
150 f"❌ Failed to write object: {sanitize_display(str(exc))}",
151 file=sys.stderr,
152 )
153 raise SystemExit(ExitCode.INTERNAL_ERROR)
154
155 _emit(fmt, object_id, stored)
156 return
157
158 # ── file mode ────────────────────────────────────────────────────────────
159 assert path is not None
160
161 # Null bytes are not valid in POSIX paths; pathlib raises ValueError on
162 # Python ≥3.12 but the explicit check is clearer and version-independent.
163 try:
164 _path_str = str(path)
165 except Exception:
166 _path_str = repr(path)
167 if "\x00" in _path_str:
168 print("❌ Path contains a null byte.", file=sys.stderr)
169 raise SystemExit(ExitCode.USER_ERROR)
170
171 if not path.exists():
172 print(
173 f"❌ Path does not exist: {sanitize_display(str(path))}",
174 file=sys.stderr,
175 )
176 raise SystemExit(ExitCode.USER_ERROR)
177 if path.is_dir():
178 print(
179 f"❌ Path is a directory, not a file: {sanitize_display(str(path))}",
180 file=sys.stderr,
181 )
182 raise SystemExit(ExitCode.USER_ERROR)
183 # Reject special files (block/char devices, named pipes, sockets).
184 # These would either block forever (FIFO), return device-specific data
185 # (block/char), or fail unpredictably (socket). Only regular files and
186 # symlinks to regular files are accepted — consistent with git hash-object.
187 if not path.is_file():
188 print(
189 f"❌ Path is not a regular file (device, pipe, or socket): "
190 f"{sanitize_display(str(path))}",
191 file=sys.stderr,
192 )
193 raise SystemExit(ExitCode.USER_ERROR)
194
195 object_id = hash_file(path)
196 stored = False
197
198 if write:
199 root = find_repo_root(pathlib.Path.cwd())
200 if root is None:
201 print("❌ Not inside a Muse repository. Cannot write object.", file=sys.stderr)
202 raise SystemExit(ExitCode.USER_ERROR)
203 try:
204 # write_object_from_path streams at 64 KiB chunks — heap-safe for
205 # arbitrarily large blobs. Re-verifies hash before writing to catch
206 # any TOCTOU race between hash_file() above and the store write.
207 stored = write_object_from_path(root, object_id, path)
208 except ValueError as exc:
209 print(
210 f"❌ Integrity check failed (file may have changed during write): "
211 f"{sanitize_display(str(exc))}",
212 file=sys.stderr,
213 )
214 raise SystemExit(ExitCode.INTERNAL_ERROR)
215 except OSError as exc:
216 print(
217 f"❌ Failed to write object: {sanitize_display(str(exc))}",
218 file=sys.stderr,
219 )
220 raise SystemExit(ExitCode.INTERNAL_ERROR)
221
222 _emit(fmt, object_id, stored)
223
224
225 def _emit(fmt: str, object_id: str, stored: bool) -> None:
226 """Print the result in the requested format."""
227 if fmt == "text":
228 print(object_id)
229 else:
230 print(json.dumps({"object_id": object_id, "stored": stored}))
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago