gabriel / muse public
verify_object.py python
401 lines 13.3 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 13 days ago
1 """muse verify-object — verify the integrity of stored objects.
2
3 Reads one or more objects from the content-addressed store and re-hashes each
4 one to confirm that its on-disk content still matches its claimed SHA-256
5 identity. Reports the result per object and exits non-zero if any object
6 fails verification.
7
8 Used by backup systems, replication agents, and CI pipelines to detect silent
9 data corruption without a full fsck.
10
11 Output (JSON, default)::
12
13 {
14 "results": [
15 {"object_id": "<sha256>", "ok": true, "size_bytes": 4096,
16 "error": null},
17 {"object_id": "<sha256>", "ok": false, "size_bytes": 512,
18 "error": "hash mismatch: stored abc123… recomputed def456…"},
19 {"object_id": "<sha256>", "ok": false, "size_bytes": null,
20 "error": "object not found in store"}
21 ],
22 "all_ok": false,
23 "checked": 3,
24 "failed": 2,
25 "duration_ms": 1.234,
26 "exit_code": 1
27 }
28
29 Text output (``--format text``)::
30
31 OK <sha256> (4096 bytes)
32 FAIL <sha256> hash mismatch: stored abc123… recomputed def456…
33 FAIL <sha256> object not found in store
34 ---
35 Checked: 3 Failed: 2
36
37 With ``--quiet``: no output; exits 0 if all pass, exits 1 otherwise.
38
39 Output contract
40 ---------------
41
42 - Exit 0: all objects verified successfully.
43 - Exit 1: one or more objects failed verification; object not found; bad args.
44 - Exit 3: unexpected I/O error (e.g. disk read failure).
45 - ``duration_ms`` and ``exit_code`` are always present in JSON output so agents
46 can measure latency and check results without inspecting the process exit
47 status separately.
48 - Results are returned in the same order as the input object IDs.
49 - ``error`` is always ``null`` when ``ok`` is ``true``.
50
51 Performance
52 -----------
53
54 Object size is measured by counting bytes as they are read during hashing —
55 no separate ``stat()`` call is made. For ``--all`` over a large store this
56 saves one syscall per object.
57
58 Use ``--fail-fast`` in CI to bail immediately after the first failure instead
59 of scanning the entire store.
60
61 Agent use
62 ---------
63
64 Verify specific objects after a replication or backup::
65
66 muse verify-object <sha256> <sha256> --json
67
68 Full store integrity check (fsck equivalent)::
69
70 muse verify-object --all --json
71
72 Fail fast — bail after the first corrupt object::
73
74 muse verify-object --all --fail-fast --json
75
76 Pipe object IDs from a manifest::
77
78 cat manifest.txt | muse verify-object --stdin --json
79
80 Quiet mode for CI::
81
82 muse verify-object --all --quiet && echo "store intact"
83 """
84
85 import argparse
86 import hashlib
87 import json
88 import logging
89 import pathlib
90 import sys
91 from typing import TypedDict
92
93 from muse.core.types import long_id
94 from muse.core.envelope import EnvelopeJson, make_envelope
95 from muse.core.errors import ExitCode
96 from muse.core.object_store import iter_stored_objects, object_path, objects_dir
97 from muse.core.repo import require_repo
98 from muse.core.timing import start_timer
99 from muse.core.validation import sanitize_display, validate_object_id
100
101 logger = logging.getLogger(__name__)
102
103 _CHUNK = 65536 # 64 KiB read chunks — keeps the heap clean for large blobs
104
105 class _ObjectResult(TypedDict):
106 object_id: str
107 ok: bool
108 size_bytes: int | None
109 error: str | None
110
111 class _VerifyObjectJson(EnvelopeJson):
112 results: list[_ObjectResult]
113 all_ok: bool
114 checked: int
115 failed: int
116
117 def _iter_all_object_ids(root: pathlib.Path) -> list[str]:
118 """Walk the object store shard tree and return every stored object ID.
119
120 The store layout is ``<root>/.muse/objects/sha256/<shard2>/<remaining62>``.
121 Symlinks in the shard directories are skipped — they are not a valid
122 Muse store layout and could point outside the repository.
123
124 Returns:
125 Sorted list of ``sha256:<64hex>`` object IDs present on disk.
126 """
127 return sorted(oid for oid, _ in iter_stored_objects(root))
128
129 def _verify_one(root: pathlib.Path, object_id: str) -> _ObjectResult:
130 """Integrity-check a single object and return its result record.
131
132 Streams the object in 64 KiB chunks to avoid loading large blobs into
133 memory. Measures size by counting bytes during hashing — no separate
134 ``stat()`` call is needed. Returns an :class:`_ObjectResult` — never
135 raises.
136 """
137 try:
138 validate_object_id(object_id)
139 except ValueError as exc:
140 return {
141 "object_id": object_id,
142 "ok": False,
143 "size_bytes": None,
144 "error": str(exc),
145 }
146
147 dest = object_path(root, object_id)
148 if not dest.exists():
149 # Fall through to pack store before reporting missing.
150 from muse.core.pack_store import read_object_from_packs
151 try:
152 content = read_object_from_packs(root, object_id)
153 except OSError as exc:
154 return {
155 "object_id": object_id,
156 "ok": False,
157 "size_bytes": None,
158 "error": f"pack integrity error: {exc}",
159 }
160 if content is None:
161 return {
162 "object_id": object_id,
163 "ok": False,
164 "size_bytes": None,
165 "error": "object not found in store",
166 }
167 # read_object_from_packs verifies the hash internally — reaching here
168 # means the object is present and correct.
169 return {"object_id": object_id, "ok": True, "size_bytes": len(content), "error": None}
170
171 try:
172 h = hashlib.sha256()
173 total_size = 0
174 payload_size: int | None = None
175 leftover = b""
176 with dest.open("rb") as fh:
177 for chunk in iter(lambda: fh.read(_CHUNK), b""):
178 h.update(chunk)
179 total_size += len(chunk)
180 if payload_size is None:
181 # Find the null byte that terminates the "<type> <size>\0" header.
182 buf = leftover + chunk
183 null_pos = buf.find(b"\0")
184 if null_pos != -1:
185 try:
186 _, size_str = buf[:null_pos].decode("ascii").split(" ", 1)
187 payload_size = int(size_str)
188 except (ValueError, UnicodeDecodeError):
189 payload_size = total_size # corrupt header: report raw size
190 else:
191 leftover = buf
192 # If header was never found (truncated/corrupt), fall back to total size.
193 if payload_size is None:
194 payload_size = total_size
195 actual = long_id(h.hexdigest())
196 except OSError as exc:
197 return {
198 "object_id": object_id,
199 "ok": False,
200 "size_bytes": None,
201 "error": f"I/O error: {exc}",
202 }
203
204 if actual != object_id:
205 return {
206 "object_id": object_id,
207 "ok": False,
208 "size_bytes": payload_size,
209 "error": (
210 f"hash mismatch: stored {object_id} "
211 f"recomputed {actual}"
212 ),
213 }
214
215 return {"object_id": object_id, "ok": True, "size_bytes": payload_size, "error": None}
216
217 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
218 """Register the verify-object subcommand."""
219 parser = subparsers.add_parser(
220 "verify-object",
221 help="Re-hash stored objects to detect data corruption.",
222 description=__doc__,
223 formatter_class=argparse.RawDescriptionHelpFormatter,
224 )
225 parser.add_argument(
226 "object_ids",
227 nargs="*",
228 help=(
229 "One or more SHA-256 object IDs to verify. "
230 "Required unless --all or --stdin is used."
231 ),
232 )
233 parser.add_argument(
234 "--all", "-a",
235 action="store_true",
236 dest="verify_all",
237 help=(
238 "Verify every object in the store — the fsck equivalent. "
239 "No object ID arguments needed. "
240 "Cannot be combined with positional object IDs."
241 ),
242 )
243 parser.add_argument(
244 "--stdin",
245 action="store_true",
246 dest="from_stdin",
247 help=(
248 "Read additional object IDs from stdin, one per line. "
249 "Blank lines and '#'-comments are skipped. "
250 "Combines with positional object ID arguments."
251 ),
252 )
253 parser.add_argument(
254 "--quiet", "-q",
255 action="store_true",
256 help="No output. Exit 0 if all objects are intact, exit 1 otherwise.",
257 )
258 parser.add_argument(
259 "--fail-fast",
260 action="store_true",
261 dest="fail_fast",
262 help=(
263 "Stop after the first failed object and exit 1 immediately. "
264 "Useful in CI to avoid scanning an entire store when a single "
265 "corruption has already been detected."
266 ),
267 )
268 parser.add_argument(
269 "--json", "-j",
270 action="store_true",
271 dest="json_out",
272 help="Emit machine-readable JSON on stdout.",
273 )
274 parser.set_defaults(func=run, json_out=False)
275
276 def run(args: argparse.Namespace) -> None:
277 """Re-hash stored objects to detect silent data corruption.
278
279 Streams each object in 64 KiB chunks, computes SHA-256, and compares it
280 to the content-addressed filename. Any mismatch indicates corruption.
281 Results are returned in the same order as the input IDs; size is counted
282 during hashing so no extra stat() call is needed.
283
284 Agent quickstart::
285
286 muse verify-object sha256:<id> --json
287 muse verify-object --all --json # full store fsck
288 muse verify-object --all --fail-fast --json
289 cat manifest.txt | muse verify-object --stdin --json
290
291 JSON fields::
292
293 results List of {object_id, ok, size_bytes, error} per object.
294 all_ok true when every object passed.
295 checked Total number of objects checked.
296 failed Number of objects that failed verification.
297 muse_version Muse release that produced this output.
298 schema Envelope schema version (int).
299 exit_code 0 all ok, 1 any failure.
300 duration_ms Wall-clock milliseconds for the command.
301 timestamp ISO-8601 UTC timestamp of command completion.
302 warnings List of non-fatal advisory messages.
303
304 Exit codes::
305
306 0 All objects verified successfully.
307 1 One or more objects failed verification or were not found.
308 3 Unexpected I/O error during hashing.
309 """
310 elapsed = start_timer()
311 json_out: bool = args.json_out
312 cli_ids: list[str] = args.object_ids or []
313 verify_all: bool = args.verify_all
314 from_stdin: bool = args.from_stdin
315 quiet: bool = args.quiet
316 fail_fast: bool = args.fail_fast
317
318 if verify_all and cli_ids:
319 print(
320 json.dumps(
321 {"error": "--all cannot be combined with explicit object ID arguments."}
322 ),
323 file=sys.stderr,
324 )
325 raise SystemExit(ExitCode.USER_ERROR)
326
327 if verify_all and from_stdin:
328 print(
329 json.dumps(
330 {"error": "--all cannot be combined with --stdin."}
331 ),
332 file=sys.stderr,
333 )
334 raise SystemExit(ExitCode.USER_ERROR)
335
336 root = require_repo()
337
338 # Collect object IDs from all sources.
339 if verify_all:
340 object_ids: list[str] = _iter_all_object_ids(root)
341 else:
342 object_ids = list(cli_ids)
343 if from_stdin:
344 for line in sys.stdin:
345 # Strip \r\n — CRLF from Windows or injection would embed \r
346 # in the ID, causing validate_object_id to reject it with a
347 # confusing error rather than a clear "malformed input" message.
348 stripped = line.rstrip("\r\n")
349 if stripped and not stripped.startswith("#"):
350 object_ids.append(stripped)
351
352 if not object_ids and not verify_all:
353 print(
354 json.dumps(
355 {"error": "At least one object ID is required (or use --all / --stdin)."}
356 ),
357 file=sys.stderr,
358 )
359 raise SystemExit(ExitCode.USER_ERROR)
360
361 # Verify — optionally bail after the first failure.
362 results: list[_ObjectResult] = []
363 for oid in object_ids:
364 r = _verify_one(root, oid)
365 results.append(r)
366 if fail_fast and not r["ok"]:
367 break
368
369 all_ok = all(r["ok"] for r in results)
370 failed_count = sum(1 for r in results if not r["ok"])
371
372 if quiet:
373 raise SystemExit(0 if all_ok else ExitCode.USER_ERROR)
374
375 if not json_out:
376 for r in results:
377 status = "OK " if r["ok"] else "FAIL"
378 oid_safe = sanitize_display(r["object_id"])
379 size_str = f" ({r['size_bytes']} bytes)" if r["size_bytes"] is not None else ""
380 err_str = (
381 f" {sanitize_display(r['error'])}"
382 if not r["ok"] and r["error"]
383 else ""
384 )
385 print(f"{status} {oid_safe}{size_str}{err_str}")
386 print(f"---\nChecked: {len(results)} Failed: {failed_count}")
387 if not all_ok:
388 raise SystemExit(ExitCode.USER_ERROR)
389 return
390
391 exit_code = 0 if all_ok else int(ExitCode.USER_ERROR)
392 print(json.dumps(_VerifyObjectJson(
393 **make_envelope(elapsed, exit_code=exit_code),
394 results=[dict(r) for r in results],
395 all_ok=all_ok,
396 checked=len(results),
397 failed=failed_count,
398 )))
399
400 if not all_ok:
401 raise SystemExit(ExitCode.USER_ERROR)
File History 10 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6 chore: bump version to 0.2.0.dev2 — nightly.2 Sonnet 4.6 patch 12 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 15 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 24 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 34 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 36 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 50 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 56 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 56 days ago