gabriel / muse public

test_cmd_verify_commit.py file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
1 """Tests for ``muse verify-commit`` — verify Ed25519 signatures on commits.
2
3 Coverage tiers
4 --------------
5 Unit:
6 _resolve_ref — HEAD→tip, HEAD→missing branch, sha256:-prefixed passthrough,
7 branch name→ref file, missing branch ref→None
8 _verify_one — valid sig, unsigned, missing commit, missing pubkey, bit-flip
9 in signature, unknown sig
10 algo, unknown pubkey algo, committed_at tamper, model_id tamper,
11 wrong keypair, decode_pubkey ValueError, signed_at non-empty,
12 error None on success, key_status cache hit
13 _fetch_key_status — active, revoked, unknown status value, network error
14
15 Integration:
16 Text output: OK/BAD/ERR lines, (unsigned) signer, key= part present/absent
17 JSON output: all schema fields, error field stripped, signed_at non-empty,
18 signer matches agent_id
19 --strict: unsigned exits nonzero; without --strict exits 0
20 --check-key-status: unknown without hub; caches per key_id
21 HEAD shorthand, branch name ref
22 Batch: all valid exits 0; one invalid exits nonzero; result order preserved
23 Nonexistent sha256:- ref → USER_ERROR
24 --json flag accepted (shorthand alias)
25
26 Security:
27 ANSI escape in ref → rejected, error to stderr, stdout empty
28 Null byte in ref → rejected
29 Path traversal ref → rejected
30 Bare hex ref → rejected with clear message, error to stderr, stdout empty
31 No traceback on bad ref or bad format
32
33 Data integrity:
34 committed_at tamper → invalid through full CLI flow
35 model_id tamper → invalid through full CLI flow
36 Wrong keypair → invalid (public key doesn't match signing key)
37
38 Stress:
39 100 signed commits all verify correctly (unit)
40 Batch of 50 commits via CLI → all results emitted
41 key_status_cache: N commits with same key → exactly 1 network call
42 """
43
44 from __future__ import annotations
45 from collections.abc import Mapping
46
47 import datetime
48 import json
49 import pathlib
50 from typing import TYPE_CHECKING
51 from unittest.mock import MagicMock, patch
52
53 import pytest
54
55 from muse.core.types import blob_id, decode_sig, encode_sig, split_id
56
57 if TYPE_CHECKING:
58 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
59 from muse.core.object_store import write_object
60 from muse.core.provenance import (
61 encode_public_key,
62 provenance_payload,
63 sign_commit_record,
64 verify_commit_ed25519,
65 )
66 from muse.core.ids import hash_commit, hash_snapshot
67 from muse.core.commits import (
68 CommitRecord,
69 commit_path,
70 write_commit,
71 )
72 from muse.core.snapshots import (
73 SnapshotRecord,
74 write_snapshot,
75 )
76 from muse.core.types import Manifest
77 from muse.core.paths import heads_dir, muse_dir, ref_path
78 from tests.cli_test_helper import CliRunner, InvokeResult
79
80 runner = CliRunner()
81
82 _REPO_ID = "verify-commit-test"
83 _counter = 0
84
85
86 # ---------------------------------------------------------------------------
87 # Helpers
88 # ---------------------------------------------------------------------------
89
90
91 def _init_repo(path: pathlib.Path) -> pathlib.Path:
92 muse = muse_dir(path)
93 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
94 (muse / d).mkdir(parents=True, exist_ok=True)
95 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
96 (muse / "repo.json").write_text(
97 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
98 )
99 return path
100
101
102 def _env(repo: pathlib.Path) -> Mapping[str, str]:
103 return {"MUSE_REPO_ROOT": str(repo)}
104
105
106 def _make_key() -> "Ed25519PrivateKey":
107 """Generate a fresh Ed25519 private key."""
108 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
109 return Ed25519PrivateKey.generate()
110
111
112 def _commit_files(
113 root: pathlib.Path,
114 files: Mapping[str, bytes],
115 branch: str = "main",
116 message: str | None = None,
117 sign: bool = False,
118 private_key: "Ed25519PrivateKey | None" = None,
119 agent_id: str = "test-agent",
120 model_id: str = "",
121 ) -> tuple[str, CommitRecord]:
122 """Create a commit; optionally sign it. Returns (commit_id, CommitRecord)."""
123 global _counter
124 _counter += 1
125 manifest: Manifest = {}
126 for rel_path, content in files.items():
127 obj_id = blob_id(content)
128 write_object(root, obj_id, content)
129 manifest[rel_path] = obj_id
130 abs_path = root / rel_path
131 abs_path.parent.mkdir(parents=True, exist_ok=True)
132 abs_path.write_bytes(content)
133 snap_id = hash_snapshot(manifest)
134 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
135 committed_at = datetime.datetime.now(datetime.timezone.utc)
136 branch_ref = ref_path(root, branch)
137 parent_id = branch_ref.read_text(encoding="utf-8").strip() if branch_ref.exists() else None
138 parents = [parent_id] if parent_id else []
139 msg = message or f"commit {_counter}"
140
141 # Resolve public key before compute_commit_id so signer_public_key is
142 # bound into the v2 hash (matching what _verify_commit_id expects).
143 sig = ""
144 pub_b64 = ""
145 key_id = ""
146 if sign and private_key is not None:
147 from muse.core.provenance import encode_public_key
148 _, pub_b64 = encode_public_key(private_key)
149
150 commit_id = hash_commit(
151 parent_ids=parents,
152 snapshot_id=snap_id,
153 message=msg,
154 committed_at_iso=committed_at.isoformat(),
155 signer_public_key=pub_b64,
156 )
157
158 if sign and private_key is not None:
159 result = sign_commit_record(
160 commit_id,
161 agent_id,
162 private_key,
163 model_id=model_id,
164 committed_at=committed_at.isoformat(),
165 )
166 if result:
167 sig, pub_b64, key_id = result
168
169 record = CommitRecord(
170 commit_id=commit_id,
171 branch=branch,
172 snapshot_id=snap_id,
173 message=msg,
174 committed_at=committed_at,
175 parent_commit_id=parent_id,
176 agent_id=agent_id if sign else "",
177 model_id=model_id if sign else "",
178 signature=sig,
179 signer_public_key=pub_b64,
180 signer_key_id=key_id,
181 )
182 write_commit(root, record)
183 branch_ref.write_text(commit_id, encoding="utf-8")
184 return commit_id, record
185
186
187 def _invoke(repo: pathlib.Path, *args: str) -> InvokeResult:
188 from muse.cli.app import main as cli
189 return runner.invoke(cli, ["verify-commit", *args], env=_env(repo))
190
191
192 def _force_write_commit(root: pathlib.Path, record: CommitRecord) -> None:
193 """Overwrite a commit object unconditionally (bypasses write_commit idempotency).
194
195 Writes to the unified object store path so read_commit can find it.
196 Uses JSON format with the 'commit <size>\0<payload>' header.
197 """
198 import json as _json
199 from muse.core.object_store import object_path as _object_path
200 obj_file = _object_path(root, record.commit_id)
201 obj_file.parent.mkdir(parents=True, exist_ok=True)
202 payload = _json.dumps(record.to_dict(), separators=(",", ":")).encode()
203 obj_file.write_bytes(f"commit {len(payload)}\0".encode() + payload)
204
205
206 # ---------------------------------------------------------------------------
207 # Unit — _resolve_ref
208 # ---------------------------------------------------------------------------
209
210
211 class TestResolveRef:
212 def test_head_resolves_to_branch_tip(self, tmp_path: pathlib.Path) -> None:
213 from muse.cli.commands.verify_commit import _resolve_ref
214 root = _init_repo(tmp_path)
215 key = _make_key()
216 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
217 resolved = _resolve_ref(root, "HEAD")
218 assert resolved == commit_id
219
220 def test_head_returns_none_when_branch_has_no_commits(self, tmp_path: pathlib.Path) -> None:
221 from muse.cli.commands.verify_commit import _resolve_ref
222 root = _init_repo(tmp_path)
223 # HEAD points to main but main ref file doesn't exist yet
224 resolved = _resolve_ref(root, "HEAD")
225 assert resolved is None
226
227 def test_sha256_prefixed_id_passthrough(self, tmp_path: pathlib.Path) -> None:
228 """sha256:-prefixed IDs are returned as-is (no ref-file lookup)."""
229 from muse.cli.commands.verify_commit import _resolve_ref
230 root = _init_repo(tmp_path)
231 key = _make_key()
232 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
233 resolved = _resolve_ref(root, commit_id)
234 assert resolved == commit_id
235
236 def test_bare_hex_normalised_to_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
237 """A 64-char bare hex is normalised to sha256: prefix."""
238 from muse.cli.commands.verify_commit import _resolve_ref
239 root = _init_repo(tmp_path)
240 key = _make_key()
241 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
242 bare = split_id(commit_id)[1]
243 resolved = _resolve_ref(root, bare)
244 assert resolved == commit_id
245
246 def test_branch_name_resolves_via_ref_file(self, tmp_path: pathlib.Path) -> None:
247 from muse.cli.commands.verify_commit import _resolve_ref
248 root = _init_repo(tmp_path)
249 key = _make_key()
250 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, branch="dev")
251 resolved = _resolve_ref(root, "dev")
252 assert resolved == commit_id
253
254 def test_missing_branch_ref_returns_none(self, tmp_path: pathlib.Path) -> None:
255 from muse.cli.commands.verify_commit import _resolve_ref
256 root = _init_repo(tmp_path)
257 resolved = _resolve_ref(root, "nonexistent-branch")
258 assert resolved is None
259
260
261 # ---------------------------------------------------------------------------
262 # Unit — _verify_one
263 # ---------------------------------------------------------------------------
264
265
266 class TestVerifyOne:
267 def test_valid_signature(self, tmp_path: pathlib.Path) -> None:
268 from muse.cli.commands.verify_commit import _verify_one
269 root = _init_repo(tmp_path)
270 key = _make_key()
271 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
272 result = _verify_one(root, commit_id)
273 assert result["valid"] is True
274 assert result["commit_id"] == commit_id
275 assert len(result["key_id"]) > 0
276
277 def test_unsigned_commit_valid_false_no_error(self, tmp_path: pathlib.Path) -> None:
278 from muse.cli.commands.verify_commit import _verify_one
279 root = _init_repo(tmp_path)
280 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=False)
281 result = _verify_one(root, commit_id)
282 assert result["valid"] is False
283 assert result["error"] is None
284 assert result["signer"] == ""
285
286 def test_missing_commit_returns_error(self, tmp_path: pathlib.Path) -> None:
287 from muse.cli.commands.verify_commit import _verify_one
288 root = _init_repo(tmp_path)
289 result = _verify_one(root, blob_id(b"nonexistent commit"))
290 assert result["valid"] is False
291 assert "not found" in (result["error"] or "")
292
293 def test_missing_public_key_valid_false(self, tmp_path: pathlib.Path) -> None:
294 from muse.cli.commands.verify_commit import _verify_one
295 root = _init_repo(tmp_path)
296 key = _make_key()
297 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
298 tampered = CommitRecord(
299 commit_id=record.commit_id, branch=record.branch,
300 snapshot_id=record.snapshot_id, message=record.message,
301 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
302 agent_id=record.agent_id, signature=record.signature,
303 signer_public_key="", # stripped
304 signer_key_id=record.signer_key_id,
305 )
306 _force_write_commit(root, tampered)
307 result = _verify_one(root, commit_id)
308 assert result["valid"] is False
309
310 def test_bit_flip_in_signature(self, tmp_path: pathlib.Path) -> None:
311 """A single bit flip in the stored signature must invalidate it."""
312 from muse.cli.commands.verify_commit import _verify_one
313 root = _init_repo(tmp_path)
314 key = _make_key()
315 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
316 algo, sig_bytes = decode_sig(record.signature)
317 flipped = bytes([sig_bytes[0] ^ 0xFF]) + sig_bytes[1:]
318 bad_sig = encode_sig(algo, flipped)
319 tampered = CommitRecord(
320 commit_id=record.commit_id, branch=record.branch,
321 snapshot_id=record.snapshot_id, message=record.message,
322 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
323 agent_id=record.agent_id, signature=bad_sig,
324 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
325 )
326 _force_write_commit(root, tampered)
327 result = _verify_one(root, commit_id)
328 assert result["valid"] is False
329
330 def test_unknown_signature_algorithm(self, tmp_path: pathlib.Path) -> None:
331 """A commit whose signature carries an unknown algorithm prefix returns valid=False."""
332 from muse.cli.commands.verify_commit import _verify_one
333 root = _init_repo(tmp_path)
334 key = _make_key()
335 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
336 _, raw_sig_bytes = decode_sig(record.signature)
337 unknown_sig = encode_sig("mldsa65", raw_sig_bytes)
338 tampered = CommitRecord(
339 commit_id=record.commit_id, branch=record.branch,
340 snapshot_id=record.snapshot_id, message=record.message,
341 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
342 agent_id=record.agent_id, signature=unknown_sig,
343 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
344 )
345 _force_write_commit(root, tampered)
346 result = _verify_one(root, commit_id)
347 assert result["valid"] is False
348 assert "mldsa65" in (result.get("error") or "")
349
350 def test_unknown_public_key_algorithm(self, tmp_path: pathlib.Path) -> None:
351 """A commit with an unknown pubkey algorithm prefix returns valid=False with error."""
352 from muse.cli.commands.verify_commit import _verify_one
353 from muse.core.types import encode_pubkey, encode_sig
354 from muse.core.ids import hash_snapshot
355 from muse.core.snapshots import (
356 SnapshotRecord,
357 write_snapshot,
358 )
359 root = _init_repo(tmp_path)
360
361 # Build commit with mldsa65 key from scratch so commit_id is consistent.
362 fake_mldsa_key = encode_pubkey("mldsa65", b"\xab" * 32)
363 fake_sig = encode_sig("ed25519", b"\x00" * 64)
364 snap_id = hash_snapshot({"a.py": blob_id(b"x = 1\n")})
365 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest={"a.py": blob_id(b"x = 1\n")}))
366 import datetime
367 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
368 commit_id = hash_commit( parent_ids=[], snapshot_id=snap_id,
369 message="mldsa-test", committed_at_iso=committed_at.isoformat(),
370 signer_public_key=fake_mldsa_key,
371 )
372 record = CommitRecord(
373 commit_id=commit_id, branch="main",
374 snapshot_id=snap_id, message="mldsa-test", committed_at=committed_at,
375 agent_id="test-agent", signature=fake_sig,
376 signer_public_key=fake_mldsa_key,
377 )
378 commit_path(root, commit_id).parent.mkdir(parents=True, exist_ok=True)
379 _force_write_commit(root, record)
380 (heads_dir(root) / "main").write_text(commit_id, encoding="utf-8")
381
382 result = _verify_one(root, commit_id)
383 assert result["valid"] is False
384 assert "mldsa65" in (result.get("error") or "")
385
386 def test_committed_at_tamper_invalidates_signature(self, tmp_path: pathlib.Path) -> None:
387 """Mutating committed_at in the stored record must invalidate the signature."""
388 from muse.cli.commands.verify_commit import _verify_one
389 root = _init_repo(tmp_path)
390 key = _make_key()
391 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
392 # Shift committed_at by one day.
393 original_ts = record.committed_at
394 tampered_ts = original_ts + datetime.timedelta(days=1)
395 tampered = CommitRecord(
396 commit_id=record.commit_id, branch=record.branch,
397 snapshot_id=record.snapshot_id, message=record.message,
398 committed_at=tampered_ts, # mutated
399 parent_commit_id=record.parent_commit_id,
400 agent_id=record.agent_id, signature=record.signature,
401 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
402 )
403 _force_write_commit(root, tampered)
404 result = _verify_one(root, commit_id)
405 assert result["valid"] is False
406
407 def test_agent_id_tamper_invalidates_signature(self, tmp_path: pathlib.Path) -> None:
408 """Changing agent_id in the stored record must invalidate the signature."""
409 from muse.cli.commands.verify_commit import _verify_one
410 root = _init_repo(tmp_path)
411 key = _make_key()
412 commit_id, record = _commit_files(
413 root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, agent_id="agent-A"
414 )
415 tampered = CommitRecord(
416 commit_id=record.commit_id, branch=record.branch,
417 snapshot_id=record.snapshot_id, message=record.message,
418 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
419 agent_id="agent-B", # tampered
420 signature=record.signature,
421 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
422 )
423 _force_write_commit(root, tampered)
424 result = _verify_one(root, commit_id)
425 assert result["valid"] is False
426
427 def test_model_id_tamper_invalidates_signature(self, tmp_path: pathlib.Path) -> None:
428 """Changing model_id in the stored record must invalidate the signature."""
429 from muse.cli.commands.verify_commit import _verify_one
430 root = _init_repo(tmp_path)
431 key = _make_key()
432 commit_id, record = _commit_files(
433 root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, model_id="claude-sonnet-4-6"
434 )
435 tampered = CommitRecord(
436 commit_id=record.commit_id, branch=record.branch,
437 snapshot_id=record.snapshot_id, message=record.message,
438 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
439 agent_id=record.agent_id, model_id="claude-opus-4-6", # tampered
440 signature=record.signature,
441 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
442 )
443 _force_write_commit(root, tampered)
444 result = _verify_one(root, commit_id)
445 assert result["valid"] is False
446
447 def test_wrong_keypair_invalid(self, tmp_path: pathlib.Path) -> None:
448 """Public key from a different keypair must not verify the signature."""
449 from muse.cli.commands.verify_commit import _verify_one
450 root = _init_repo(tmp_path)
451 signing_key = _make_key()
452 wrong_key = _make_key()
453 commit_id, record = _commit_files(
454 root, {"a.py": b"x = 1\n"}, sign=True, private_key=signing_key
455 )
456 # Swap in the public key from wrong_key.
457 _, wrong_pub_b64 = encode_public_key(wrong_key)
458 tampered = CommitRecord(
459 commit_id=record.commit_id, branch=record.branch,
460 snapshot_id=record.snapshot_id, message=record.message,
461 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
462 agent_id=record.agent_id, signature=record.signature,
463 signer_public_key=wrong_pub_b64, signer_key_id=record.signer_key_id,
464 )
465 _force_write_commit(root, tampered)
466 result = _verify_one(root, commit_id)
467 assert result["valid"] is False
468
469 def test_signed_at_populated_for_signed_commit(self, tmp_path: pathlib.Path) -> None:
470 """signed_at is a non-empty ISO string for a signed commit."""
471 from muse.cli.commands.verify_commit import _verify_one
472 root = _init_repo(tmp_path)
473 key = _make_key()
474 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
475 result = _verify_one(root, commit_id)
476 assert result["signed_at"]
477 assert "T" in result["signed_at"] # ISO 8601 format
478
479 def test_error_none_for_valid_commit(self, tmp_path: pathlib.Path) -> None:
480 """error field is None when the signature is valid."""
481 from muse.cli.commands.verify_commit import _verify_one
482 root = _init_repo(tmp_path)
483 key = _make_key()
484 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
485 result = _verify_one(root, commit_id)
486 assert result["error"] is None
487
488 def test_key_status_unknown_without_hub(self, tmp_path: pathlib.Path) -> None:
489 from muse.cli.commands.verify_commit import _verify_one
490 root = _init_repo(tmp_path)
491 key = _make_key()
492 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
493 result = _verify_one(root, commit_id, check_key_status=True, hub_url=None)
494 assert result["key_status"] == "unknown"
495
496 def test_key_status_cache_hit_skips_network(self, tmp_path: pathlib.Path) -> None:
497 """Cache hit must prevent a second _fetch_key_status call."""
498 from muse.cli.commands.verify_commit import _verify_one
499 root = _init_repo(tmp_path)
500 key = _make_key()
501 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
502 cache: dict[str, str] = {}
503 call_count = 0
504
505 def mock_fetch(hub_url: str, key_id: str) -> str:
506 nonlocal call_count
507 call_count += 1
508 return "active"
509
510 with patch("muse.cli.commands.verify_commit._fetch_key_status", side_effect=mock_fetch):
511 _verify_one(root, commit_id, check_key_status=True, hub_url="http://fake", key_status_cache=cache)
512 _verify_one(root, commit_id, check_key_status=True, hub_url="http://fake", key_status_cache=cache)
513
514 assert call_count == 1
515
516 def test_json_schema_all_keys_present(self, tmp_path: pathlib.Path) -> None:
517 from muse.cli.commands.verify_commit import _verify_one
518 root = _init_repo(tmp_path)
519 key = _make_key()
520 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
521 result = _verify_one(root, commit_id)
522 assert {"commit_id", "valid", "signer", "key_id", "signed_at", "key_status", "error"} == set(result)
523
524
525 # ---------------------------------------------------------------------------
526 # Unit — _fetch_key_status
527 # ---------------------------------------------------------------------------
528
529
530 class TestFetchKeyStatus:
531 def test_returns_active(self) -> None:
532 from muse.cli.commands.verify_commit import _fetch_key_status
533 mock_resp = MagicMock()
534 mock_resp.read.return_value = json.dumps({"status": "active"}).encode()
535 mock_resp.__enter__ = lambda s: s
536 mock_resp.__exit__ = MagicMock(return_value=False)
537 with patch("urllib.request.urlopen", return_value=mock_resp):
538 assert _fetch_key_status("http://hub", "key123") == "active"
539
540 def test_returns_revoked(self) -> None:
541 from muse.cli.commands.verify_commit import _fetch_key_status
542 mock_resp = MagicMock()
543 mock_resp.read.return_value = json.dumps({"status": "revoked"}).encode()
544 mock_resp.__enter__ = lambda s: s
545 mock_resp.__exit__ = MagicMock(return_value=False)
546 with patch("urllib.request.urlopen", return_value=mock_resp):
547 assert _fetch_key_status("http://hub", "key123") == "revoked"
548
549 def test_returns_unknown_for_unrecognised_status(self) -> None:
550 from muse.cli.commands.verify_commit import _fetch_key_status
551 mock_resp = MagicMock()
552 mock_resp.read.return_value = json.dumps({"status": "pending"}).encode()
553 mock_resp.__enter__ = lambda s: s
554 mock_resp.__exit__ = MagicMock(return_value=False)
555 with patch("urllib.request.urlopen", return_value=mock_resp):
556 assert _fetch_key_status("http://hub", "key123") == "unknown"
557
558 def test_returns_unknown_on_network_error(self) -> None:
559 from muse.cli.commands.verify_commit import _fetch_key_status
560 with patch("urllib.request.urlopen", side_effect=OSError("connection refused")):
561 assert _fetch_key_status("http://hub", "key123") == "unknown"
562
563 def test_returns_unknown_on_timeout(self) -> None:
564 from muse.cli.commands.verify_commit import _fetch_key_status
565 import socket
566 with patch("urllib.request.urlopen", side_effect=socket.timeout("timed out")):
567 assert _fetch_key_status("http://hub", "key123") == "unknown"
568
569 def test_returns_unknown_on_invalid_json(self) -> None:
570 from muse.cli.commands.verify_commit import _fetch_key_status
571 mock_resp = MagicMock()
572 mock_resp.read.return_value = b"not json"
573 mock_resp.__enter__ = lambda s: s
574 mock_resp.__exit__ = MagicMock(return_value=False)
575 with patch("urllib.request.urlopen", return_value=mock_resp):
576 assert _fetch_key_status("http://hub", "key123") == "unknown"
577
578
579 # ---------------------------------------------------------------------------
580 # Integration — text output
581 # ---------------------------------------------------------------------------
582
583
584 class TestTextOutput:
585 def test_ok_line_format(self, tmp_path: pathlib.Path) -> None:
586 """Text output: 'OK <short_id> signer=<agent_id> key=<key_id>'"""
587 root = _init_repo(tmp_path)
588 key = _make_key()
589 commit_id, _ = _commit_files(
590 root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, agent_id="claude-code"
591 )
592 result = _invoke(root, commit_id)
593 assert result.exit_code == 0
594 assert "OK" in result.output
595 assert "claude-code" in result.output
596 assert "key=" in result.output
597
598 def test_bad_line_for_invalid_signature(self, tmp_path: pathlib.Path) -> None:
599 root = _init_repo(tmp_path)
600 key = _make_key()
601 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
602 algo, sig_bytes = decode_sig(record.signature)
603 flipped = bytes([sig_bytes[0] ^ 0xFF]) + sig_bytes[1:]
604 tampered = CommitRecord(
605 commit_id=record.commit_id, branch=record.branch,
606 snapshot_id=record.snapshot_id, message=record.message,
607 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
608 agent_id=record.agent_id, signature=encode_sig(algo, flipped),
609 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
610 )
611 _force_write_commit(root, tampered)
612 result = _invoke(root, commit_id)
613 assert result.exit_code != 0
614 assert "BAD" in result.output
615
616 def test_err_line_for_missing_commit(self, tmp_path: pathlib.Path) -> None:
617 """Text output: 'ERR <short_id> (commit not found)'"""
618 root = _init_repo(tmp_path)
619 key = _make_key()
620 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
621 # Write a ref that points to a commit that doesn't exist.
622 ghost_id = blob_id(b"ghost commit that does not exist")
623 result = _invoke(root, ghost_id)
624 assert result.exit_code != 0
625 assert "ERR" in result.output
626
627 def test_unsigned_shows_unsigned_signer(self, tmp_path: pathlib.Path) -> None:
628 """Unsigned commits show '(unsigned)' as the signer in text output."""
629 root = _init_repo(tmp_path)
630 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=False)
631 result = _invoke(root, commit_id)
632 assert result.exit_code == 0
633 assert "(unsigned)" in result.output
634
635 def test_key_absent_from_text_output_for_unsigned(self, tmp_path: pathlib.Path) -> None:
636 """key= part must not appear in text output for unsigned commits."""
637 root = _init_repo(tmp_path)
638 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=False)
639 result = _invoke(root, commit_id)
640 assert "key=" not in result.output
641
642 def test_short_commit_id_in_text_output(self, tmp_path: pathlib.Path) -> None:
643 """Text output uses short_id, not the full 71-char commit ID."""
644 root = _init_repo(tmp_path)
645 key = _make_key()
646 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
647 result = _invoke(root, commit_id)
648 assert commit_id not in result.output # full ID not present
649 assert commit_id[len("sha256:"):len("sha256:") + 12] in result.output # short hex present
650
651
652 # ---------------------------------------------------------------------------
653 # Integration — JSON output
654 # ---------------------------------------------------------------------------
655
656
657 class TestJsonOutput:
658 def test_valid_commit_all_fields(self, tmp_path: pathlib.Path) -> None:
659 root = _init_repo(tmp_path)
660 key = _make_key()
661 commit_id, _ = _commit_files(
662 root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, agent_id="claude-code"
663 )
664 result = _invoke(root, commit_id, "--json")
665 assert result.exit_code == 0
666 data = json.loads(result.stdout)
667 assert data["commit_id"] == commit_id
668 assert data["valid"] is True
669 assert data["signer"] == "claude-code"
670 assert data["key_id"]
671 assert data["key_status"] == "unknown"
672
673 def test_signed_at_non_empty_for_signed_commit(self, tmp_path: pathlib.Path) -> None:
674 root = _init_repo(tmp_path)
675 key = _make_key()
676 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
677 result = _invoke(root, commit_id, "--json")
678 data = json.loads(result.stdout)
679 assert data["signed_at"]
680 assert "T" in data["signed_at"]
681
682 def test_error_field_stripped_from_json_output(self, tmp_path: pathlib.Path) -> None:
683 """The internal 'error' field must not appear in emitted JSON."""
684 root = _init_repo(tmp_path)
685 key = _make_key()
686 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
687 result = _invoke(root, commit_id, "--json")
688 data = json.loads(result.stdout)
689 assert "error" not in data
690
691 def test_duration_ms_and_exit_code_in_json(self, tmp_path: pathlib.Path) -> None:
692 """duration_ms and exit_code are present in every JSON result line."""
693 root = _init_repo(tmp_path)
694 key = _make_key()
695 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
696 result = _invoke(root, commit_id, "--json")
697 data = json.loads(result.stdout)
698 assert "duration_ms" in data
699 assert isinstance(data["duration_ms"], float)
700 assert data["duration_ms"] >= 0
701 assert "exit_code" in data
702 assert data["exit_code"] == 0
703
704 def test_exit_code_nonzero_in_json_on_failure(self, tmp_path: pathlib.Path) -> None:
705 """exit_code reflects the actual exit status — non-zero when verification fails."""
706 root = _init_repo(tmp_path)
707 result = _invoke(root, blob_id(b"nonexistent commit"), "--json")
708 data = json.loads(result.stdout)
709 assert data["exit_code"] != 0
710 assert data["duration_ms"] >= 0
711
712 def test_unsigned_commit_json(self, tmp_path: pathlib.Path) -> None:
713 root = _init_repo(tmp_path)
714 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=False)
715 result = _invoke(root, commit_id, "--json")
716 assert result.exit_code == 0
717 data = json.loads(result.stdout)
718 assert data["valid"] is False
719 assert data["signer"] == ""
720 assert "error" not in data
721
722 def test_batch_each_line_is_valid_json(self, tmp_path: pathlib.Path) -> None:
723 """Batch output: one valid JSON object per line."""
724 root = _init_repo(tmp_path)
725 key = _make_key()
726 ids = [_commit_files(root, {f"f{i}.py": f"x={i}".encode()}, sign=True, private_key=key)[0]
727 for i in range(3)]
728 result = _invoke(root, *ids, "--json")
729 assert result.exit_code == 0
730 lines = [l for l in result.stdout.strip().splitlines() if l]
731 assert len(lines) == 3
732 for line in lines:
733 obj = json.loads(line)
734 assert obj["valid"] is True
735
736 def test_batch_results_in_submission_order(self, tmp_path: pathlib.Path) -> None:
737 """Batch results must arrive in the same order as the input commit IDs."""
738 root = _init_repo(tmp_path)
739 key = _make_key()
740 ids = [_commit_files(root, {f"f{i}.py": f"x={i}".encode()}, sign=True, private_key=key)[0]
741 for i in range(5)]
742 result = _invoke(root, *ids, "--json")
743 assert result.exit_code == 0
744 lines = [l for l in result.stdout.strip().splitlines() if l]
745 returned_ids = [json.loads(l)["commit_id"] for l in lines]
746 assert returned_ids == ids
747
748 def test_json_flag_alias(self, tmp_path: pathlib.Path) -> None:
749 """--json is accepted and produces JSON output."""
750 root = _init_repo(tmp_path)
751 key = _make_key()
752 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
753 result = _invoke(root, "--json", commit_id)
754 assert result.exit_code == 0
755 assert "valid" in json.loads(result.stdout)
756
757
758 # ---------------------------------------------------------------------------
759 # Integration — HEAD and branch name refs
760 # ---------------------------------------------------------------------------
761
762
763 class TestRefResolution:
764 def test_head_shorthand(self, tmp_path: pathlib.Path) -> None:
765 root = _init_repo(tmp_path)
766 key = _make_key()
767 _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
768 result = _invoke(root, "HEAD", "--json")
769 assert result.exit_code == 0
770 assert json.loads(result.stdout)["valid"] is True
771
772 def test_branch_name_ref(self, tmp_path: pathlib.Path) -> None:
773 root = _init_repo(tmp_path)
774 key = _make_key()
775 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, branch="dev")
776 result = _invoke(root, "dev", "--json")
777 assert result.exit_code == 0
778 data = json.loads(result.stdout)
779 assert data["commit_id"] == commit_id
780 assert data["valid"] is True
781
782 def test_nonexistent_sha256_ref_exits_user_error(self, tmp_path: pathlib.Path) -> None:
783 """A sha256:-prefixed ID that doesn't exist in the store exits USER_ERROR."""
784 root = _init_repo(tmp_path)
785 key = _make_key()
786 _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
787 ghost = blob_id(b"ghost commit")
788 result = _invoke(root, ghost, "--json")
789 assert result.exit_code != 0
790 data = json.loads(result.stdout)
791 assert data["valid"] is False
792
793 def test_nonexistent_branch_exits_user_error(self, tmp_path: pathlib.Path) -> None:
794 root = _init_repo(tmp_path)
795 result = _invoke(root, "no-such-branch")
796 assert result.exit_code != 0
797 assert result.stdout_bytes == b"" # error went to stderr
798
799
800 # ---------------------------------------------------------------------------
801 # Integration — --strict
802 # ---------------------------------------------------------------------------
803
804
805 class TestStrictMode:
806 def test_unsigned_no_strict_exits_0(self, tmp_path: pathlib.Path) -> None:
807 root = _init_repo(tmp_path)
808 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=False)
809 result = _invoke(root, commit_id, "--json")
810 assert result.exit_code == 0
811 assert json.loads(result.stdout)["valid"] is False
812
813 def test_unsigned_strict_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
814 root = _init_repo(tmp_path)
815 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=False)
816 result = _invoke(root, commit_id, "--strict")
817 assert result.exit_code != 0
818
819 def test_signed_strict_exits_0(self, tmp_path: pathlib.Path) -> None:
820 root = _init_repo(tmp_path)
821 key = _make_key()
822 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
823 result = _invoke(root, commit_id, "--strict")
824 assert result.exit_code == 0
825
826 def test_batch_one_unsigned_strict_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
827 root = _init_repo(tmp_path)
828 key = _make_key()
829 cid1, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
830 cid2, _ = _commit_files(root, {"a.py": b"x = 2\n"}, sign=False)
831 result = _invoke(root, cid1, cid2, "--strict")
832 assert result.exit_code != 0
833
834
835 # ---------------------------------------------------------------------------
836 # Integration — --check-key-status
837 # ---------------------------------------------------------------------------
838
839
840 class TestCheckKeyStatus:
841 def test_unknown_without_hub(self, tmp_path: pathlib.Path) -> None:
842 root = _init_repo(tmp_path)
843 key = _make_key()
844 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
845 result = _invoke(root, commit_id, "--check-key-status", "--json")
846 assert result.exit_code == 0
847 assert json.loads(result.stdout)["key_status"] == "unknown"
848
849
850
851 # ---------------------------------------------------------------------------
852 # Security
853 # ---------------------------------------------------------------------------
854
855
856 class TestSecurity:
857 def test_ansi_in_ref_rejected(self, tmp_path: pathlib.Path) -> None:
858 root = _init_repo(tmp_path)
859 result = _invoke(root, "\x1b[31mbad\x1b[0m")
860 assert result.exit_code != 0
861 assert result.stdout_bytes == b"" # error went to stderr
862
863 def test_null_byte_in_ref_rejected(self, tmp_path: pathlib.Path) -> None:
864 root = _init_repo(tmp_path)
865 result = _invoke(root, "sha256:abc\x00def")
866 assert result.exit_code != 0
867
868 def test_path_traversal_in_ref_rejected(self, tmp_path: pathlib.Path) -> None:
869 root = _init_repo(tmp_path)
870 result = _invoke(root, "../../etc/passwd")
871 assert result.exit_code != 0
872 assert result.stdout_bytes == b""
873
874 def test_bare_hex_ref_rejected_with_message(self, tmp_path: pathlib.Path) -> None:
875 """Bare hex without sha256: prefix → rejected; message mentions sha256:."""
876 root = _init_repo(tmp_path)
877 bare = "a" * 64
878 result = _invoke(root, bare)
879 assert result.exit_code != 0
880 assert result.stdout_bytes == b""
881 assert "sha256:" in result.stderr
882
883 def test_no_traceback_on_bad_ref(self, tmp_path: pathlib.Path) -> None:
884 root = _init_repo(tmp_path)
885 result = _invoke(root, "not-a-real-ref")
886 assert "Traceback" not in result.output
887 assert "Traceback" not in result.stderr
888
889
890 # ---------------------------------------------------------------------------
891 # Data integrity — full CLI flow
892 # ---------------------------------------------------------------------------
893
894
895 class TestDataIntegrity:
896 def test_committed_at_tamper_fails_cli(self, tmp_path: pathlib.Path) -> None:
897 """committed_at mutation detected through the full CLI verify-commit flow."""
898 root = _init_repo(tmp_path)
899 key = _make_key()
900 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
901 tampered_ts = record.committed_at + datetime.timedelta(seconds=1)
902 tampered = CommitRecord(
903 commit_id=record.commit_id, branch=record.branch,
904 snapshot_id=record.snapshot_id, message=record.message,
905 committed_at=tampered_ts,
906 parent_commit_id=record.parent_commit_id,
907 agent_id=record.agent_id, signature=record.signature,
908 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
909 )
910 _force_write_commit(root, tampered)
911 result = _invoke(root, commit_id, "--json")
912 assert result.exit_code != 0
913 assert json.loads(result.stdout)["valid"] is False
914
915 def test_model_id_tamper_fails_cli(self, tmp_path: pathlib.Path) -> None:
916 root = _init_repo(tmp_path)
917 key = _make_key()
918 commit_id, record = _commit_files(
919 root, {"a.py": b"x = 1\n"}, sign=True, private_key=key, model_id="claude-sonnet-4-6"
920 )
921 tampered = CommitRecord(
922 commit_id=record.commit_id, branch=record.branch,
923 snapshot_id=record.snapshot_id, message=record.message,
924 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
925 agent_id=record.agent_id, model_id="gpt-5", # tampered
926 signature=record.signature,
927 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
928 )
929 _force_write_commit(root, tampered)
930 result = _invoke(root, commit_id, "--json")
931 assert result.exit_code != 0
932 assert json.loads(result.stdout)["valid"] is False
933
934 def test_wrong_keypair_fails_cli(self, tmp_path: pathlib.Path) -> None:
935 """Public key from a different keypair → invalid through full CLI flow."""
936 root = _init_repo(tmp_path)
937 signing_key = _make_key()
938 wrong_key = _make_key()
939 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=signing_key)
940 _, wrong_pub_b64 = encode_public_key(wrong_key)
941 tampered = CommitRecord(
942 commit_id=record.commit_id, branch=record.branch,
943 snapshot_id=record.snapshot_id, message=record.message,
944 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
945 agent_id=record.agent_id, signature=record.signature,
946 signer_public_key=wrong_pub_b64, signer_key_id=record.signer_key_id,
947 )
948 _force_write_commit(root, tampered)
949 result = _invoke(root, commit_id, "--json")
950 assert result.exit_code != 0
951 assert json.loads(result.stdout)["valid"] is False
952
953 def test_ed25519_prefix_survives_store_roundtrip(self, tmp_path: pathlib.Path) -> None:
954 """Signature and public key must keep 'ed25519:' prefix through write→read."""
955 from muse.core.commits import read_commit
956 root = _init_repo(tmp_path)
957 key = _make_key()
958 commit_id, _ = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
959 reloaded = read_commit(root, commit_id)
960 assert reloaded is not None
961 assert reloaded.signature.startswith("ed25519:")
962 assert reloaded.signer_public_key.startswith("ed25519:")
963
964 def test_force_writecommit_path_matches_read_commit(self, tmp_path: pathlib.Path) -> None:
965 """_force_write_commit must write to the path that read_commit reads from."""
966 from muse.core.commits import read_commit
967 root = _init_repo(tmp_path)
968 key = _make_key()
969 commit_id, record = _commit_files(root, {"a.py": b"x = 1\n"}, sign=True, private_key=key)
970 sentinel = CommitRecord(
971 commit_id=record.commit_id, branch=record.branch,
972 snapshot_id=record.snapshot_id, message=record.message,
973 committed_at=record.committed_at, parent_commit_id=record.parent_commit_id,
974 agent_id="sentinel-agent", signature=record.signature,
975 signer_public_key=record.signer_public_key, signer_key_id=record.signer_key_id,
976 )
977 _force_write_commit(root, sentinel)
978 reloaded = read_commit(root, commit_id)
979 assert reloaded is not None
980 assert reloaded.agent_id == "sentinel-agent"
981
982
983 # ---------------------------------------------------------------------------
984 # Stress
985 # ---------------------------------------------------------------------------
986
987
988 class TestStress:
989 def test_100_signed_commits_all_valid(self, tmp_path: pathlib.Path) -> None:
990 """100 signed commits all verify correctly (unit path)."""
991 from muse.cli.commands.verify_commit import _verify_one
992 root = _init_repo(tmp_path)
993 key = _make_key()
994 for i in range(100):
995 commit_id, _ = _commit_files(
996 root, {f"f{i}.py": f"v = {i}\n".encode()}, sign=True, private_key=key
997 )
998 result = _verify_one(root, commit_id)
999 assert result["valid"] is True, f"commit {i} failed"
1000
1001 def test_batch_50_commits_all_results_emitted(self, tmp_path: pathlib.Path) -> None:
1002 """Batch of 50 commits → CLI emits exactly 50 JSON lines."""
1003 root = _init_repo(tmp_path)
1004 key = _make_key()
1005 ids = [
1006 _commit_files(root, {f"f{i}.py": f"x={i}".encode()}, sign=True, private_key=key)[0]
1007 for i in range(50)
1008 ]
1009 result = _invoke(root, *ids, "--json")
1010 assert result.exit_code == 0
1011 lines = [l for l in result.stdout.strip().splitlines() if l]
1012 assert len(lines) == 50
1013 assert all(json.loads(l)["valid"] for l in lines)
1014
1015 def test_10_different_keys_all_valid(self, tmp_path: pathlib.Path) -> None:
1016 """Each commit signed with a different key verifies independently."""
1017 from muse.cli.commands.verify_commit import _verify_one
1018 root = _init_repo(tmp_path)
1019 for i in range(10):
1020 key = _make_key()
1021 commit_id, _ = _commit_files(
1022 root, {f"k{i}.py": f"v={i}".encode()}, sign=True, private_key=key
1023 )
1024 result = _verify_one(root, commit_id)
1025 assert result["valid"] is True, f"key {i} failed"
1026
1027 def test_key_status_cache_n_commits_same_key_one_call(self, tmp_path: pathlib.Path) -> None:
1028 """N commits sharing a key_id → exactly 1 network call via cache."""
1029 from muse.cli.commands.verify_commit import _verify_one
1030 root = _init_repo(tmp_path)
1031 key = _make_key()
1032 call_count = 0
1033
1034 def mock_fetch(hub_url: str, key_id: str) -> str:
1035 nonlocal call_count
1036 call_count += 1
1037 return "active"
1038
1039 cache: dict[str, str] = {}
1040 with patch("muse.cli.commands.verify_commit._fetch_key_status", side_effect=mock_fetch):
1041 for i in range(10):
1042 commit_id, _ = _commit_files(
1043 root, {f"f{i}.py": f"x={i}".encode()}, sign=True, private_key=key
1044 )
1045 _verify_one(root, commit_id, check_key_status=True, hub_url="http://fake", key_status_cache=cache)
1046
1047 assert call_count == 1, f"expected 1 network call, got {call_count}"
1048
1049
1050 # ---------------------------------------------------------------------------
1051 # Flag registration
1052 # ---------------------------------------------------------------------------
1053
1054
1055 class TestRegisterFlags:
1056 def _parse(self, *args: str) -> "argparse.Namespace":
1057 import argparse
1058 from muse.cli.commands.verify_commit import register
1059 p = argparse.ArgumentParser()
1060 sub = p.add_subparsers()
1061 register(sub)
1062 return p.parse_args(["verify-commit", *args])
1063
1064 def test_default_json_out_is_false(self) -> None:
1065 ns = self._parse("HEAD")
1066 assert ns.json_out is False
1067
1068 def test_json_flag_sets_json_out(self) -> None:
1069 ns = self._parse("HEAD", "--json")
1070 assert ns.json_out is True
1071
1072 def test_j_shorthand_sets_json_out(self) -> None:
1073 ns = self._parse("HEAD", "-j")
1074 assert ns.json_out is True