gabriel / muse public
verify.py python
279 lines 10.4 KB
Raw
sha256:804d2db97fc23e396ef4d532efb84f2d45202063e6620be10778bf8534596549 fix: format numbers and show full IDs in muse verify human output Sonnet 4.6 7 days ago
1 """``muse verify`` — whole-repository integrity check.
2
3 Walks every reachable commit from every branch ref and performs a five-tier
4 integrity check:
5
6 1. Every branch ref points to an existing, well-formed commit.
7 2. Every commit's snapshot exists.
8 3. Every object referenced by every snapshot exists, and (unless
9 ``--no-objects``) its SHA-256 is recomputed to detect silent corruption.
10 4. For every commit that carries an Ed25519 signature, the signature is verified
11 against the embedded ``signer_public_key``.
12 5. Missing public keys are reported separately as ``kind="key_missing"`` so
13 agents can distinguish key-rotation events from genuine tamper detection
14 (``kind="signature"``).
15
16 This is Muse's equivalent of ``git fsck``. Run it periodically on long-lived
17 agent repositories or after recovering from a storage failure.
18
19 Usage::
20
21 muse verify # full check — re-hashes all objects
22 muse verify --no-objects # existence check only (faster)
23 muse verify --branch feat/x # check one branch only
24 muse verify --fail-fast # stop on first failure (CI-friendly)
25 muse verify --quiet # no output — exit code only
26 muse verify --json # machine-readable report
27
28 JSON success schema::
29
30 {
31 "repo_id": "<str>",
32 "refs_checked": <int>,
33 "commits_checked": <int>,
34 "snapshots_checked": <int>,
35 "objects_checked": <int>,
36 "signatures_checked": <int>,
37 "all_ok": <bool>,
38 "nothing_checked": <bool>,
39 "check_objects": <bool>,
40 "branch": "<str | null>",
41 "fail_fast": <bool>,
42 "duration_ms": <float>,
43 "exit_code": <int>,
44 "failures": [
45 {
46 "kind": "ref|commit|snapshot|object|signature|key_missing",
47 "id": "<str>",
48 "error": "<str>"
49 }
50 ]
51 }
52
53 JSON error schema (when ``--json`` is active)::
54
55 {"error": "<kind>", "message": "<str>", "duration_ms": <float>, "exit_code": <int>}
56
57 Exit codes::
58
59 0 — all checks passed
60 1 — one or more integrity failures detected
61 3 — I/O error reading repository files
62 """
63
64 import argparse
65 import json
66 import logging
67 import sys
68 from typing import TypedDict
69
70 from muse.core.envelope import EnvelopeJson, make_envelope
71 from muse.core.errors import ExitCode
72 from muse.core.repo import read_repo_id, require_repo
73 from muse.core.validation import sanitize_display
74 from muse.core.verify import VerifyFailure, VerifyResult, run_verify
75 from muse.core.timing import start_timer
76
77 logger = logging.getLogger(__name__)
78
79 class _VerifyJson(EnvelopeJson):
80 """JSON wire format for ``muse verify --json`` on success."""
81
82 repo_id: str
83 refs_checked: int
84 commits_checked: int
85 snapshots_checked: int
86 objects_checked: int
87 signatures_checked: int
88 shallow_commits: int
89 promised_objects: int
90 is_shallow: bool
91 promisor_remotes: list[str]
92 all_ok: bool
93 nothing_checked: bool
94 check_objects: bool
95 strict: bool
96 branch: str | None
97 fail_fast: bool
98 failures: list[VerifyFailure]
99
100 class _VerifyErrorJson(EnvelopeJson):
101 """JSON wire format for ``muse verify --json`` on error."""
102
103 error: str
104 message: str
105
106 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
107 """Register the verify subcommand."""
108 parser = subparsers.add_parser(
109 "verify",
110 help="Check repository integrity — commits, snapshots, and objects.",
111 description=__doc__,
112 formatter_class=argparse.RawDescriptionHelpFormatter,
113 )
114 parser.add_argument(
115 "--quiet", "-q", action="store_true",
116 help="No output — exit code only.",
117 )
118 parser.add_argument(
119 "--no-objects", "-O", action="store_true", dest="no_objects",
120 help="Existence check only — skip object re-hashing (faster).",
121 )
122 parser.add_argument(
123 "--branch", "-b", metavar="BRANCH", default=None,
124 help="Verify only the named branch instead of all branches.",
125 )
126 parser.add_argument(
127 "--fail-fast", action="store_true", dest="fail_fast",
128 help="Stop on the first failure (useful in CI pipelines).",
129 )
130 parser.add_argument(
131 "--strict", action="store_true",
132 help="Treat promised (remote-only) objects as failures — full deep integrity check.",
133 )
134 parser.add_argument(
135 "--json", "-j", action="store_true", dest="json_out",
136 help="Emit a machine-readable JSON report on stdout.",
137 )
138 parser.set_defaults(func=run, json_out=False)
139
140 def run(args: argparse.Namespace) -> None:
141 """Check repository integrity — commits, snapshots, objects, and signatures.
142
143 Walks every reachable commit from every branch ref, verifying existence and
144 content hash at each tier. Failures with ``kind="key_missing"`` indicate a
145 signer public key is absent (possible key rotation) and should not be treated
146 as hard corruption. I/O errors are emitted as JSON to stdout when ``--json``
147 is active so agent pipelines never receive mixed-mode output.
148
149 Agent quickstart::
150
151 muse verify --json
152 muse verify --no-objects --json # fast existence-only check
153 muse verify --branch feat/x --json # one branch only
154 muse verify --fail-fast --json # abort on first failure
155 muse verify --strict --json # treat promised objects as failures
156
157 JSON fields::
158
159 repo_id Repository identifier string.
160 refs_checked Number of branch refs checked.
161 commits_checked Number of commit records verified.
162 snapshots_checked Number of snapshots verified.
163 objects_checked Number of content objects checked (re-hashed unless --no-objects).
164 signatures_checked Number of Ed25519 signatures verified.
165 shallow_commits Number of shallow boundary grafts.
166 promised_objects Number of objects deferred to promisor remotes.
167 is_shallow true if the repo has any shallow grafts.
168 promisor_remotes List of remote names that hold promised objects.
169 all_ok true when every check passed.
170 nothing_checked true when no refs were found to check.
171 check_objects true unless --no-objects was passed.
172 strict true when --strict was passed.
173 branch Branch filter passed via --branch, or null.
174 fail_fast true when --fail-fast was passed.
175 failures List of {kind, id, error} failure records.
176 muse_version Muse release that produced this output.
177 schema Envelope schema version (int).
178 exit_code 0 all ok, 1 failures found, 3 I/O error.
179 duration_ms Wall-clock milliseconds for the command.
180 timestamp ISO-8601 UTC timestamp of command completion.
181 warnings List of non-fatal advisory messages.
182
183 Exit codes::
184
185 0 All checks passed.
186 1 One or more integrity failures detected.
187 3 I/O error reading repository files.
188 """
189 elapsed = start_timer()
190
191 quiet: bool = args.quiet
192 no_objects: bool = args.no_objects
193 json_out: bool = args.json_out
194 branch: str | None = args.branch
195 fail_fast: bool = args.fail_fast
196 strict: bool = args.strict
197
198 def _emit_error(msg: str, code: int, error_key: str = "error") -> None:
199 if json_out:
200 print(json.dumps({**make_envelope(elapsed, exit_code=code), "error": error_key, "message": msg}))
201 elif not quiet:
202 print(f"❌ {msg}", file=sys.stderr)
203 raise SystemExit(code)
204
205 root = require_repo()
206
207 try:
208 result = run_verify(
209 root,
210 check_objects=not no_objects,
211 branch=branch,
212 fail_fast=fail_fast,
213 strict=strict,
214 )
215 except OSError as exc:
216 _emit_error(f"I/O error during verify: {exc}", ExitCode.INTERNAL_ERROR, "io_error")
217
218 if quiet:
219 raise SystemExit(0 if result["all_ok"] else ExitCode.USER_ERROR)
220
221 exit_code = 0 if result["all_ok"] else int(ExitCode.USER_ERROR)
222
223 if json_out:
224 repo_id = read_repo_id(root) or ""
225 print(json.dumps(_VerifyJson(
226 **make_envelope(elapsed, exit_code=exit_code),
227 repo_id=repo_id,
228 refs_checked=result["refs_checked"],
229 commits_checked=result["commits_checked"],
230 snapshots_checked=result["snapshots_checked"],
231 objects_checked=result["objects_checked"],
232 signatures_checked=result["signatures_checked"],
233 shallow_commits=result["shallow_commits"],
234 promised_objects=result["promised_objects"],
235 is_shallow=result["is_shallow"],
236 promisor_remotes=result["promisor_remotes"],
237 all_ok=result["all_ok"],
238 nothing_checked=result["nothing_checked"],
239 check_objects=not no_objects,
240 strict=strict,
241 branch=branch,
242 fail_fast=fail_fast,
243 failures=result["failures"],
244 )))
245 else:
246 _print_text(result, no_objects=no_objects, branch=branch)
247
248 raise SystemExit(exit_code)
249
250 def _print_text(
251 result: VerifyResult,
252 *,
253 no_objects: bool,
254 branch: str | None,
255 ) -> None:
256 """Render a human-readable summary of the verify result."""
257 if branch:
258 print(f"Scope: branch '{sanitize_display(branch)}'")
259 print(f"Checking refs... {result['refs_checked']} ref(s)")
260 print(f"Checking commits... {result['commits_checked']} commit(s)")
261 if result["shallow_commits"]:
262 print(f" Shallow boundary: {result['shallow_commits']} graft(s) — history stops here")
263 print(f"Checking snapshots... {result['snapshots_checked']} snapshot(s)")
264 action = "existence only" if no_objects else "re-hashed"
265 print(f"Checking objects... {result['objects_checked']} object(s) [{action}]")
266 if result["promised_objects"]:
267 remotes = ", ".join(result["promisor_remotes"]) or "(none)"
268 print(f" Promised objects: {result['promised_objects']} — on promisor remote(s): {remotes}")
269 print(f"Checking signatures... {result['signatures_checked']} signed commit(s)")
270
271 if result["all_ok"]:
272 print("✅ Repository is healthy.")
273 else:
274 failure_count = len(result["failures"])
275 print(f"\n❌ {failure_count} integrity failure(s):")
276 for f in result["failures"]:
277 kind = sanitize_display(f["kind"])
278 err = sanitize_display(f["error"])
279 print(f" {kind:<12} {f['id'][:24]} {err}")
File History 1 commit
sha256:804d2db97fc23e396ef4d532efb84f2d45202063e6620be10778bf8534596549 fix: format numbers and show full IDs in muse verify human output Sonnet 4.6 7 days ago