gabriel / muse public
test_cmd_bundle_hardening.py python
2,115 lines 84.9 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days ago
1 """Hardening tests for ``muse bundle``.
2
3 Covers:
4 Unit — _iter_branches (symlink guard, size cap), _reachable_from,
5 _load_bundle (narrow except), _resolve_refs, TypeGuards
6 Security — symlink traversal in _iter_branches, ANSI injection in
7 branch names and failure messages, oversized bundle rejection
8 Perf — reachable set is pre-computed once (not per branch)
9 JSON — _BundleCreateJson, _BundleUnbundleJson, _BundleVerifyJson,
10 list-heads dict schema
11 Flags — --json for create / unbundle / verify / list-heads
12 Integration — create → unbundle round-trip with branch ref updates,
13 --have pruning reduces bundle size,
14 verify catches corruption and missing snapshot objects
15 E2E — --help output for all subcommands
16 Stress — 200-commit chain, concurrent unbundle reads
17 """
18
19 from __future__ import annotations
20
21 import datetime
22 import hashlib
23 import json
24 import pathlib
25 import threading
26 from typing import TypedDict
27
28 import msgpack
29 import pytest
30 from tests.cli_test_helper import CliRunner, InvokeResult
31
32 from muse.core.object_store import write_object
33 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
34 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
35 from muse.core.types import Manifest, long_id, blob_id
36
37 cli = None
38 runner = CliRunner()
39 _invoke_lock = threading.Lock()
40
41 _REPO_ID = "bundle-hardening-test"
42
43
44 # ---------------------------------------------------------------------------
45 # Helpers
46 # ---------------------------------------------------------------------------
47
48
49 class _CreateOut(TypedDict):
50 file: str
51 commits: int
52 objects: int
53 size_bytes: int
54 branches: list[str]
55
56
57 class _UnbundleOut(TypedDict):
58 commits_written: int
59 snapshots_written: int
60 objects_written: int
61 objects_skipped: int
62 refs_updated: list[str]
63
64
65 class _VerifyOut(TypedDict):
66 objects_checked: int
67 snapshots_checked: int
68 all_ok: bool
69 failures: list[str]
70
71
72
73
74 def _init_repo(path: pathlib.Path, repo_id: str = _REPO_ID) -> pathlib.Path:
75 muse = muse_dir(path)
76 for d in ("commits", "snapshots", "objects", "refs/heads"):
77 (muse / d).mkdir(parents=True, exist_ok=True)
78 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
79 (muse / "repo.json").write_text(
80 json.dumps({"repo_id": repo_id, "domain": "midi"}), encoding="utf-8"
81 )
82 return path
83
84
85 def _env(repo: pathlib.Path) -> Manifest:
86 return {"MUSE_REPO_ROOT": str(repo)}
87
88
89 _counter = 0
90 _branch_heads_map: dict[tuple[str, str], str] = {}
91
92
93 def _make_commit(
94 root: pathlib.Path,
95 parent_id: str | None = None,
96 content: bytes = b"data",
97 branch: str = "main",
98 ) -> str:
99 global _counter
100 _counter += 1
101 c = content + str(_counter).encode()
102 obj_id = long_id(blob_id(c))
103 write_object(root, obj_id, c)
104 manifest = {f"f_{_counter}.txt": obj_id}
105 snap_id = compute_snapshot_id(manifest)
106 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
107 committed_at = datetime.datetime.now(datetime.timezone.utc)
108
109 resolved_parent = parent_id
110 if resolved_parent is None:
111 key = (str(root), branch)
112 resolved_parent = _branch_heads_map.get(key)
113
114 parent_ids = [resolved_parent] if resolved_parent else []
115 commit_id = compute_commit_id( parent_ids=parent_ids,
116 snapshot_id=snap_id,
117 message=f"commit {_counter}",
118 committed_at_iso=committed_at.isoformat(),
119 )
120 write_commit(
121 root,
122 CommitRecord(
123 commit_id=commit_id,
124 repo_id="test-repo",
125 branch=branch,
126 snapshot_id=snap_id,
127 message=f"commit {_counter}",
128 committed_at=committed_at,
129 parent_commit_id=resolved_parent,
130 ),
131 )
132 branch_ref = ref_path(root, branch)
133 branch_ref.parent.mkdir(parents=True, exist_ok=True)
134 branch_ref.write_text(commit_id, encoding="utf-8")
135 _branch_heads_map[(str(root), branch)] = commit_id
136 return commit_id
137
138
139 def _invoke(args: list[str], env: Manifest | None = None) -> InvokeResult:
140 with _invoke_lock:
141 return runner.invoke(cli, args, env=env)
142
143
144 def _parse_create(result: InvokeResult) -> _CreateOut:
145 raw: _CreateOut = json.loads(result.output)
146 return raw
147
148
149 def _parse_unbundle(result: InvokeResult) -> _UnbundleOut:
150 raw: _UnbundleOut = json.loads(result.output)
151 return raw
152
153
154 def _parse_verify(result: InvokeResult) -> _VerifyOut:
155 raw: _VerifyOut = json.loads(result.output)
156 return raw
157
158
159 # ---------------------------------------------------------------------------
160 # Unit: _iter_branches — symlink guard
161 # ---------------------------------------------------------------------------
162
163
164 def test_iter_branches_skips_symlinks(tmp_path: pathlib.Path) -> None:
165 """A symlink inside refs/heads must be silently skipped."""
166 from muse.cli.commands.bundle import _iter_branches
167
168 _init_repo(tmp_path)
169 target = tmp_path / "outside.txt"
170 target.write_text("malicious-sha" * 8, encoding="utf-8") # 64 chars
171
172 h_dir = heads_dir(tmp_path)
173 real_ref = h_dir / "main"
174 real_ref.write_text("a" * 64, encoding="utf-8")
175
176 link = h_dir / "malicious"
177 link.symlink_to(target)
178
179 result = _iter_branches(tmp_path)
180 branch_names = [name for name, _ in result]
181 assert "malicious" not in branch_names
182 assert "main" in branch_names
183
184
185 def test_iter_branches_size_cap(tmp_path: pathlib.Path) -> None:
186 """Ref files larger than 65 bytes are read but will be invalid after strip."""
187 from muse.cli.commands.bundle import _iter_branches, _MAX_REF_BYTES
188
189 _init_repo(tmp_path)
190 h_dir = heads_dir(tmp_path)
191 oversized = h_dir / "main"
192 oversized.write_bytes(b"x" * (_MAX_REF_BYTES + 100))
193
194 result = _iter_branches(tmp_path)
195 # Should still return one entry; the content is capped — the commit ID
196 # won't be valid hex but _iter_branches returns it; validation is downstream.
197 assert len(result) == 1
198 _, cid = result[0]
199 assert len(cid) <= _MAX_REF_BYTES # capped at read time
200
201
202 def test_iter_branches_empty_dir(tmp_path: pathlib.Path) -> None:
203 from muse.cli.commands.bundle import _iter_branches
204
205 _init_repo(tmp_path)
206 result = _iter_branches(tmp_path)
207 assert result == []
208
209
210 def test_iter_branches_multiple(tmp_path: pathlib.Path) -> None:
211 from muse.cli.commands.bundle import _iter_branches
212
213 _init_repo(tmp_path)
214 h_dir = heads_dir(tmp_path)
215 for name in ("main", "dev", "feat/foo"):
216 p = h_dir / name
217 p.parent.mkdir(parents=True, exist_ok=True)
218 p.write_text("a" * 64, encoding="utf-8")
219
220 result = _iter_branches(tmp_path)
221 names = [n for n, _ in result]
222 assert "main" in names
223 assert "dev" in names
224 assert "feat/foo" in names
225
226
227 # ---------------------------------------------------------------------------
228 # Unit: _reachable_from — correctness
229 # ---------------------------------------------------------------------------
230
231
232 def test_reachable_from_single(tmp_path: pathlib.Path) -> None:
233 from muse.cli.commands.bundle import _reachable_from
234
235 _init_repo(tmp_path)
236 c1 = _make_commit(tmp_path, content=b"r1")
237 result = _reachable_from(tmp_path, [c1])
238 assert c1 in result
239
240
241 def test_reachable_from_chain(tmp_path: pathlib.Path) -> None:
242 from muse.cli.commands.bundle import _reachable_from
243
244 _init_repo(tmp_path)
245 c1 = _make_commit(tmp_path, content=b"rc1")
246 c2 = _make_commit(tmp_path, parent_id=c1, content=b"rc2")
247 c3 = _make_commit(tmp_path, parent_id=c2, content=b"rc3")
248 result = _reachable_from(tmp_path, [c3])
249 assert c1 in result
250 assert c2 in result
251 assert c3 in result
252
253
254 def test_reachable_from_empty_tips(tmp_path: pathlib.Path) -> None:
255 from muse.cli.commands.bundle import _reachable_from
256
257 _init_repo(tmp_path)
258 assert _reachable_from(tmp_path, []) == set()
259
260
261 # ---------------------------------------------------------------------------
262 # Unit: _load_bundle — narrow except
263 # ---------------------------------------------------------------------------
264
265
266 def test_load_bundle_not_found(tmp_path: pathlib.Path) -> None:
267 result = _invoke(
268 ["bundle", "verify", str(tmp_path / "missing.bundle")],
269 env=_env(tmp_path),
270 )
271 assert result.exit_code != 0
272
273
274 def test_load_bundle_invalid_msgpack(tmp_path: pathlib.Path) -> None:
275 _init_repo(tmp_path)
276 bad = tmp_path / "bad.bundle"
277 bad.write_bytes(b"\xff\xfe this is not msgpack")
278 result = _invoke(["bundle", "verify", str(bad)], env=_env(tmp_path))
279 assert result.exit_code != 0
280
281
282 def test_load_bundle_not_dict(tmp_path: pathlib.Path) -> None:
283 """A valid msgpack list instead of dict must be rejected cleanly."""
284 _init_repo(tmp_path)
285 bad = tmp_path / "list.bundle"
286 bad.write_bytes(msgpack.packb([1, 2, 3], use_bin_type=True))
287 result = _invoke(["bundle", "verify", str(bad)], env=_env(tmp_path))
288 assert result.exit_code != 0
289
290
291 # ---------------------------------------------------------------------------
292 # Security: ANSI injection in branch names
293 # ---------------------------------------------------------------------------
294
295
296 def test_list_heads_ansi_injection(tmp_path: pathlib.Path) -> None:
297 """Branch names with ANSI escapes must be stripped in text output."""
298 _init_repo(tmp_path)
299 _make_commit(tmp_path, content=b"ansi-branch")
300 out = tmp_path / "ansi.bundle"
301 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
302
303 # Inject a crafted branch_heads entry with ANSI escape in branch name.
304 raw = msgpack.unpackb(out.read_bytes(), raw=False)
305 raw["branch_heads"] = {"\x1b[31mmalicious\x1b[0m": "a" * 64}
306 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
307
308 result = _invoke(["bundle", "list-heads", str(out)], env=_env(tmp_path))
309 assert result.exit_code == 0
310 assert "\x1b" not in result.output
311
312
313 def test_verify_failure_ansi_injection(tmp_path: pathlib.Path) -> None:
314 """Failure messages must not allow ANSI injection through object_id fields."""
315 _init_repo(tmp_path)
316 _make_commit(tmp_path, content=b"ansi-verify")
317 out = tmp_path / "ansi-v.bundle"
318 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
319
320 # Tamper content to trigger a hash mismatch failure.
321 raw = msgpack.unpackb(out.read_bytes(), raw=False)
322 if raw.get("objects"):
323 raw["objects"][0]["content"] = b"\x1b[31mTAMPERED\x1b[0m"
324 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
325
326 result = _invoke(["bundle", "verify", str(out)], env=_env(tmp_path))
327 # Text output must strip ANSI from the failure description.
328 assert "\x1b" not in result.output
329 assert result.exit_code != 0
330
331
332 # ---------------------------------------------------------------------------
333 # JSON schema: bundle create --json
334 # ---------------------------------------------------------------------------
335
336
337 def test_create_json_schema(tmp_path: pathlib.Path) -> None:
338 _init_repo(tmp_path)
339 _make_commit(tmp_path, content=b"cj1")
340 out = tmp_path / "cj.bundle"
341 result = _invoke(["bundle", "create", str(out), "--json"], env=_env(tmp_path))
342 assert result.exit_code == 0
343 data = _parse_create(result)
344 assert data["file"] == str(out)
345 assert data["commits"] >= 1
346 assert data["objects"] >= 1
347 assert data["size_bytes"] > 0
348 assert isinstance(data["branches"], list)
349 assert "main" in data["branches"]
350
351
352 def test_create_json_no_output_on_success_without_flag(tmp_path: pathlib.Path) -> None:
353 _init_repo(tmp_path)
354 _make_commit(tmp_path, content=b"cj-no-flag")
355 out = tmp_path / "cnf.bundle"
356 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
357 assert result.exit_code == 0
358 # Text output, not JSON.
359 assert "Bundle" in result.output or "✅" in result.output
360
361
362 # ---------------------------------------------------------------------------
363 # JSON schema: bundle unbundle --json
364 # ---------------------------------------------------------------------------
365
366
367 def test_unbundle_json_schema(tmp_path: pathlib.Path) -> None:
368 src = tmp_path / "src"
369 dst = tmp_path / "dst"
370 src.mkdir()
371 dst.mkdir()
372 _init_repo(src)
373 _init_repo(dst, repo_id="dst-json")
374
375 _make_commit(src, content=b"uj1")
376 out = tmp_path / "uj.bundle"
377 _invoke(["bundle", "create", str(out)], env=_env(src))
378
379 result = _invoke(["bundle", "unbundle", str(out), "--json"], env=_env(dst))
380 assert result.exit_code == 0
381 data = _parse_unbundle(result)
382 assert data["commits_written"] >= 1
383 assert isinstance(data["snapshots_written"], int)
384 assert isinstance(data["objects_written"], int)
385 assert isinstance(data["objects_skipped"], int)
386 assert isinstance(data["refs_updated"], list)
387
388
389 def test_unbundle_json_refs_updated(tmp_path: pathlib.Path) -> None:
390 src = tmp_path / "src"
391 dst = tmp_path / "dst"
392 src.mkdir()
393 dst.mkdir()
394 _init_repo(src)
395 _init_repo(dst, repo_id="dst-ru")
396
397 _make_commit(src, content=b"ru1")
398 out = tmp_path / "ru.bundle"
399 _invoke(["bundle", "create", str(out)], env=_env(src))
400
401 result = _invoke(["bundle", "unbundle", str(out), "--json"], env=_env(dst))
402 assert result.exit_code == 0
403 data = _parse_unbundle(result)
404 assert "main" in data["refs_updated"]
405
406
407 def test_unbundle_json_no_update_refs(tmp_path: pathlib.Path) -> None:
408 src = tmp_path / "src"
409 dst = tmp_path / "dst"
410 src.mkdir()
411 dst.mkdir()
412 _init_repo(src)
413 _init_repo(dst, repo_id="dst-nur")
414
415 _make_commit(src, content=b"nur1")
416 out = tmp_path / "nur.bundle"
417 _invoke(["bundle", "create", str(out)], env=_env(src))
418
419 result = _invoke(
420 ["bundle", "unbundle", str(out), "--no-update-refs", "--json"],
421 env=_env(dst),
422 )
423 assert result.exit_code == 0
424 data = _parse_unbundle(result)
425 assert data["refs_updated"] == []
426
427
428 # ---------------------------------------------------------------------------
429 # JSON schema: bundle verify --json
430 # ---------------------------------------------------------------------------
431
432
433 def test_verify_json_schema_clean(tmp_path: pathlib.Path) -> None:
434 _init_repo(tmp_path)
435 _make_commit(tmp_path, content=b"vjs1")
436 out = tmp_path / "vjs.bundle"
437 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
438 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
439 assert result.exit_code == 0
440 data = _parse_verify(result)
441 assert data["all_ok"] is True
442 assert data["objects_checked"] >= 1
443 assert "snapshots_checked" in data
444 assert data["failures"] == []
445
446
447 def test_verify_json_schema_corrupt(tmp_path: pathlib.Path) -> None:
448 _init_repo(tmp_path)
449 _make_commit(tmp_path, content=b"corrupt-j")
450 out = tmp_path / "cj2.bundle"
451 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
452
453 raw = msgpack.unpackb(out.read_bytes(), raw=False)
454 if raw.get("objects"):
455 raw["objects"][0]["content"] = b"tampered!"
456 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
457
458 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
459 assert result.exit_code != 0
460 data = _parse_verify(result)
461 assert data["all_ok"] is False
462 assert len(data["failures"]) > 0
463
464
465 def test_verify_json_snapshots_checked(tmp_path: pathlib.Path) -> None:
466 """``snapshots_checked`` must count non-zero when snapshots are present."""
467 _init_repo(tmp_path)
468 _make_commit(tmp_path, content=b"snap-counted")
469 out = tmp_path / "sc.bundle"
470 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
471 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
472 assert result.exit_code == 0
473 data = _parse_verify(result)
474 # At least one snapshot should have been included in the bundle.
475 assert data["snapshots_checked"] >= 1
476
477
478 # ---------------------------------------------------------------------------
479 # JSON schema: bundle list-heads --json
480 # ---------------------------------------------------------------------------
481
482
483 def test_list_heads_json_schema(tmp_path: pathlib.Path) -> None:
484 _init_repo(tmp_path)
485 _make_commit(tmp_path, content=b"lhjs1")
486 out = tmp_path / "lhjs.bundle"
487 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
488 result = _invoke(["bundle", "list-heads", str(out), "--json"], env=_env(tmp_path))
489 assert result.exit_code == 0
490 data = json.loads(result.output)
491 assert isinstance(data, dict)
492 heads = data["heads"]
493 assert "main" in heads
494 for _branch, cid in heads.items():
495 assert isinstance(cid, str) and cid.startswith("sha256:")
496 assert len(cid) == len("sha256:") + 64
497
498
499 # ---------------------------------------------------------------------------
500 # Flags: --json rejects old --format arg
501 # ---------------------------------------------------------------------------
502
503
504 def test_verify_rejects_format_flag(tmp_path: pathlib.Path) -> None:
505 """The old ``--format json`` pattern must not be accepted."""
506 _init_repo(tmp_path)
507 _make_commit(tmp_path, content=b"old-fmt")
508 out = tmp_path / "old.bundle"
509 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
510 result = _invoke(
511 ["bundle", "verify", str(out), "--format", "json"], env=_env(tmp_path)
512 )
513 # --format is no longer a registered flag, so argparse returns exit 2.
514 assert result.exit_code == 2
515
516
517 def test_list_heads_rejects_format_flag(tmp_path: pathlib.Path) -> None:
518 _init_repo(tmp_path)
519 _make_commit(tmp_path, content=b"lh-old")
520 out = tmp_path / "lh-old.bundle"
521 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
522 result = _invoke(
523 ["bundle", "list-heads", str(out), "--format", "json"], env=_env(tmp_path)
524 )
525 assert result.exit_code == 2
526
527
528 # ---------------------------------------------------------------------------
529 # Integration: --have pruning
530 # ---------------------------------------------------------------------------
531
532
533 def test_create_have_prunes_bundle(tmp_path: pathlib.Path) -> None:
534 """Passing --have should produce a smaller bundle than the full chain."""
535 _init_repo(tmp_path)
536 c1 = _make_commit(tmp_path, content=b"have-base")
537 _make_commit(tmp_path, parent_id=c1, content=b"have-tip")
538
539 out_full = tmp_path / "full.bundle"
540 out_pruned = tmp_path / "pruned.bundle"
541
542 _invoke(["bundle", "create", str(out_full)], env=_env(tmp_path))
543 _invoke(
544 ["bundle", "create", str(out_pruned), "--have", c1],
545 env=_env(tmp_path),
546 )
547
548 # Pruned bundle must be smaller (fewer commits packed).
549 assert out_pruned.stat().st_size < out_full.stat().st_size
550
551
552 def test_create_have_json_smaller_commits(tmp_path: pathlib.Path) -> None:
553 _init_repo(tmp_path)
554 c1 = _make_commit(tmp_path, content=b"hjp-base")
555 _make_commit(tmp_path, parent_id=c1, content=b"hjp-tip")
556
557 out_full = tmp_path / "hjp-full.bundle"
558 out_pruned = tmp_path / "hjp-pruned.bundle"
559
560 r_full = _invoke(
561 ["bundle", "create", str(out_full), "--json"], env=_env(tmp_path)
562 )
563 r_pruned = _invoke(
564 ["bundle", "create", str(out_pruned), "--have", c1, "--json"],
565 env=_env(tmp_path),
566 )
567
568 full_data = _parse_create(r_full)
569 pruned_data = _parse_create(r_pruned)
570 assert pruned_data["commits"] < full_data["commits"]
571
572
573 # ---------------------------------------------------------------------------
574 # Integration: multi-branch bundle
575 # ---------------------------------------------------------------------------
576
577
578 def test_create_multiple_branches(tmp_path: pathlib.Path) -> None:
579 _init_repo(tmp_path)
580 _make_commit(tmp_path, content=b"mb-main", branch="main")
581 _make_commit(tmp_path, content=b"mb-feat", branch="feat/x")
582
583 out = tmp_path / "mb.bundle"
584 result = _invoke(
585 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
586 )
587 assert result.exit_code == 0
588 data = _parse_create(result)
589 assert "main" in data["branches"] or "feat/x" in data["branches"]
590
591
592 def test_round_trip_with_json_summary(tmp_path: pathlib.Path) -> None:
593 """Full create → verify → unbundle pipeline with JSON at each step."""
594 src = tmp_path / "src"
595 dst = tmp_path / "dst"
596 src.mkdir()
597 dst.mkdir()
598 _init_repo(src)
599 _init_repo(dst, repo_id="dst-rt-json")
600
601 prev: str | None = None
602 for i in range(5):
603 prev = _make_commit(src, parent_id=prev, content=f"rt-{i}".encode())
604
605 out = tmp_path / "rt-json.bundle"
606
607 create_result = _invoke(
608 ["bundle", "create", str(out), "--json"], env=_env(src)
609 )
610 assert create_result.exit_code == 0
611 create_data = _parse_create(create_result)
612 assert create_data["commits"] == 5
613
614 verify_result = _invoke(
615 ["bundle", "verify", str(out), "--json"], env=_env(src)
616 )
617 assert verify_result.exit_code == 0
618 verify_data = _parse_verify(verify_result)
619 assert verify_data["all_ok"] is True
620
621 unbundle_result = _invoke(
622 ["bundle", "unbundle", str(out), "--json"], env=_env(dst)
623 )
624 assert unbundle_result.exit_code == 0
625 unbundle_data = _parse_unbundle(unbundle_result)
626 assert unbundle_data["commits_written"] == 5
627
628
629 # ---------------------------------------------------------------------------
630 # Integration: verify detects missing snapshot objects
631 # ---------------------------------------------------------------------------
632
633
634 def test_verify_missing_snapshot_object(tmp_path: pathlib.Path) -> None:
635 """Removing an object from the bundle should cause snapshot verification to fail."""
636 _init_repo(tmp_path)
637 _make_commit(tmp_path, content=b"snap-miss")
638 out = tmp_path / "snap-miss.bundle"
639 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
640
641 raw = msgpack.unpackb(out.read_bytes(), raw=False)
642 # Remove all objects so snapshots cannot find theirs.
643 raw["objects"] = []
644 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
645
646 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
647 data = _parse_verify(result)
648 assert data["all_ok"] is False
649 # Some failure should mention missing objects.
650 assert any("missing" in f for f in data["failures"])
651
652
653 # ---------------------------------------------------------------------------
654 # E2E: help output for all subcommands
655 # ---------------------------------------------------------------------------
656
657
658 def test_create_help_mentions_json() -> None:
659 result = _invoke(["bundle", "create", "--help"])
660 assert result.exit_code == 0
661 assert "--json" in result.output
662
663
664 def test_unbundle_help_mentions_json() -> None:
665 result = _invoke(["bundle", "unbundle", "--help"])
666 assert result.exit_code == 0
667 assert "--json" in result.output
668
669
670 def test_verify_help_mentions_json() -> None:
671 result = _invoke(["bundle", "verify", "--help"])
672 assert result.exit_code == 0
673 assert "--json" in result.output
674 assert "--format" not in result.output
675
676
677 def test_list_heads_help_mentions_json() -> None:
678 result = _invoke(["bundle", "list-heads", "--help"])
679 assert result.exit_code == 0
680 assert "--json" in result.output
681 assert "--format" not in result.output
682
683
684 def test_bundle_help_top_level() -> None:
685 result = _invoke(["bundle", "--help"])
686 assert result.exit_code == 0
687 assert "create" in result.output
688 assert "unbundle" in result.output
689 assert "verify" in result.output
690 assert "list-heads" in result.output
691
692
693 # ---------------------------------------------------------------------------
694 # Stress: 200-commit bundle
695 # ---------------------------------------------------------------------------
696
697
698 def test_stress_200_commit_chain(tmp_path: pathlib.Path) -> None:
699 _init_repo(tmp_path)
700 prev: str | None = None
701 for i in range(200):
702 prev = _make_commit(tmp_path, parent_id=prev, content=f"stress-{i}".encode())
703
704 out = tmp_path / "stress200.bundle"
705 create_result = _invoke(
706 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
707 )
708 assert create_result.exit_code == 0
709 data = _parse_create(create_result)
710 assert data["commits"] == 200
711
712 verify_result = _invoke(["bundle", "verify", str(out), "-q"], env=_env(tmp_path))
713 assert verify_result.exit_code == 0
714
715
716 # ---------------------------------------------------------------------------
717 # Stress: concurrent list-heads reads
718 # ---------------------------------------------------------------------------
719
720
721 def test_stress_concurrent_list_heads(tmp_path: pathlib.Path) -> None:
722 _init_repo(tmp_path)
723 _make_commit(tmp_path, content=b"concurrent-bundle")
724 out = tmp_path / "concurrent.bundle"
725 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
726
727 errors: list[str] = []
728
729 def _read() -> None:
730 r = _invoke(["bundle", "list-heads", str(out), "--json"], env=_env(tmp_path))
731 if r.exit_code != 0:
732 errors.append(f"exit {r.exit_code}: {r.output}")
733 else:
734 try:
735 data = json.loads(r.output)
736 if not isinstance(data, dict):
737 errors.append("not a dict")
738 except json.JSONDecodeError as exc:
739 errors.append(str(exc))
740
741 threads = [threading.Thread(target=_read) for _ in range(8)]
742 for t in threads:
743 t.start()
744 for t in threads:
745 t.join()
746
747 assert not errors, f"Concurrent list-heads failures: {errors}"
748
749
750 # ===========================================================================
751 # TestBundleCreateExtended — 18 tests
752 # ===========================================================================
753
754
755 class TestBundleCreateExtended:
756 def test_exits_0_basic(self, tmp_path: pathlib.Path) -> None:
757 """Single commit → create exits 0."""
758 _init_repo(tmp_path)
759 _make_commit(tmp_path, content=b"ext-basic")
760 out = tmp_path / "basic.bundle"
761 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
762 assert result.exit_code == 0
763
764 def test_creates_file_on_disk(self, tmp_path: pathlib.Path) -> None:
765 """Output file must exist after a successful create."""
766 _init_repo(tmp_path)
767 _make_commit(tmp_path, content=b"ext-file")
768 out = tmp_path / "check.bundle"
769 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
770 assert out.exists()
771
772 def test_text_output_mentions_commits(self, tmp_path: pathlib.Path) -> None:
773 _init_repo(tmp_path)
774 _make_commit(tmp_path, content=b"ext-txt-c")
775 out = tmp_path / "tc.bundle"
776 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
777 assert "commits" in result.output
778
779 def test_text_output_mentions_kib(self, tmp_path: pathlib.Path) -> None:
780 _init_repo(tmp_path)
781 _make_commit(tmp_path, content=b"ext-txt-kib")
782 out = tmp_path / "kib.bundle"
783 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
784 assert "KiB" in result.output
785
786 def test_text_output_contains_bundle_path(self, tmp_path: pathlib.Path) -> None:
787 _init_repo(tmp_path)
788 _make_commit(tmp_path, content=b"ext-txt-path")
789 out = tmp_path / "pathcheck.bundle"
790 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
791 assert "pathcheck.bundle" in result.output
792
793 def test_empty_repo_exits_1(self, tmp_path: pathlib.Path) -> None:
794 """Repo with no commits → exit 1 (no commits to bundle)."""
795 _init_repo(tmp_path)
796 out = tmp_path / "empty.bundle"
797 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
798 assert result.exit_code == 1
799
800 def test_bad_ref_exits_1(self, tmp_path: pathlib.Path) -> None:
801 """Unknown ref → exit 1."""
802 _init_repo(tmp_path)
803 _make_commit(tmp_path, content=b"ext-bad-ref")
804 out = tmp_path / "bad-ref.bundle"
805 result = _invoke(
806 ["bundle", "create", str(out), "nonexistent-branch"],
807 env=_env(tmp_path),
808 )
809 assert result.exit_code == 1
810
811 def test_json_branches_sorted(self, tmp_path: pathlib.Path) -> None:
812 """Branches list in JSON output must be sorted."""
813 _init_repo(tmp_path)
814 # Create a commit on main, then make z-branch and a-branch point to
815 # the same commit so they are all reachable when bundling HEAD.
816 c1 = _make_commit(tmp_path, content=b"ext-br-base", branch="main")
817 for br in ("z-branch", "a-branch"):
818 ref_file = ref_path(tmp_path, br)
819 ref_file.write_text(c1, encoding="utf-8")
820 out = tmp_path / "sorted.bundle"
821 result = _invoke(
822 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
823 )
824 assert result.exit_code == 0
825 data = _parse_create(result)
826 assert data["branches"] == sorted(data["branches"])
827
828 def test_json_size_matches_file(self, tmp_path: pathlib.Path) -> None:
829 """size_bytes in JSON must equal the actual file size on disk."""
830 _init_repo(tmp_path)
831 _make_commit(tmp_path, content=b"ext-size")
832 out = tmp_path / "size.bundle"
833 result = _invoke(
834 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
835 )
836 assert result.exit_code == 0
837 data = _parse_create(result)
838 assert data["size_bytes"] == out.stat().st_size
839
840 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
841 """-j must produce identical JSON to --json."""
842 _init_repo(tmp_path)
843 _make_commit(tmp_path, content=b"ext-j-alias")
844 out1 = tmp_path / "j1.bundle"
845 out2 = tmp_path / "j2.bundle"
846 r1 = _invoke(["bundle", "create", str(out1), "--json"], env=_env(tmp_path))
847 r2 = _invoke(["bundle", "create", str(out2), "-j"], env=_env(tmp_path))
848 assert r1.exit_code == 0
849 assert r2.exit_code == 0
850 d1 = json.loads(r1.output)
851 d2 = json.loads(r2.output)
852 # Both should have the same structural keys and counts.
853 assert d1["commits"] == d2["commits"]
854 assert d1["objects"] == d2["objects"]
855 assert d1["branches"] == d2["branches"]
856
857 def test_default_ref_is_head(self, tmp_path: pathlib.Path) -> None:
858 """When no refs are given, HEAD is used — bundle contains the HEAD commit."""
859 _init_repo(tmp_path)
860 _make_commit(tmp_path, content=b"ext-head")
861 out = tmp_path / "head.bundle"
862 result = _invoke(
863 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
864 )
865 assert result.exit_code == 0
866 data = _parse_create(result)
867 assert data["commits"] >= 1
868
869 def test_explicit_head_ref(self, tmp_path: pathlib.Path) -> None:
870 """Passing 'HEAD' explicitly is equivalent to the default."""
871 _init_repo(tmp_path)
872 _make_commit(tmp_path, content=b"ext-head-explicit")
873 out_default = tmp_path / "head-default.bundle"
874 out_explicit = tmp_path / "head-explicit.bundle"
875 _invoke(["bundle", "create", str(out_default)], env=_env(tmp_path))
876 result = _invoke(
877 ["bundle", "create", str(out_explicit), "HEAD", "--json"],
878 env=_env(tmp_path),
879 )
880 assert result.exit_code == 0
881 data = _parse_create(result)
882 assert data["commits"] >= 1
883 # Both bundles should contain the same number of commits.
884 raw_default = __import__("msgpack").unpackb(
885 out_default.read_bytes(), raw=False
886 )
887 assert len(raw_default.get("commits", [])) == data["commits"]
888
889 def test_explicit_commit_id(self, tmp_path: pathlib.Path) -> None:
890 """A raw commit ID passed as ref is resolved correctly."""
891 _init_repo(tmp_path)
892 cid = _make_commit(tmp_path, content=b"ext-cid")
893 out = tmp_path / "cid.bundle"
894 result = _invoke(
895 ["bundle", "create", str(out), cid, "--json"],
896 env=_env(tmp_path),
897 )
898 assert result.exit_code == 0
899 data = _parse_create(result)
900 assert data["commits"] >= 1
901
902 def test_output_is_valid_msgpack(self, tmp_path: pathlib.Path) -> None:
903 """The output file must be valid msgpack."""
904 import msgpack as _mp
905
906 _init_repo(tmp_path)
907 _make_commit(tmp_path, content=b"ext-msgpack")
908 out = tmp_path / "mp.bundle"
909 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
910 raw = _mp.unpackb(out.read_bytes(), raw=False)
911 assert isinstance(raw, dict)
912 assert "commits" in raw
913
914 def test_help_mentions_agent_quickstart(self) -> None:
915 result = _invoke(["bundle", "create", "--help"])
916 assert result.exit_code == 0
917 assert "Agent quickstart" in result.output
918
919 def test_help_mentions_exit_codes(self) -> None:
920 result = _invoke(["bundle", "create", "--help"])
921 assert result.exit_code == 0
922 assert "Exit codes" in result.output
923
924 def test_help_mentions_json_schema(self) -> None:
925 result = _invoke(["bundle", "create", "--help"])
926 assert result.exit_code == 0
927 assert "JSON output schema" in result.output
928
929 def test_multiple_have_ids_reduce_bundle(self, tmp_path: pathlib.Path) -> None:
930 """Multiple --have IDs each reduce what is bundled."""
931 _init_repo(tmp_path)
932 c1 = _make_commit(tmp_path, content=b"ext-have-1")
933 c2 = _make_commit(tmp_path, parent_id=c1, content=b"ext-have-2")
934 _make_commit(tmp_path, parent_id=c2, content=b"ext-have-3")
935
936 out_full = tmp_path / "have-full.bundle"
937 out_pruned = tmp_path / "have-pruned.bundle"
938 r_full = _invoke(
939 ["bundle", "create", str(out_full), "--json"], env=_env(tmp_path)
940 )
941 r_pruned = _invoke(
942 ["bundle", "create", str(out_pruned), "--have", c1, c2, "--json"],
943 env=_env(tmp_path),
944 )
945 assert r_full.exit_code == 0
946 assert r_pruned.exit_code == 0
947 full_data = _parse_create(r_full)
948 pruned_data = _parse_create(r_pruned)
949 assert pruned_data["commits"] < full_data["commits"]
950
951
952 # ===========================================================================
953 # TestBundleCreateSecurity — 6 tests
954 # ===========================================================================
955
956
957 class TestBundleCreateSecurity:
958 def test_ansi_in_file_path_stripped_text_output(
959 self, tmp_path: pathlib.Path
960 ) -> None:
961 """ANSI escape in the output file path must be stripped in text output."""
962 _init_repo(tmp_path)
963 _make_commit(tmp_path, content=b"sec-ansi-path")
964 # Build an output path whose filename component contains an ANSI escape.
965 out = tmp_path / "\x1b[31mmalicious\x1b[0m.bundle"
966 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
967 assert result.exit_code == 0
968 assert "\x1b" not in result.output
969
970 def test_control_char_in_file_path_stripped(
971 self, tmp_path: pathlib.Path
972 ) -> None:
973 """Control characters in the output file path must not reach stdout."""
974 _init_repo(tmp_path)
975 _make_commit(tmp_path, content=b"sec-ctrl-path")
976 out = tmp_path / "foo\x07bar.bundle"
977 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
978 assert result.exit_code == 0
979 assert "\x07" not in result.output
980
981 def test_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
982 """Without a .muse directory, create must exit 2 (REPO_NOT_FOUND)."""
983 out = tmp_path / "norepo.bundle"
984 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
985 assert result.exit_code == 2
986
987 def test_bad_ref_ansi_stripped_from_error(
988 self, tmp_path: pathlib.Path
989 ) -> None:
990 """ANSI in an unknown ref name must not appear in the error output."""
991 _init_repo(tmp_path)
992 _make_commit(tmp_path, content=b"sec-ref-ansi")
993 out = tmp_path / "ref-ansi.bundle"
994 malicious_ref = "\x1b[31mbadref\x1b[0m"
995 result = _invoke(
996 ["bundle", "create", str(out), malicious_ref], env=_env(tmp_path)
997 )
998 assert result.exit_code == 1
999 assert "\x1b" not in result.output
1000
1001 def test_ansi_in_have_no_injection(self, tmp_path: pathlib.Path) -> None:
1002 """ANSI characters in a --have value must not appear in any output."""
1003 _init_repo(tmp_path)
1004 _make_commit(tmp_path, content=b"sec-have-ansi")
1005 out = tmp_path / "have-ansi.bundle"
1006 malicious_have = f"\x1b[31m{'a' * 64}\x1b[0m"
1007 result = _invoke(
1008 ["bundle", "create", str(out), "--have", malicious_have],
1009 env=_env(tmp_path),
1010 )
1011 # The have ID won't match anything — bundle succeeds with full history.
1012 assert "\x1b" not in result.output
1013
1014 def test_no_json_on_error(self, tmp_path: pathlib.Path) -> None:
1015 """On error (no commits), stdout must not contain JSON."""
1016 _init_repo(tmp_path)
1017 out = tmp_path / "err-json.bundle"
1018 result = _invoke(
1019 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
1020 )
1021 assert result.exit_code != 0
1022 assert not result.output.strip().startswith("{")
1023
1024
1025 # ===========================================================================
1026 # TestBundleCreateStress — 3 tests
1027 # ===========================================================================
1028
1029
1030 class TestBundleCreateStress:
1031 def test_50_commit_chain(self, tmp_path: pathlib.Path) -> None:
1032 """50-commit linear chain is bundled correctly."""
1033 _init_repo(tmp_path)
1034 prev: str | None = None
1035 for i in range(50):
1036 prev = _make_commit(
1037 tmp_path, parent_id=prev, content=f"stress50-{i}".encode()
1038 )
1039 out = tmp_path / "stress50.bundle"
1040 result = _invoke(
1041 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
1042 )
1043 assert result.exit_code == 0
1044 data = _parse_create(result)
1045 assert data["commits"] == 50
1046 assert data["size_bytes"] > 0
1047
1048 def test_create_with_large_have_list(self, tmp_path: pathlib.Path) -> None:
1049 """Passing 15 --have IDs on a 20-commit chain produces a smaller bundle."""
1050 _init_repo(tmp_path)
1051 ids: list[str] = []
1052 prev: str | None = None
1053 for i in range(20):
1054 prev = _make_commit(
1055 tmp_path, parent_id=prev, content=f"have-list-{i}".encode()
1056 )
1057 ids.append(prev)
1058
1059 out_full = tmp_path / "have-full-20.bundle"
1060 out_pruned = tmp_path / "have-pruned-20.bundle"
1061 r_full = _invoke(
1062 ["bundle", "create", str(out_full), "--json"], env=_env(tmp_path)
1063 )
1064 # Pass the first 15 as --have to exclude them.
1065 have_args = ["--have"] + ids[:15]
1066 r_pruned = _invoke(
1067 ["bundle", "create", str(out_pruned)] + have_args + ["--json"],
1068 env=_env(tmp_path),
1069 )
1070 assert r_full.exit_code == 0
1071 assert r_pruned.exit_code == 0
1072 full_data = _parse_create(r_full)
1073 pruned_data = _parse_create(r_pruned)
1074 assert pruned_data["commits"] < full_data["commits"]
1075
1076 def test_many_branches(self, tmp_path: pathlib.Path) -> None:
1077 """10 branches pointing to reachable commits all appear in the bundle."""
1078 _init_repo(tmp_path)
1079 # Build a 10-commit chain on main, then create a feature branch ref
1080 # pointing to each commit — all are reachable from HEAD.
1081 prev: str | None = None
1082 commit_ids: list[str] = []
1083 for i in range(10):
1084 prev = _make_commit(
1085 tmp_path, parent_id=prev, content=f"stress-br-{i}".encode()
1086 )
1087 commit_ids.append(prev)
1088 branch_names = [f"feat/stress-br-{i}" for i in range(10)]
1089 for br, cid in zip(branch_names, commit_ids):
1090 ref_file = ref_path(tmp_path, br)
1091 ref_file.parent.mkdir(parents=True, exist_ok=True)
1092 ref_file.write_text(cid, encoding="utf-8")
1093 out = tmp_path / "many-branches.bundle"
1094 result = _invoke(
1095 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
1096 )
1097 assert result.exit_code == 0
1098 data = _parse_create(result)
1099 for br in branch_names:
1100 assert br in data["branches"]
1101
1102
1103 # ===========================================================================
1104 # TestBundleUnbundleExtended — 18 tests
1105 # ===========================================================================
1106
1107
1108 def _make_bundle(src: pathlib.Path, dst_file: pathlib.Path) -> None:
1109 """Helper: create a bundle from src repo into dst_file."""
1110 _invoke(["bundle", "create", str(dst_file)], env=_env(src))
1111
1112
1113 class TestBundleUnbundleExtended:
1114 def _src_dst(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path]:
1115 src = tmp_path / "src"
1116 dst = tmp_path / "dst"
1117 src.mkdir()
1118 dst.mkdir()
1119 _init_repo(src)
1120 _init_repo(dst, repo_id="ub-dst")
1121 return src, dst
1122
1123 def test_exits_0_basic(self, tmp_path: pathlib.Path) -> None:
1124 src, dst = self._src_dst(tmp_path)
1125 _make_commit(src, content=b"ub-basic")
1126 bundle = tmp_path / "basic.bundle"
1127 _make_bundle(src, bundle)
1128 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1129 assert result.exit_code == 0
1130
1131 def test_commits_written_count(self, tmp_path: pathlib.Path) -> None:
1132 src, dst = self._src_dst(tmp_path)
1133 prev: str | None = None
1134 for i in range(3):
1135 prev = _make_commit(src, parent_id=prev, content=f"ub-cnt-{i}".encode())
1136 bundle = tmp_path / "cnt.bundle"
1137 _make_bundle(src, bundle)
1138 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1139 assert result.exit_code == 0
1140 data = _parse_unbundle(result)
1141 assert data["commits_written"] == 3
1142
1143 def test_snapshots_written_count(self, tmp_path: pathlib.Path) -> None:
1144 src, dst = self._src_dst(tmp_path)
1145 _make_commit(src, content=b"ub-snap")
1146 bundle = tmp_path / "snap.bundle"
1147 _make_bundle(src, bundle)
1148 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1149 assert result.exit_code == 0
1150 data = _parse_unbundle(result)
1151 assert data["snapshots_written"] >= 1
1152
1153 def test_objects_written_count(self, tmp_path: pathlib.Path) -> None:
1154 src, dst = self._src_dst(tmp_path)
1155 _make_commit(src, content=b"ub-obj")
1156 bundle = tmp_path / "obj.bundle"
1157 _make_bundle(src, bundle)
1158 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1159 assert result.exit_code == 0
1160 data = _parse_unbundle(result)
1161 assert data["objects_written"] >= 1
1162
1163 def test_objects_skipped_idempotent(self, tmp_path: pathlib.Path) -> None:
1164 """Unbundling twice: second pass skips all already-present objects."""
1165 src, dst = self._src_dst(tmp_path)
1166 _make_commit(src, content=b"ub-idem")
1167 bundle = tmp_path / "idem.bundle"
1168 _make_bundle(src, bundle)
1169 _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1170 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1171 assert result.exit_code == 0
1172 data = _parse_unbundle(result)
1173 assert data["commits_written"] == 0
1174 assert data["objects_written"] == 0
1175 assert data["objects_skipped"] >= 1
1176
1177 def test_text_output_mentions_commits(self, tmp_path: pathlib.Path) -> None:
1178 src, dst = self._src_dst(tmp_path)
1179 _make_commit(src, content=b"ub-txt-c")
1180 bundle = tmp_path / "txt-c.bundle"
1181 _make_bundle(src, bundle)
1182 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1183 assert result.exit_code == 0
1184 assert "commit(s)" in result.output
1185
1186 def test_text_output_mentions_applied(self, tmp_path: pathlib.Path) -> None:
1187 src, dst = self._src_dst(tmp_path)
1188 _make_commit(src, content=b"ub-txt-a")
1189 bundle = tmp_path / "txt-a.bundle"
1190 _make_bundle(src, bundle)
1191 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1192 assert result.exit_code == 0
1193 assert "Bundle applied" in result.output
1194
1195 def test_refs_updated_by_default(self, tmp_path: pathlib.Path) -> None:
1196 """By default, branch refs in the destination are updated."""
1197 src, dst = self._src_dst(tmp_path)
1198 _make_commit(src, content=b"ub-ref-up")
1199 bundle = tmp_path / "ref-up.bundle"
1200 _make_bundle(src, bundle)
1201 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1202 assert result.exit_code == 0
1203 data = _parse_unbundle(result)
1204 assert "main" in data["refs_updated"]
1205
1206 def test_no_update_refs_skips_refs(self, tmp_path: pathlib.Path) -> None:
1207 src, dst = self._src_dst(tmp_path)
1208 _make_commit(src, content=b"ub-no-ref")
1209 bundle = tmp_path / "no-ref.bundle"
1210 _make_bundle(src, bundle)
1211 result = _invoke(
1212 ["bundle", "unbundle", str(bundle), "--no-update-refs", "--json"],
1213 env=_env(dst),
1214 )
1215 assert result.exit_code == 0
1216 data = _parse_unbundle(result)
1217 assert data["refs_updated"] == []
1218
1219 def test_refs_updated_branch_file_exists(self, tmp_path: pathlib.Path) -> None:
1220 """After unbundle, the branch ref file must exist in the destination."""
1221 src, dst = self._src_dst(tmp_path)
1222 _make_commit(src, content=b"ub-ref-file")
1223 bundle = tmp_path / "ref-file.bundle"
1224 _make_bundle(src, bundle)
1225 _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1226 ref_file = heads_dir(dst) / "main"
1227 assert ref_file.exists()
1228 cid = ref_file.read_text(encoding="utf-8").strip()
1229 # Ref files store canonical "sha256:<64hex>" format (71 chars).
1230 assert cid.startswith("sha256:")
1231 assert len(cid) == 71
1232
1233 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
1234 """-j must produce identical JSON to --json."""
1235 src1, dst1 = self._src_dst(tmp_path)
1236 src2 = tmp_path / "src2"
1237 dst2 = tmp_path / "dst2"
1238 src2.mkdir()
1239 dst2.mkdir()
1240 _init_repo(src2)
1241 _init_repo(dst2, repo_id="ub-j2")
1242
1243 _make_commit(src1, content=b"ub-j-a1")
1244 _make_commit(src2, content=b"ub-j-a2")
1245 b1 = tmp_path / "j1.bundle"
1246 b2 = tmp_path / "j2.bundle"
1247 _make_bundle(src1, b1)
1248 _make_bundle(src2, b2)
1249
1250 r1 = _invoke(["bundle", "unbundle", str(b1), "--json"], env=_env(dst1))
1251 r2 = _invoke(["bundle", "unbundle", str(b2), "-j"], env=_env(dst2))
1252 assert r1.exit_code == 0
1253 assert r2.exit_code == 0
1254 d1 = _parse_unbundle(r1)
1255 d2 = _parse_unbundle(r2)
1256 assert set(d1.keys()) == set(d2.keys())
1257 assert d1["commits_written"] == d2["commits_written"]
1258
1259 def test_json_refs_updated_sorted(self, tmp_path: pathlib.Path) -> None:
1260 """refs_updated in JSON output must be sorted."""
1261 src, dst = self._src_dst(tmp_path)
1262 c1 = _make_commit(src, content=b"ub-sort-base")
1263 # Add extra branch refs pointing at c1 so the bundle has multiple heads.
1264 for br in ("z-br", "a-br"):
1265 ref = ref_path(src, br)
1266 ref.write_text(c1, encoding="utf-8")
1267 bundle = tmp_path / "sort.bundle"
1268 _make_bundle(src, bundle)
1269 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1270 assert result.exit_code == 0
1271 data = _parse_unbundle(result)
1272 assert data["refs_updated"] == sorted(data["refs_updated"])
1273
1274 def test_empty_bundle_no_crash(self, tmp_path: pathlib.Path) -> None:
1275 """An empty dict bundle (no commits/objects) must exit 0 cleanly."""
1276 _init_repo(tmp_path)
1277 empty_bundle = tmp_path / "empty.bundle"
1278 empty_bundle.write_bytes(msgpack.packb({}, use_bin_type=True))
1279 result = _invoke(["bundle", "unbundle", str(empty_bundle)], env=_env(tmp_path))
1280 assert result.exit_code == 0
1281
1282 def test_bundle_without_branch_heads_no_refs(self, tmp_path: pathlib.Path) -> None:
1283 """A bundle missing the branch_heads key → refs_updated must be empty."""
1284 src, dst = self._src_dst(tmp_path)
1285 _make_commit(src, content=b"ub-no-heads")
1286 bundle = tmp_path / "no-heads.bundle"
1287 _make_bundle(src, bundle)
1288 # Strip branch_heads from the bundle.
1289 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1290 raw.pop("branch_heads", None)
1291 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1292 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1293 assert result.exit_code == 0
1294 data = _parse_unbundle(result)
1295 assert data["refs_updated"] == []
1296
1297 def test_help_mentions_agent_quickstart(self) -> None:
1298 result = _invoke(["bundle", "unbundle", "--help"])
1299 assert result.exit_code == 0
1300 assert "Agent quickstart" in result.output
1301
1302 def test_help_mentions_exit_codes(self) -> None:
1303 result = _invoke(["bundle", "unbundle", "--help"])
1304 assert result.exit_code == 0
1305 assert "Exit codes" in result.output
1306
1307 def test_help_mentions_json_schema(self) -> None:
1308 result = _invoke(["bundle", "unbundle", "--help"])
1309 assert result.exit_code == 0
1310 assert "JSON output schema" in result.output
1311
1312 def test_no_update_refs_flag_in_help(self) -> None:
1313 result = _invoke(["bundle", "unbundle", "--help"])
1314 assert result.exit_code == 0
1315 assert "--no-update-refs" in result.output
1316
1317
1318 # ===========================================================================
1319 # TestBundleUnbundleSecurity — 6 tests
1320 # ===========================================================================
1321
1322
1323 class TestBundleUnbundleSecurity:
1324 def test_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
1325 """Without a .muse directory, unbundle must exit 2 (REPO_NOT_FOUND)."""
1326 bundle = tmp_path / "norepo.bundle"
1327 bundle.write_bytes(msgpack.packb({}, use_bin_type=True))
1328 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(tmp_path))
1329 assert result.exit_code == 2
1330
1331 def test_missing_bundle_file_exits_1(self, tmp_path: pathlib.Path) -> None:
1332 _init_repo(tmp_path)
1333 result = _invoke(
1334 ["bundle", "unbundle", str(tmp_path / "missing.bundle")],
1335 env=_env(tmp_path),
1336 )
1337 assert result.exit_code == 1
1338
1339 def test_invalid_msgpack_exits_1(self, tmp_path: pathlib.Path) -> None:
1340 _init_repo(tmp_path)
1341 corrupt = tmp_path / "corrupt.bundle"
1342 corrupt.write_bytes(b"\xff\xfe not msgpack at all")
1343 result = _invoke(["bundle", "unbundle", str(corrupt)], env=_env(tmp_path))
1344 assert result.exit_code == 1
1345
1346 def test_ansi_branch_name_skipped_no_injection(
1347 self, tmp_path: pathlib.Path
1348 ) -> None:
1349 """ANSI escape in a bundle branch name is skipped; no escape in output."""
1350 src = tmp_path / "src"
1351 dst = tmp_path / "dst"
1352 src.mkdir()
1353 dst.mkdir()
1354 _init_repo(src)
1355 _init_repo(dst, repo_id="sec-ansi-br")
1356 _make_commit(src, content=b"sec-ansi-br")
1357 bundle = tmp_path / "ansi-br.bundle"
1358 _make_bundle(src, bundle)
1359 # Inject an ANSI-poisoned branch name into branch_heads.
1360 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1361 raw["branch_heads"] = {"\x1b[31mmalicious\x1b[0m": "a" * 64}
1362 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1363 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1364 assert result.exit_code == 0
1365 assert "\x1b" not in result.output
1366
1367 def test_invalid_commit_id_branch_ref_skipped(
1368 self, tmp_path: pathlib.Path
1369 ) -> None:
1370 """A commit ID shorter than 64 chars in branch_heads must be skipped."""
1371 src = tmp_path / "src"
1372 dst = tmp_path / "dst"
1373 src.mkdir()
1374 dst.mkdir()
1375 _init_repo(src)
1376 _init_repo(dst, repo_id="sec-short-cid")
1377 _make_commit(src, content=b"sec-short-cid")
1378 bundle = tmp_path / "short-cid.bundle"
1379 _make_bundle(src, bundle)
1380 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1381 # Replace the commit IDs with a too-short value.
1382 raw["branch_heads"] = {"main": "tooshort"}
1383 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1384 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1385 assert result.exit_code == 0
1386 data = _parse_unbundle(result)
1387 assert "main" not in data["refs_updated"]
1388
1389 def test_no_json_on_missing_file(self, tmp_path: pathlib.Path) -> None:
1390 """Error path (file not found) must not emit JSON to stdout."""
1391 _init_repo(tmp_path)
1392 result = _invoke(
1393 ["bundle", "unbundle", str(tmp_path / "ghost.bundle"), "--json"],
1394 env=_env(tmp_path),
1395 )
1396 assert result.exit_code != 0
1397 assert not result.output.strip().startswith("{")
1398
1399
1400 # ===========================================================================
1401 # TestBundleUnbundleStress — 3 tests
1402 # ===========================================================================
1403
1404
1405 class TestBundleUnbundleStress:
1406 def test_50_commit_chain(self, tmp_path: pathlib.Path) -> None:
1407 """50-commit chain is fully unpacked into the destination."""
1408 src = tmp_path / "src"
1409 dst = tmp_path / "dst"
1410 src.mkdir()
1411 dst.mkdir()
1412 _init_repo(src)
1413 _init_repo(dst, repo_id="stress-ub-dst")
1414 prev: str | None = None
1415 for i in range(50):
1416 prev = _make_commit(src, parent_id=prev, content=f"ub50-{i}".encode())
1417 bundle = tmp_path / "ub50.bundle"
1418 _make_bundle(src, bundle)
1419 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1420 assert result.exit_code == 0
1421 data = _parse_unbundle(result)
1422 assert data["commits_written"] == 50
1423 assert data["objects_written"] >= 50
1424
1425 def test_idempotent_multiple_applications(self, tmp_path: pathlib.Path) -> None:
1426 """Applying the same bundle 5 times: only the first writes anything."""
1427 src = tmp_path / "src"
1428 dst = tmp_path / "dst"
1429 src.mkdir()
1430 dst.mkdir()
1431 _init_repo(src)
1432 _init_repo(dst, repo_id="stress-idem-dst")
1433 prev: str | None = None
1434 for i in range(5):
1435 prev = _make_commit(src, parent_id=prev, content=f"idem-{i}".encode())
1436 bundle = tmp_path / "idem5.bundle"
1437 _make_bundle(src, bundle)
1438 first = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1439 assert first.exit_code == 0
1440 first_data = _parse_unbundle(first)
1441 assert first_data["commits_written"] == 5
1442 for _ in range(4):
1443 repeat = _invoke(
1444 ["bundle", "unbundle", str(bundle), "--json"], env=_env(dst)
1445 )
1446 assert repeat.exit_code == 0
1447 repeat_data = _parse_unbundle(repeat)
1448 assert repeat_data["commits_written"] == 0
1449 assert repeat_data["objects_written"] == 0
1450
1451 def test_many_branch_refs_updated(self, tmp_path: pathlib.Path) -> None:
1452 """10 branch heads in the bundle → all 10 appear in refs_updated."""
1453 src = tmp_path / "src"
1454 dst = tmp_path / "dst"
1455 src.mkdir()
1456 dst.mkdir()
1457 _init_repo(src)
1458 _init_repo(dst, repo_id="stress-refs-dst")
1459 # Build a 10-commit chain on main.
1460 prev: str | None = None
1461 cids: list[str] = []
1462 for i in range(10):
1463 prev = _make_commit(src, parent_id=prev, content=f"br-ref-{i}".encode())
1464 cids.append(prev)
1465 # Create 10 feature branch refs pointing to reachable commits.
1466 br_names = [f"feat/br-{i}" for i in range(10)]
1467 for br, cid in zip(br_names, cids):
1468 ref = ref_path(src, br)
1469 ref.parent.mkdir(parents=True, exist_ok=True)
1470 ref.write_text(cid, encoding="utf-8")
1471 bundle = tmp_path / "many-refs.bundle"
1472 _make_bundle(src, bundle)
1473 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1474 assert result.exit_code == 0
1475 data = _parse_unbundle(result)
1476 for br in br_names:
1477 assert br in data["refs_updated"]
1478
1479
1480 # ===========================================================================
1481 # TestBundleVerifyExtended — 18 tests
1482 # ===========================================================================
1483
1484
1485 class TestBundleVerifyExtended:
1486 def _clean_bundle(self, tmp_path: pathlib.Path) -> pathlib.Path:
1487 """Create a repo with one commit and return a clean bundle path."""
1488 _init_repo(tmp_path)
1489 _make_commit(tmp_path, content=b"vext-clean")
1490 out = tmp_path / "clean.bundle"
1491 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1492 return out
1493
1494 def _corrupt_bundle(self, tmp_path: pathlib.Path) -> pathlib.Path:
1495 """Create a bundle then tamper one object's content."""
1496 _init_repo(tmp_path)
1497 _make_commit(tmp_path, content=b"vext-corrupt")
1498 out = tmp_path / "corrupt.bundle"
1499 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1500 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1501 if raw.get("objects"):
1502 raw["objects"][0]["content"] = b"TAMPERED"
1503 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
1504 return out
1505
1506 def test_exits_0_on_clean_bundle(self, tmp_path: pathlib.Path) -> None:
1507 bundle = self._clean_bundle(tmp_path)
1508 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1509 assert result.exit_code == 0
1510
1511 def test_exits_1_on_corrupt_object(self, tmp_path: pathlib.Path) -> None:
1512 bundle = self._corrupt_bundle(tmp_path)
1513 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1514 assert result.exit_code == 1
1515
1516 def test_all_ok_true_on_clean(self, tmp_path: pathlib.Path) -> None:
1517 bundle = self._clean_bundle(tmp_path)
1518 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1519 assert result.exit_code == 0
1520 data = _parse_verify(result)
1521 assert data["all_ok"] is True
1522
1523 def test_all_ok_false_on_corrupt(self, tmp_path: pathlib.Path) -> None:
1524 bundle = self._corrupt_bundle(tmp_path)
1525 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1526 assert result.exit_code == 1
1527 data = _parse_verify(result)
1528 assert data["all_ok"] is False
1529
1530 def test_objects_checked_count(self, tmp_path: pathlib.Path) -> None:
1531 """objects_checked must equal the number of objects in the bundle."""
1532 _init_repo(tmp_path)
1533 _make_commit(tmp_path, content=b"vext-cnt")
1534 out = tmp_path / "cnt.bundle"
1535 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1536 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1537 n_objects = len(raw.get("objects", []))
1538 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
1539 assert result.exit_code == 0
1540 data = _parse_verify(result)
1541 assert data["objects_checked"] == n_objects
1542
1543 def test_snapshots_checked_count(self, tmp_path: pathlib.Path) -> None:
1544 bundle = self._clean_bundle(tmp_path)
1545 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1546 assert result.exit_code == 0
1547 data = _parse_verify(result)
1548 assert data["snapshots_checked"] >= 1
1549
1550 def test_failures_empty_on_clean(self, tmp_path: pathlib.Path) -> None:
1551 bundle = self._clean_bundle(tmp_path)
1552 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1553 data = _parse_verify(result)
1554 assert data["failures"] == []
1555
1556 def test_failures_nonempty_on_corrupt(self, tmp_path: pathlib.Path) -> None:
1557 bundle = self._corrupt_bundle(tmp_path)
1558 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1559 data = _parse_verify(result)
1560 assert len(data["failures"]) >= 1
1561
1562 def test_quiet_clean_exits_0_no_output(self, tmp_path: pathlib.Path) -> None:
1563 bundle = self._clean_bundle(tmp_path)
1564 result = _invoke(["bundle", "verify", str(bundle), "--quiet"], env=_env(tmp_path))
1565 assert result.exit_code == 0
1566 assert result.output.strip() == ""
1567
1568 def test_quiet_corrupt_exits_1_no_output(self, tmp_path: pathlib.Path) -> None:
1569 bundle = self._corrupt_bundle(tmp_path)
1570 result = _invoke(["bundle", "verify", str(bundle), "-q"], env=_env(tmp_path))
1571 assert result.exit_code == 1
1572 assert result.output.strip() == ""
1573
1574 def test_json_output_is_single_line(self, tmp_path: pathlib.Path) -> None:
1575 """JSON output must be compact (no indent=2), matching all other commands."""
1576 bundle = self._clean_bundle(tmp_path)
1577 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1578 assert result.exit_code == 0
1579 # Compact JSON has no interior newlines.
1580 assert "\n" not in result.output.strip()
1581
1582 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
1583 bundle = self._clean_bundle(tmp_path)
1584 r1 = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1585 r2 = _invoke(["bundle", "verify", str(bundle), "-j"], env=_env(tmp_path))
1586 assert r1.exit_code == 0
1587 assert r2.exit_code == 0
1588 _volatile = {"timestamp", "duration_ms"}
1589 d1 = {k: v for k, v in json.loads(r1.output).items() if k not in _volatile}
1590 d2 = {k: v for k, v in json.loads(r2.output).items() if k not in _volatile}
1591 assert d1 == d2
1592
1593 def test_text_output_mentions_objects_checked(self, tmp_path: pathlib.Path) -> None:
1594 bundle = self._clean_bundle(tmp_path)
1595 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1596 assert "Objects checked" in result.output
1597
1598 def test_text_output_mentions_snapshots_checked(self, tmp_path: pathlib.Path) -> None:
1599 bundle = self._clean_bundle(tmp_path)
1600 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1601 assert "Snapshots checked" in result.output
1602
1603 def test_text_output_clean_checkmark(self, tmp_path: pathlib.Path) -> None:
1604 bundle = self._clean_bundle(tmp_path)
1605 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1606 assert "Bundle is clean" in result.output
1607
1608 def test_text_output_failures_listed(self, tmp_path: pathlib.Path) -> None:
1609 bundle = self._corrupt_bundle(tmp_path)
1610 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1611 assert result.exit_code == 1
1612 assert "hash mismatch" in result.output
1613
1614 def test_help_mentions_agent_quickstart(self) -> None:
1615 result = _invoke(["bundle", "verify", "--help"])
1616 assert result.exit_code == 0
1617 assert "Agent quickstart" in result.output
1618
1619 def test_help_mentions_exit_codes(self) -> None:
1620 result = _invoke(["bundle", "verify", "--help"])
1621 assert result.exit_code == 0
1622 assert "Exit codes" in result.output
1623
1624
1625 # ===========================================================================
1626 # TestBundleVerifySecurity — 6 tests
1627 # ===========================================================================
1628
1629
1630 class TestBundleVerifySecurity:
1631 def _bundle_with_ansi_object_id(self, tmp_path: pathlib.Path) -> pathlib.Path:
1632 """Bundle where an object_id contains an ANSI escape sequence."""
1633 _init_repo(tmp_path)
1634 _make_commit(tmp_path, content=b"sec-ansi-oid")
1635 out = tmp_path / "ansi-oid.bundle"
1636 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1637 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1638 if raw.get("objects"):
1639 # Inject ANSI into the object_id — will trigger hash mismatch failure.
1640 raw["objects"][0]["object_id"] = "\x1b[31mmalicious_oid_xxx\x1b[0m"
1641 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
1642 return out
1643
1644 def _bundle_with_ansi_rel_path(self, tmp_path: pathlib.Path) -> pathlib.Path:
1645 """Bundle where a snapshot manifest key contains an ANSI escape."""
1646 _init_repo(tmp_path)
1647 _make_commit(tmp_path, content=b"sec-ansi-path")
1648 out = tmp_path / "ansi-path.bundle"
1649 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1650 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1651 if raw.get("snapshots"):
1652 snap = raw["snapshots"][0]
1653 # Replace manifest keys with ANSI-poisoned path.
1654 old_manifest = snap.get("manifest", {})
1655 snap["manifest"] = {
1656 "\x1b[31mmalicious/path\x1b[0m": v for v in old_manifest.values()
1657 }
1658 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
1659 return out
1660
1661 def test_ansi_in_object_id_failure_stripped_text(
1662 self, tmp_path: pathlib.Path
1663 ) -> None:
1664 """ANSI in object_id within a failure message must be stripped in text output."""
1665 bundle = self._bundle_with_ansi_object_id(tmp_path)
1666 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1667 assert "\x1b" not in result.output
1668
1669 def test_ansi_in_rel_path_failure_stripped_text(
1670 self, tmp_path: pathlib.Path
1671 ) -> None:
1672 """ANSI in a manifest rel_path within a failure must be stripped in text output."""
1673 bundle = self._bundle_with_ansi_rel_path(tmp_path)
1674 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1675 assert "\x1b" not in result.output
1676
1677 def test_ansi_in_failures_sanitized_json(self, tmp_path: pathlib.Path) -> None:
1678 """failures list in JSON output must not contain raw ANSI escapes."""
1679 bundle = self._bundle_with_ansi_object_id(tmp_path)
1680 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1681 assert "\x1b" not in result.output
1682
1683 def test_no_repo_required(self, tmp_path: pathlib.Path) -> None:
1684 """verify must work outside any .muse repository (no require_repo call)."""
1685 work = tmp_path / "no_repo"
1686 work.mkdir()
1687 _init_repo(tmp_path)
1688 _make_commit(tmp_path, content=b"sec-no-repo")
1689 bundle = tmp_path / "no-repo.bundle"
1690 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1691 # Run verify from a directory with no .muse — must NOT exit 2.
1692 result = _invoke(["bundle", "verify", str(bundle)], env={"MUSE_REPO_ROOT": str(work)})
1693 assert result.exit_code != 2
1694
1695 def test_missing_file_exits_1(self, tmp_path: pathlib.Path) -> None:
1696 _init_repo(tmp_path)
1697 result = _invoke(
1698 ["bundle", "verify", str(tmp_path / "ghost.bundle")],
1699 env=_env(tmp_path),
1700 )
1701 assert result.exit_code == 1
1702
1703 def test_invalid_msgpack_exits_1(self, tmp_path: pathlib.Path) -> None:
1704 _init_repo(tmp_path)
1705 corrupt = tmp_path / "bad.bundle"
1706 corrupt.write_bytes(b"\xff\xfe not msgpack")
1707 result = _invoke(["bundle", "verify", str(corrupt)], env=_env(tmp_path))
1708 assert result.exit_code == 1
1709
1710
1711 # ===========================================================================
1712 # TestBundleVerifyStress — 3 tests
1713 # ===========================================================================
1714
1715
1716 class TestBundleVerifyStress:
1717 def test_200_commit_bundle_verify_clean(self, tmp_path: pathlib.Path) -> None:
1718 """200-commit bundle verifies clean with correct counts."""
1719 _init_repo(tmp_path)
1720 prev: str | None = None
1721 for i in range(200):
1722 prev = _make_commit(tmp_path, parent_id=prev, content=f"vstress-{i}".encode())
1723 out = tmp_path / "vstress200.bundle"
1724 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1725 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
1726 assert result.exit_code == 0
1727 data = _parse_verify(result)
1728 assert data["all_ok"] is True
1729 assert data["objects_checked"] >= 200
1730 assert data["snapshots_checked"] >= 200
1731
1732 def test_multiple_corrupt_objects_all_detected(
1733 self, tmp_path: pathlib.Path
1734 ) -> None:
1735 """Multiple corrupted objects must each produce a failure entry."""
1736 _init_repo(tmp_path)
1737 prev: str | None = None
1738 for i in range(5):
1739 prev = _make_commit(tmp_path, parent_id=prev, content=f"multi-corrupt-{i}".encode())
1740 out = tmp_path / "multi-corrupt.bundle"
1741 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1742 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1743 # Corrupt every object.
1744 for obj in raw.get("objects", []):
1745 obj["content"] = b"TAMPERED"
1746 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
1747 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
1748 assert result.exit_code == 1
1749 data = _parse_verify(result)
1750 assert data["all_ok"] is False
1751 assert len(data["failures"]) >= 5
1752
1753 def test_empty_bundle_verifies_clean(self, tmp_path: pathlib.Path) -> None:
1754 """An empty dict bundle has nothing to check and must exit 0."""
1755 _init_repo(tmp_path)
1756 empty = tmp_path / "empty.bundle"
1757 empty.write_bytes(msgpack.packb({}, use_bin_type=True))
1758 result = _invoke(["bundle", "verify", str(empty), "--json"], env=_env(tmp_path))
1759 assert result.exit_code == 0
1760 data = _parse_verify(result)
1761 assert data["all_ok"] is True
1762 assert data["objects_checked"] == 0
1763 assert data["failures"] == []
1764
1765
1766 # ===========================================================================
1767 # TestBundleListHeadsExtended — 18 tests
1768 # ===========================================================================
1769
1770
1771 class TestBundleListHeadsExtended:
1772 def _bundle_with_head(self, tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path:
1773 _init_repo(tmp_path)
1774 _make_commit(tmp_path, content=b"lhe-base", branch=branch)
1775 out = tmp_path / "lhe.bundle"
1776 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1777 return out
1778
1779 def test_exits_0_with_heads(self, tmp_path: pathlib.Path) -> None:
1780 bundle = self._bundle_with_head(tmp_path)
1781 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1782 assert result.exit_code == 0
1783
1784 def test_exits_0_no_heads(self, tmp_path: pathlib.Path) -> None:
1785 """A bundle with no branch_heads key must still exit 0."""
1786 _init_repo(tmp_path)
1787 _make_commit(tmp_path, content=b"lhe-noheads")
1788 bundle = tmp_path / "noheads.bundle"
1789 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1790 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1791 raw.pop("branch_heads", None)
1792 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1793 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1794 assert result.exit_code == 0
1795
1796 def test_text_shows_branch_and_cid(self, tmp_path: pathlib.Path) -> None:
1797 bundle = self._bundle_with_head(tmp_path)
1798 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1799 assert result.exit_code == 0
1800 assert "main" in result.output
1801
1802 def test_text_no_heads_message(self, tmp_path: pathlib.Path) -> None:
1803 _init_repo(tmp_path)
1804 _make_commit(tmp_path, content=b"lhe-nomsg")
1805 bundle = tmp_path / "nomsg.bundle"
1806 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1807 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1808 raw.pop("branch_heads", None)
1809 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1810 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1811 assert "No branch heads" in result.output
1812
1813 def test_json_returns_dict(self, tmp_path: pathlib.Path) -> None:
1814 bundle = self._bundle_with_head(tmp_path)
1815 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1816 assert result.exit_code == 0
1817 data = json.loads(result.output)
1818 assert isinstance(data["heads"], dict)
1819
1820 def test_json_contains_main(self, tmp_path: pathlib.Path) -> None:
1821 bundle = self._bundle_with_head(tmp_path)
1822 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1823 data = json.loads(result.output)
1824 assert "main" in data["heads"]
1825
1826 def test_json_commit_id_has_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
1827 bundle = self._bundle_with_head(tmp_path)
1828 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1829 data = json.loads(result.output)
1830 for cid in data["heads"].values():
1831 assert cid.startswith("sha256:")
1832 assert len(cid) == len("sha256:") + 64
1833
1834 def test_json_is_single_line(self, tmp_path: pathlib.Path) -> None:
1835 """JSON output must be compact (no indent=2)."""
1836 bundle = self._bundle_with_head(tmp_path)
1837 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1838 assert "\n" not in result.output.strip()
1839
1840 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
1841 bundle = self._bundle_with_head(tmp_path)
1842 r1 = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1843 r2 = _invoke(["bundle", "list-heads", str(bundle), "-j"], env=_env(tmp_path))
1844 assert r1.exit_code == 0 and r2.exit_code == 0
1845 assert json.loads(r1.output)["heads"] == json.loads(r2.output)["heads"]
1846
1847 def test_json_empty_on_no_heads(self, tmp_path: pathlib.Path) -> None:
1848 _init_repo(tmp_path)
1849 _make_commit(tmp_path, content=b"lhe-empty-json")
1850 bundle = tmp_path / "ej.bundle"
1851 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1852 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1853 raw.pop("branch_heads", None)
1854 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1855 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1856 assert result.exit_code == 0
1857 assert json.loads(result.output)["heads"] == {}
1858
1859 def test_multiple_branches_all_listed_text(self, tmp_path: pathlib.Path) -> None:
1860 _init_repo(tmp_path)
1861 c1 = _make_commit(tmp_path, content=b"lhe-multi-base")
1862 for br in ("feat/a", "feat/b", "feat/c"):
1863 ref = ref_path(tmp_path, br)
1864 ref.parent.mkdir(parents=True, exist_ok=True)
1865 ref.write_text(c1, encoding="utf-8")
1866 bundle = tmp_path / "multi.bundle"
1867 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1868 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1869 assert result.exit_code == 0
1870 for br in ("feat/a", "feat/b", "feat/c"):
1871 assert br in result.output
1872
1873 def test_multiple_branches_all_in_json(self, tmp_path: pathlib.Path) -> None:
1874 _init_repo(tmp_path)
1875 c1 = _make_commit(tmp_path, content=b"lhe-multi-json")
1876 for br in ("feat/x", "feat/y"):
1877 ref = ref_path(tmp_path, br)
1878 ref.parent.mkdir(parents=True, exist_ok=True)
1879 ref.write_text(c1, encoding="utf-8")
1880 bundle = tmp_path / "multij.bundle"
1881 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1882 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1883 data = json.loads(result.output)
1884 assert "feat/x" in data["heads"]
1885 assert "feat/y" in data["heads"]
1886
1887 def test_text_cid_shows_sha256_prefix_plus_12(self, tmp_path: pathlib.Path) -> None:
1888 """Text output shows sha256: prefix + 12 hex chars abbreviated commit ID."""
1889 bundle = self._bundle_with_head(tmp_path)
1890 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1891 # Each non-empty line should start with sha256:<12hex>.
1892 for line in result.output.strip().splitlines():
1893 parts = line.split()
1894 assert parts[0].startswith("sha256:")
1895 assert len(parts[0]) == len("sha256:") + 12
1896
1897 def test_no_repo_required(self, tmp_path: pathlib.Path) -> None:
1898 """list-heads must work outside any .muse repository."""
1899 work = tmp_path / "no_repo_dir"
1900 work.mkdir()
1901 _init_repo(tmp_path)
1902 _make_commit(tmp_path, content=b"lhe-no-repo")
1903 bundle = tmp_path / "norepo.bundle"
1904 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1905 result = _invoke(["bundle", "list-heads", str(bundle)], env={"MUSE_REPO_ROOT": str(work)})
1906 assert result.exit_code != 2
1907
1908 def test_help_mentions_agent_quickstart(self) -> None:
1909 result = _invoke(["bundle", "list-heads", "--help"])
1910 assert result.exit_code == 0
1911 assert "Agent quickstart" in result.output
1912
1913 def test_help_mentions_exit_codes(self) -> None:
1914 result = _invoke(["bundle", "list-heads", "--help"])
1915 assert result.exit_code == 0
1916 assert "Exit codes" in result.output
1917
1918 def test_help_mentions_json_schema(self) -> None:
1919 result = _invoke(["bundle", "list-heads", "--help"])
1920 assert result.exit_code == 0
1921 assert "JSON output schema" in result.output
1922
1923 def test_missing_file_exits_1(self, tmp_path: pathlib.Path) -> None:
1924 _init_repo(tmp_path)
1925 result = _invoke(
1926 ["bundle", "list-heads", str(tmp_path / "ghost.bundle")],
1927 env=_env(tmp_path),
1928 )
1929 assert result.exit_code == 1
1930
1931
1932 # ===========================================================================
1933 # TestBundleListHeadsSecurity — 6 tests
1934 # ===========================================================================
1935
1936
1937 class TestBundleListHeadsSecurity:
1938 def test_ansi_branch_name_stripped_text(self, tmp_path: pathlib.Path) -> None:
1939 """ANSI escape in branch name must not appear in text output."""
1940 _init_repo(tmp_path)
1941 _make_commit(tmp_path, content=b"sec-lh-ansi")
1942 bundle = tmp_path / "ansi-br.bundle"
1943 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1944 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1945 raw["branch_heads"] = {"\x1b[31mmalicious\x1b[0m": "a" * 64}
1946 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1947 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1948 assert result.exit_code == 0
1949 assert "\x1b" not in result.output
1950
1951 def test_ansi_commit_id_stripped_text(self, tmp_path: pathlib.Path) -> None:
1952 """ANSI escape in a commit ID must not appear in text output (cid[:12])."""
1953 _init_repo(tmp_path)
1954 _make_commit(tmp_path, content=b"sec-lh-cid")
1955 bundle = tmp_path / "ansi-cid.bundle"
1956 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1957 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1958 raw["branch_heads"] = {"main": f"\x1b[31m{'a' * 64}\x1b[0m"}
1959 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1960 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1961 assert result.exit_code == 0
1962 assert "\x1b" not in result.output
1963
1964 def test_ansi_branch_name_stripped_json(self, tmp_path: pathlib.Path) -> None:
1965 """ANSI escape in branch name must not appear in JSON output."""
1966 _init_repo(tmp_path)
1967 _make_commit(tmp_path, content=b"sec-lh-ansi-json")
1968 bundle = tmp_path / "ansi-br-json.bundle"
1969 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1970 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1971 raw["branch_heads"] = {"\x1b[31mmalicious\x1b[0m": "b" * 64}
1972 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1973 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1974 assert result.exit_code == 0
1975 assert "\x1b" not in result.output
1976
1977 def test_ansi_commit_id_stripped_json(self, tmp_path: pathlib.Path) -> None:
1978 """ANSI escape in commit ID value must not appear in JSON output."""
1979 _init_repo(tmp_path)
1980 _make_commit(tmp_path, content=b"sec-lh-cid-json")
1981 bundle = tmp_path / "ansi-cid-json.bundle"
1982 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1983 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1984 raw["branch_heads"] = {"main": f"\x1b[32m{'c' * 64}\x1b[0m"}
1985 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1986 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1987 assert result.exit_code == 0
1988 assert "\x1b" not in result.output
1989
1990 def test_invalid_msgpack_exits_1(self, tmp_path: pathlib.Path) -> None:
1991 _init_repo(tmp_path)
1992 bad = tmp_path / "bad.bundle"
1993 bad.write_bytes(b"\xff\xfe garbage")
1994 result = _invoke(["bundle", "list-heads", str(bad)], env=_env(tmp_path))
1995 assert result.exit_code == 1
1996
1997 def test_no_json_on_missing_file(self, tmp_path: pathlib.Path) -> None:
1998 """Missing file error must not emit JSON to stdout."""
1999 _init_repo(tmp_path)
2000 result = _invoke(
2001 ["bundle", "list-heads", str(tmp_path / "missing.bundle"), "--json"],
2002 env=_env(tmp_path),
2003 )
2004 assert result.exit_code != 0
2005 assert not result.output.strip().startswith("{")
2006
2007
2008 # ===========================================================================
2009 # TestBundleListHeadsStress — 3 tests
2010 # ===========================================================================
2011
2012
2013 class TestBundleListHeadsStress:
2014 def test_50_branches_all_listed(self, tmp_path: pathlib.Path) -> None:
2015 """50 branch heads are all present in the JSON output."""
2016 _init_repo(tmp_path)
2017 c1 = _make_commit(tmp_path, content=b"lhstress-base")
2018 branch_names = [f"feat/stress-{i}" for i in range(50)]
2019 for br in branch_names:
2020 ref = ref_path(tmp_path, br)
2021 ref.parent.mkdir(parents=True, exist_ok=True)
2022 ref.write_text(c1, encoding="utf-8")
2023 bundle = tmp_path / "stress50.bundle"
2024 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
2025 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
2026 assert result.exit_code == 0
2027 data = json.loads(result.output)
2028 for br in branch_names:
2029 assert br in data["heads"]
2030
2031 def test_concurrent_reads_consistent(self, tmp_path: pathlib.Path) -> None:
2032 """Concurrent list-heads reads on the same bundle must all succeed."""
2033 _init_repo(tmp_path)
2034 _make_commit(tmp_path, content=b"lhstress-concurrent")
2035 bundle = tmp_path / "concurrent.bundle"
2036 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
2037 errors: list[str] = []
2038
2039 def _read() -> None:
2040 r = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
2041 if r.exit_code != 0:
2042 errors.append(f"exit {r.exit_code}")
2043 else:
2044 try:
2045 if not isinstance(json.loads(r.output), dict):
2046 errors.append("not a dict")
2047 except json.JSONDecodeError as exc:
2048 errors.append(str(exc))
2049
2050 threads = [threading.Thread(target=_read) for _ in range(10)]
2051 for t in threads:
2052 t.start()
2053 for t in threads:
2054 t.join()
2055 assert not errors, f"Concurrent failures: {errors}"
2056
2057 def test_large_bundle_list_heads_fast(self, tmp_path: pathlib.Path) -> None:
2058 """list-heads on a 200-commit bundle returns quickly (I/O, not compute)."""
2059 import time
2060 _init_repo(tmp_path)
2061 prev: str | None = None
2062 for i in range(200):
2063 prev = _make_commit(tmp_path, parent_id=prev, content=f"lhfast-{i}".encode())
2064 bundle = tmp_path / "fast200.bundle"
2065 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
2066 t0 = time.monotonic()
2067 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
2068 elapsed = time.monotonic() - t0
2069 assert result.exit_code == 0
2070 assert elapsed < 5.0, f"list-heads took {elapsed:.2f}s on 200-commit bundle"
2071 # ---------------------------------------------------------------------------
2072 # Flag registration tests
2073 # ---------------------------------------------------------------------------
2074
2075 import argparse as _argparse
2076 from muse.cli.commands.bundle import register as _register_bundle
2077 from muse.core.paths import heads_dir, muse_dir, ref_path
2078
2079
2080 def _parse_bundle(*args: str) -> _argparse.Namespace:
2081 """Build an argument parser via register() and parse args."""
2082 root_p = _argparse.ArgumentParser()
2083 subs = root_p.add_subparsers(dest="cmd")
2084 _register_bundle(subs)
2085 return root_p.parse_args(["bundle", *args])
2086
2087
2088 class TestRegisterFlags:
2089 def test_create_default_json_out_is_false(self) -> None:
2090 ns = _parse_bundle("create", "out.bundle")
2091 assert ns.json_out is False
2092
2093 def test_create_json_flag(self) -> None:
2094 ns = _parse_bundle("create", "out.bundle", "--json")
2095 assert ns.json_out is True
2096
2097 def test_create_j_shorthand(self) -> None:
2098 ns = _parse_bundle("create", "out.bundle", "-j")
2099 assert ns.json_out is True
2100
2101 def test_inspect_default_json_out_is_false(self) -> None:
2102 ns = _parse_bundle("inspect", "bundle.muse")
2103 assert ns.json_out is False
2104
2105 def test_inspect_j_shorthand(self) -> None:
2106 ns = _parse_bundle("inspect", "bundle.muse", "-j")
2107 assert ns.json_out is True
2108
2109 def test_verify_default_json_out_is_false(self) -> None:
2110 ns = _parse_bundle("verify", "bundle.muse")
2111 assert ns.json_out is False
2112
2113 def test_verify_j_shorthand(self) -> None:
2114 ns = _parse_bundle("verify", "bundle.muse", "-j")
2115 assert ns.json_out is True
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago