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